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
+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