Merge remote-tracking branch 'origin/main' into revert/component-types

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
Dmitri Dolguikh
2026-08-13 16:30:08 +02:00
80 changed files with 3083 additions and 554 deletions
@@ -0,0 +1,25 @@
// Package activity records that a principal used a reverse proxy service, so
// that activity accounting counts people and devices which reach services
// through the proxy but never touch the dashboard or the management API.
package activity
import (
"context"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
)
// Manager records reverse proxy usage against the timestamps activity
// accounting reads. Both methods are best effort from the caller's point of
// view: a lost record is corrected by the next request, and no authorization
// decision reads them back.
type Manager interface {
// RecordUserLogin records a completed SSO sign-in to a proxied service.
// Service users have no interactive login and are ignored.
RecordUserLogin(ctx context.Context, accountID string, user *types.User) error
// RecordPeerSeen records that a peer reached a private service over the
// mesh, which is what lets its owner count as active. Peers activity
// accounting excludes, and peers already seen recently, are ignored.
RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error
}
@@ -0,0 +1,66 @@
package manager
import (
"context"
"time"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// peerSeenInterval is how stale a peer's LastSeen must be before reaching a
// private service refreshes it. Positive tunnel validations are cached on the
// proxy for five minutes, so without a floor a busy peer would rewrite its row
// behind every request; an hour still sits well inside the window activity
// accounting asks about.
const peerSeenInterval = time.Hour
type managerImpl struct {
store store.Store
}
// NewManager returns the activity manager backed by the management store.
func NewManager(store store.Store) activity.Manager {
return &managerImpl{store: store}
}
// RecordUserLogin stamps the login the same way the dashboard and device login
// paths do, so a person who only ever reaches proxied services still has a
// login on record.
func (m *managerImpl) RecordUserLogin(ctx context.Context, accountID string, user *types.User) error {
if user == nil || user.IsServiceUser {
return nil
}
return m.store.SaveUserLastLogin(ctx, accountID, user.Id, time.Now().UTC())
}
// RecordPeerSeen stamps LastSeen, the column a peer activates its owner
// through. The peer the caller already holds answers the throttle without a
// query, so a peer seen inside the interval costs nothing to skip; the same
// cutoff goes to the store, which enforces it inside the UPDATE so concurrent
// requests for one peer cannot each write off their own stale read.
func (m *managerImpl) RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error {
if peer == nil || !countsTowardActivity(peer) {
return nil
}
staleBefore := time.Now().UTC().Add(-peerSeenInterval)
if peer.Status != nil && peer.Status.LastSeen.After(staleBefore) {
return nil
}
_, err := m.store.RefreshPeerLastSeen(ctx, accountID, peer.ID, staleBefore)
return err
}
// countsTowardActivity reports whether the peer represents a device a person
// actually runs. Embedded proxy peers are infrastructure and browser (WASM)
// clients are ephemeral sessions, so activity accounting ignores both and a
// write for them could never count.
func countsTowardActivity(peer *peer.Peer) bool {
return !peer.ProxyMeta.Embedded && peer.Meta.KernelVersion != "wasm"
}
@@ -0,0 +1,149 @@
package manager
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// recordingStore captures the two writes the activity manager makes. The
// embedded interface satisfies the rest and panics if anything else is called,
// which keeps the manager honest about its surface.
type recordingStore struct {
store.Store
logins []loginWrite
seen []seenWrite
}
type loginWrite struct {
accountID string
userID string
at time.Time
}
type seenWrite struct {
accountID string
peerID string
staleBefore time.Time
}
func (s *recordingStore) SaveUserLastLogin(_ context.Context, accountID, userID string, lastLogin time.Time) error {
s.logins = append(s.logins, loginWrite{accountID: accountID, userID: userID, at: lastLogin})
return nil
}
func (s *recordingStore) RefreshPeerLastSeen(_ context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
s.seen = append(s.seen, seenWrite{accountID: accountID, peerID: peerID, staleBefore: staleBefore})
return true, nil
}
func TestRecordUserLogin(t *testing.T) {
tests := []struct {
name string
user *types.User
expectWrite bool
}{
{
name: "regular user is recorded",
user: &types.User{Id: "user1", AccountID: "account1"},
expectWrite: true,
},
{
// Activity accounting never counts service users, so a row for one
// would be noise.
name: "service user is ignored",
user: &types.User{Id: "svc1", AccountID: "account1", IsServiceUser: true},
expectWrite: false,
},
{
name: "missing user is ignored",
user: nil,
expectWrite: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &recordingStore{}
require.NoError(t, NewManager(st).RecordUserLogin(context.Background(), "account1", tt.user))
if !tt.expectWrite {
assert.Empty(t, st.logins, "no login should have been recorded")
return
}
require.Len(t, st.logins, 1, "exactly one login should have been recorded")
assert.Equal(t, "account1", st.logins[0].accountID, "login must be recorded against the service account")
assert.Equal(t, tt.user.Id, st.logins[0].userID, "login must be recorded against the signing-in user")
assert.Equal(t, time.UTC, st.logins[0].at.Location(), "timestamps are written in UTC")
assert.WithinDuration(t, time.Now().UTC(), st.logins[0].at, time.Minute, "login should be stamped now")
})
}
}
func TestRecordPeerSeen(t *testing.T) {
tests := []struct {
name string
peer *peer.Peer
expectWrite bool
}{
{
name: "peer seen long ago is recorded",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: true,
},
{
name: "peer never seen is recorded",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{}},
expectWrite: true,
},
{
// The throttle. The caller already holds the peer, so skipping a
// recently seen one costs nothing.
name: "peer seen inside the interval is skipped",
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-10 * time.Minute)}},
expectWrite: false,
},
{
name: "embedded proxy peer is skipped",
peer: &peer.Peer{ID: "peer1", ProxyMeta: peer.ProxyMeta{Embedded: true}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: false,
},
{
name: "browser client is skipped",
peer: &peer.Peer{ID: "peer1", Meta: peer.PeerSystemMeta{KernelVersion: "wasm"}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
expectWrite: false,
},
{
name: "missing peer is ignored",
peer: nil,
expectWrite: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &recordingStore{}
require.NoError(t, NewManager(st).RecordPeerSeen(context.Background(), "account1", tt.peer))
if !tt.expectWrite {
assert.Empty(t, st.seen, "no activity should have been recorded")
return
}
require.Len(t, st.seen, 1, "exactly one activity write should have been recorded")
assert.Equal(t, "account1", st.seen[0].accountID, "activity must be recorded against the service account")
assert.Equal(t, tt.peer.ID, st.seen[0].peerID, "activity must be recorded against the calling peer")
assert.Equal(t, time.UTC, st.seen[0].staleBefore.Location(), "cutoffs are passed in UTC")
assert.WithinDuration(t, time.Now().UTC().Add(-peerSeenInterval), st.seen[0].staleBefore, time.Minute,
"the store must enforce the same interval the local check applies")
})
}
}
+10
View File
@@ -28,6 +28,8 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
@@ -254,6 +256,7 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
s.AfterInit(func(s *BaseServer) {
proxyService.SetServiceManager(s.ServiceManager())
proxyService.SetActivityManager(s.ProxyActivityManager())
proxyService.SetProxyController(s.ServiceProxyController())
proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store()))
proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager())
@@ -313,6 +316,13 @@ func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore {
})
}
// ProxyActivityManager records reverse proxy usage for activity accounting.
func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager {
return Create(s, func() proxyactivity.Manager {
return proxyactivitymanager.NewManager(s.Store())
})
}
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
return Create(s, func() accesslogs.Manager {
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())
+45 -1
View File
@@ -32,6 +32,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
@@ -128,6 +129,9 @@ type ProxyServiceServer struct {
// Manager for IdP-enriched user data (may be nil when no IdP is configured)
idpManager idp.Manager
// Manager that records reverse proxy usage for activity accounting
activityManager activity.Manager
// Store for one-time authentication tokens
tokenStore *OneTimeTokenStore
@@ -250,6 +254,13 @@ func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
s.serviceManager = manager
}
// SetActivityManager wires the manager that records reverse proxy usage.
func (s *ProxyServiceServer) SetActivityManager(manager activity.Manager) {
s.mu.Lock()
defer s.mu.Unlock()
s.activityManager = manager
}
// SetAgentNetworkSynthesizer wires the agent-network service synthesiser.
// Optional — when nil the snapshot path skips agent-network synthesis. The
// modules layer injects this after both the proxy server and the agent-network
@@ -1717,7 +1728,7 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return sessionkey.SignToken(
token, err := sessionkey.SignToken(
service.SessionPrivateKey,
userID,
user.Email,
@@ -1727,6 +1738,25 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
groupNames,
proxyauth.DefaultSessionExpiry,
)
if err != nil {
return "", err
}
s.recordUserLogin(ctx, service.AccountID, user)
return token, nil
}
// recordUserLogin hands the sign-in to the activity manager. The RPC must not
// fail on it, so the error is logged and dropped here rather than returned.
func (s *ProxyServiceServer) recordUserLogin(ctx context.Context, accountID string, user *types.User) {
if s.activityManager == nil {
return
}
if err := s.activityManager.RecordUserLogin(ctx, accountID, user); err != nil {
log.WithContext(ctx).Debugf("record proxy login for user %s: %v", user.Id, err)
}
}
// ValidateUserGroupAccess checks if a user has access to a service.
@@ -2076,6 +2106,8 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
return nil, err
}
s.recordPeerSeen(ctx, service.AccountID, peer)
log.WithFields(log.Fields{
"domain": domain,
"tunnel_ip": tunnelIPStr,
@@ -2093,6 +2125,18 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}, nil
}
// recordPeerSeen hands the mesh request to the activity manager. The RPC must
// not fail on it, so the error is logged and dropped here rather than returned.
func (s *ProxyServiceServer) recordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) {
if s.activityManager == nil {
return
}
if err := s.activityManager.RecordPeerSeen(ctx, accountID, peer); err != nil {
log.WithContext(ctx).Debugf("record proxy activity for peer %s: %v", peer.ID, err)
}
}
// resolvePeerOwner returns the user a peer is linked to, once per request so
// the status gate and the identity resolution below share a single lookup.
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -155,6 +156,27 @@ type mockTunnelPeersManager struct {
groupsErr error
}
// mockActivityManager records what the RPC handed to the activity manager. The
// policy (throttling, exclusions) is the manager's and is tested there; these
// tests only pin which requests reach it.
type mockActivityManager struct {
seenMarks []seenMark
}
type seenMark struct {
accountID string
peerID string
}
func (m *mockActivityManager) RecordUserLogin(_ context.Context, _ string, _ *types.User) error {
return nil
}
func (m *mockActivityManager) RecordPeerSeen(_ context.Context, accountID string, peer *peer.Peer) error {
m.seenMarks = append(m.seenMarks, seenMark{accountID: accountID, peerID: peer.ID})
return nil
}
func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
return m.peer, m.peerErr
}
@@ -745,6 +767,78 @@ func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
}
}
// TestValidateTunnelPeerRecordsActivity pins that a granted mesh request is
// handed to the activity manager. Which of those the manager then writes is its
// own decision, covered by its tests.
func TestValidateTunnelPeerRecordsActivity(t *testing.T) {
const (
domain = "app.example.com"
accountID = "account1"
peerID = "peer1"
)
activityManager := &mockActivityManager{}
server := &ProxyServiceServer{
activityManager: activityManager,
serviceManager: &mockReverseProxyManager{
proxiesByAccount: map[string][]*service.Service{
accountID: {{Domain: domain, AccountID: accountID}},
},
},
peersManager: &mockTunnelPeersManager{
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
},
usersManager: &mockUsersManager{users: map[string]*types.User{}},
}
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
Domain: domain,
TunnelIp: "100.64.0.1",
})
require.NoError(t, err)
require.True(t, resp.GetValid(), "peer should be granted access")
require.Len(t, activityManager.seenMarks, 1, "a granted peer should reach the activity manager once")
assert.Equal(t, accountID, activityManager.seenMarks[0].accountID, "activity must be attributed to the service account")
assert.Equal(t, peerID, activityManager.seenMarks[0].peerID, "activity must be attributed to the calling peer")
}
// TestValidateTunnelPeerDeniedRecordsNoActivity keeps the write on the granted
// path only: a refused peer is not evidence its owner was active.
func TestValidateTunnelPeerDeniedRecordsNoActivity(t *testing.T) {
const (
domain = "app.example.com"
accountID = "account1"
)
activityManager := &mockActivityManager{}
server := &ProxyServiceServer{
activityManager: activityManager,
serviceManager: &mockReverseProxyManager{
proxiesByAccount: map[string][]*service.Service{
accountID: {{Domain: domain, AccountID: accountID}},
},
},
peersManager: &mockTunnelPeersManager{
peer: &peer.Peer{ID: "peer1", Name: "agent", UserID: "user1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
},
// The owner is blocked, so the tunnel gate denies before the mint.
usersManager: &mockUsersManager{users: map[string]*types.User{
"user1": {Id: "user1", AccountID: accountID, Blocked: true},
}},
}
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
Domain: domain,
TunnelIp: "100.64.0.1",
})
require.NoError(t, err)
require.False(t, resp.GetValid(), "blocked owner should be denied")
assert.Empty(t, activityManager.seenMarks, "a denied peer must not be marked seen")
}
func TestGetAccountProxyByDomain(t *testing.T) {
tests := []struct {
name string