mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 21:09:07 +02:00
Merge remote-tracking branch 'origin/main' into embedded-vnc
# Conflicts: # client/cmd/up.go # client/internal/engine.go # client/internal/profilemanager/config.go # client/proto/daemon.pb.go # client/server/mdm.go # client/server/server.go # client/status/status.go # client/ui/main.go # client/ui/services/windowmanager.go # management/internals/shared/grpc/conversion.go # management/server/store/sql_store.go # management/server/store/sql_store_test.go # shared/management/networkmap/envelope.go # shared/management/proto/management.pb.go
This commit is contained in:
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/shared/requestbuffer"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -60,8 +59,6 @@ type Controller struct {
|
||||
|
||||
requestBuffer account.RequestBuffer
|
||||
|
||||
proxyController port_forwarding.Controller
|
||||
|
||||
integratedPeerValidator integrated_validator.IntegratedValidator
|
||||
|
||||
serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
|
||||
@@ -87,7 +84,7 @@ type bufferAffectedUpdate struct {
|
||||
|
||||
var _ network_map.Controller = (*Controller)(nil)
|
||||
|
||||
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
|
||||
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
|
||||
nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("error creating metrics: %w", err))
|
||||
@@ -104,11 +101,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
|
||||
dnsDomain: dnsDomain,
|
||||
config: config,
|
||||
|
||||
proxyController: proxyController,
|
||||
EphemeralPeersManager: ephemeralPeersManager,
|
||||
serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
|
||||
perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
|
||||
nmdataStore: nmdataStore,
|
||||
nmdataStore: nmdataStore,
|
||||
}
|
||||
|
||||
if nmdataStore != nil {
|
||||
@@ -131,14 +127,20 @@ func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peer
|
||||
return c.peersUpdateManager.CreateChannel(ctx, peerID), nil
|
||||
}
|
||||
|
||||
func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, peerID string) {
|
||||
c.peersUpdateManager.CloseChannel(ctx, peerID)
|
||||
// OnPeerDisconnected closes the session's updates channel and schedules an ephemeral peer for
|
||||
// cleanup. It returns false without touching anything when a newer session owns the peer. A nil
|
||||
// session closes any registered channel.
|
||||
func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, peerID string, session chan *network_map.UpdateMessage) bool {
|
||||
if !c.peersUpdateManager.CloseSessionChannel(ctx, peerID, session) {
|
||||
return false
|
||||
}
|
||||
peer, err := c.repo.GetPeerByID(ctx, accountID, peerID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get peer %s: %v", peerID, err)
|
||||
return
|
||||
return true
|
||||
}
|
||||
c.EphemeralPeersManager.OnPeerDisconnected(ctx, peer)
|
||||
return true
|
||||
}
|
||||
|
||||
// injectAllProxyPolicies prepares an account for the per-peer network-map
|
||||
@@ -226,12 +228,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return fmt.Errorf("failed to get proxy network maps: %v", err)
|
||||
}
|
||||
|
||||
extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get flow enabled status: %v", err)
|
||||
@@ -273,7 +269,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
start = time.Now()
|
||||
|
||||
peerGroups := account.GetPeerGroups(p.ID)
|
||||
proxyNetworkMap := proxyNetworkMaps[p.ID]
|
||||
var update *proto.SyncResponse
|
||||
|
||||
commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
|
||||
@@ -294,10 +289,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
|
||||
// the client merges it into Calculate()'s output the same
|
||||
// way the legacy server did via NetworkMap.Merge.
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
|
||||
@@ -313,10 +305,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
if proxyNetworkMap != nil {
|
||||
nmap.Merge(proxyNetworkMap)
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.metrics.CountToSyncResponseDuration(time.Since(start))
|
||||
@@ -451,7 +439,7 @@ func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string,
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
|
||||
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
|
||||
@@ -684,12 +672,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return fmt.Errorf("failed to get proxy network maps: %v", err)
|
||||
}
|
||||
|
||||
extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get flow enabled status: %v", err)
|
||||
@@ -722,7 +704,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
|
||||
start = time.Now()
|
||||
|
||||
peerGroups := account.GetPeerGroups(p.ID)
|
||||
proxyNetworkMap := proxyNetworkMaps[p.ID]
|
||||
var update *proto.SyncResponse
|
||||
|
||||
commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
|
||||
@@ -743,10 +724,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
start = time.Now()
|
||||
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
|
||||
// the client merges it into Calculate()'s output the same
|
||||
// way the legacy server did via NetworkMap.Merge.
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
|
||||
@@ -762,10 +740,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
|
||||
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
if proxyNetworkMap != nil {
|
||||
nmap.Merge(proxyNetworkMap)
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.metrics.CountToSyncResponseDuration(time.Since(start))
|
||||
@@ -843,19 +817,12 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
|
||||
return fmt.Errorf("failed to get posture checks for peer %s: %v", peerId, err)
|
||||
}
|
||||
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
proxyNetworkMap := proxyNetworkMaps[peer.ID]
|
||||
extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get extra settings: %v", err)
|
||||
@@ -881,10 +848,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
|
||||
components := account.GetPeerNetworkMapComponents(
|
||||
ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
|
||||
|
||||
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
|
||||
// the client merges it into Calculate()'s output the same
|
||||
// way the legacy server did via NetworkMap.Merge.
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
|
||||
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
|
||||
Update: update,
|
||||
@@ -897,10 +861,6 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
|
||||
nmap := account.GetPeerNetworkMapFromComponents(
|
||||
ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
|
||||
|
||||
if proxyNetworkMap != nil {
|
||||
nmap.Merge(proxyNetworkMap)
|
||||
}
|
||||
|
||||
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
|
||||
@@ -951,17 +911,16 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
|
||||
|
||||
// GetValidatedPeerWithComponents is the components-format counterpart of
|
||||
// GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable
|
||||
// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding
|
||||
// data the legacy server folds in via NetworkMap.Merge). The gRPC layer
|
||||
// encodes both into the wire envelope. Callers must gate on capability
|
||||
// themselves before dispatching here — this method does NOT branch on it.
|
||||
func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
// peers, which the gRPC layer encodes into the wire envelope. Callers must
|
||||
// gate on capability themselves before dispatching here — this method does
|
||||
// NOT branch on it.
|
||||
func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) {
|
||||
if isRequiresApproval {
|
||||
network, err := c.repo.GetAccountNetwork(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
|
||||
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, 0, nil
|
||||
}
|
||||
|
||||
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
|
||||
@@ -970,39 +929,29 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
|
||||
|
||||
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
// it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case
|
||||
if _, ok := account.Peers[peer.ID]; !ok {
|
||||
return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID)
|
||||
return nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID)
|
||||
}
|
||||
|
||||
c.injectAllProxyPolicies(ctx, account)
|
||||
|
||||
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
postureChecks, err := c.getPeerPostureChecks(account, peer.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
// Fetch the proxy network map fragment for this peer alongside the
|
||||
// components — same single-account-load path the streaming controller
|
||||
// uses, so initial-sync delivers BYOP/forwarding patches synchronously
|
||||
// instead of waiting for the next streaming push.
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return nil, nil, nil, nil, 0, err
|
||||
return nil, nil, nil, 0, err
|
||||
}
|
||||
|
||||
dnsDomain := c.GetDNSDomain(account.Settings)
|
||||
@@ -1014,13 +963,12 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
|
||||
components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
|
||||
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
|
||||
|
||||
return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
|
||||
return peer, components, postureChecks, dnsFwdPort, nil
|
||||
}
|
||||
|
||||
// getValidatedPeerWithComponentsFromData is the account-free variant of
|
||||
// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
|
||||
// like on the other nmdata paths.
|
||||
func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
// GetValidatedPeerWithComponents.
|
||||
func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) {
|
||||
postureChecks := peerPostureChecksFromData(nmData, peer.ID)
|
||||
|
||||
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
|
||||
@@ -1029,7 +977,7 @@ func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context,
|
||||
components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
|
||||
dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
|
||||
|
||||
return peer, components, nil, postureChecks, dnsFwdPort, nil
|
||||
return peer, components, postureChecks, dnsFwdPort, nil
|
||||
}
|
||||
|
||||
// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
|
||||
@@ -1173,22 +1121,11 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
|
||||
dnsDomain := c.GetDNSDomain(account.Settings)
|
||||
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
|
||||
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
|
||||
|
||||
proxyNetworkMap, ok := proxyNetworkMaps[peerID]
|
||||
if ok {
|
||||
networkMap.Merge(proxyNetworkMap)
|
||||
}
|
||||
|
||||
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
|
||||
|
||||
return networkMap, postureChecks, dnsFwdPort, nil
|
||||
@@ -1457,23 +1394,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
|
||||
dnsDomain := c.GetDNSDomain(account.Settings)
|
||||
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
|
||||
|
||||
proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.injectAllProxyPolicies(ctx, account)
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
|
||||
|
||||
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
|
||||
if ok {
|
||||
networkMap.Merge(proxyNetworkMap)
|
||||
}
|
||||
|
||||
return networkMap, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
)
|
||||
|
||||
type recordingEphemeralManager struct {
|
||||
disconnected []string
|
||||
}
|
||||
|
||||
func (r *recordingEphemeralManager) LoadInitialPeers(context.Context) {}
|
||||
func (r *recordingEphemeralManager) Stop() {}
|
||||
func (r *recordingEphemeralManager) OnPeerConnected(context.Context, *nbpeer.Peer) {}
|
||||
func (r *recordingEphemeralManager) OnPeerDisconnected(_ context.Context, peer *nbpeer.Peer) {
|
||||
r.disconnected = append(r.disconnected, peer.ID)
|
||||
}
|
||||
|
||||
func TestOnPeerDisconnected_OwnSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewMockRepository(gomock.NewController(t))
|
||||
ephemeralManager := &recordingEphemeralManager{}
|
||||
updateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
c := Controller{repo: repo, peersUpdateManager: updateManager, EphemeralPeersManager: ephemeralManager}
|
||||
|
||||
session := updateManager.CreateChannel(ctx, "peer-1")
|
||||
repo.EXPECT().GetPeerByID(gomock.Any(), "account-1", "peer-1").Return(&nbpeer.Peer{ID: "peer-1", Ephemeral: true}, nil)
|
||||
|
||||
require.True(t, c.OnPeerDisconnected(ctx, "account-1", "peer-1", session))
|
||||
assert.False(t, updateManager.HasChannel("peer-1"))
|
||||
assert.Equal(t, []string{"peer-1"}, ephemeralManager.disconnected)
|
||||
}
|
||||
|
||||
func TestOnPeerDisconnected_NilSessionClosesOlderChannel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := NewMockRepository(gomock.NewController(t))
|
||||
ephemeralManager := &recordingEphemeralManager{}
|
||||
updateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
c := Controller{repo: repo, peersUpdateManager: updateManager, EphemeralPeersManager: ephemeralManager}
|
||||
|
||||
older := updateManager.CreateChannel(ctx, "peer-1")
|
||||
repo.EXPECT().GetPeerByID(gomock.Any(), "account-1", "peer-1").Return(&nbpeer.Peer{ID: "peer-1", Ephemeral: true}, nil)
|
||||
|
||||
require.True(t, c.OnPeerDisconnected(ctx, "account-1", "peer-1", nil))
|
||||
assert.False(t, updateManager.HasChannel("peer-1"))
|
||||
_, open := <-older
|
||||
assert.False(t, open, "older channel must be closed")
|
||||
assert.Equal(t, []string{"peer-1"}, ephemeralManager.disconnected)
|
||||
}
|
||||
|
||||
func TestOnPeerDisconnected_NewerSessionOwnsPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ephemeralManager := &recordingEphemeralManager{}
|
||||
updateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
c := Controller{repo: NewMockRepository(gomock.NewController(t)), peersUpdateManager: updateManager, EphemeralPeersManager: ephemeralManager}
|
||||
|
||||
stale := updateManager.CreateChannel(ctx, "peer-1")
|
||||
current := updateManager.CreateChannel(ctx, "peer-1")
|
||||
|
||||
require.False(t, c.OnPeerDisconnected(ctx, "account-1", "peer-1", stale))
|
||||
require.True(t, updateManager.HasChannel("peer-1"))
|
||||
updateManager.SendUpdate(ctx, "peer-1", &network_map.UpdateMessage{})
|
||||
select {
|
||||
case _, open := <-current:
|
||||
assert.True(t, open, "newer session channel must stay open")
|
||||
default:
|
||||
t.Fatal("newer session channel did not receive the update")
|
||||
}
|
||||
assert.Empty(t, ephemeralManager.disconnected, "a live peer must not be scheduled for ephemeral cleanup")
|
||||
}
|
||||
@@ -122,11 +122,10 @@ func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) {
|
||||
}
|
||||
|
||||
mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil)
|
||||
peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"})
|
||||
peer, components, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"})
|
||||
|
||||
assert.Nil(t, peer)
|
||||
assert.Nil(t, components)
|
||||
assert.Nil(t, netmap)
|
||||
assert.Nil(t, posturechecks)
|
||||
assert.Equal(t, int64(0), dnsforwardPort)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (r *repository) GetAccountNetwork(ctx context.Context, accountID string) (*
|
||||
}
|
||||
|
||||
func (r *repository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) {
|
||||
return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
}
|
||||
|
||||
func (r *repository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) {
|
||||
|
||||
@@ -89,6 +89,21 @@ func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetAccountServices mocks base method.
|
||||
func (m *MockRepository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAccountServices", ctx, accountID)
|
||||
ret0, _ := ret[0].([]*service.Service)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAccountServices indicates an expected call of GetAccountServices.
|
||||
func (mr *MockRepositoryMockRecorder) GetAccountServices(ctx, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockRepository)(nil).GetAccountServices), ctx, accountID)
|
||||
}
|
||||
|
||||
// GetAccountZones mocks base method.
|
||||
func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -24,7 +24,7 @@ type Controller interface {
|
||||
UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
|
||||
BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
|
||||
GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error)
|
||||
GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error)
|
||||
GetDNSDomain(settings *types.Settings) string
|
||||
StartWarmup(context.Context)
|
||||
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
|
||||
@@ -35,7 +35,10 @@ type Controller interface {
|
||||
OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
|
||||
DisconnectPeers(ctx context.Context, accountId string, peerIDs []string)
|
||||
OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *UpdateMessage, error)
|
||||
OnPeerDisconnected(ctx context.Context, accountID string, peerID string)
|
||||
// OnPeerDisconnected tears down the stream state of the peer's session, identified by its
|
||||
// updates channel. It returns false and leaves everything untouched when a newer session
|
||||
// owns the peer. A nil session is the newest session and tears down any registered channel.
|
||||
OnPeerDisconnected(ctx context.Context, accountID string, peerID string, session chan *UpdateMessage) bool
|
||||
|
||||
TrackEphemeralPeer(ctx context.Context, peer *nbpeer.Peer)
|
||||
}
|
||||
|
||||
@@ -127,16 +127,15 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
|
||||
}
|
||||
|
||||
// GetValidatedPeerWithComponents mocks base method.
|
||||
func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) {
|
||||
func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p)
|
||||
ret0, _ := ret[0].(*peer.Peer)
|
||||
ret1, _ := ret[1].(*types.NetworkMapComponents)
|
||||
ret2, _ := ret[2].(*types.NetworkMap)
|
||||
ret3, _ := ret[3].([]*nmdata.PostureChecks)
|
||||
ret4, _ := ret[4].(int64)
|
||||
ret5, _ := ret[5].(error)
|
||||
return ret0, ret1, ret2, ret3, ret4, ret5
|
||||
ret2, _ := ret[2].([]*nmdata.PostureChecks)
|
||||
ret3, _ := ret[3].(int64)
|
||||
ret4, _ := ret[4].(error)
|
||||
return ret0, ret1, ret2, ret3, ret4
|
||||
}
|
||||
|
||||
// GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents.
|
||||
@@ -178,15 +177,17 @@ func (mr *MockControllerMockRecorder) OnPeerConnected(ctx, accountID, peerID any
|
||||
}
|
||||
|
||||
// OnPeerDisconnected mocks base method.
|
||||
func (m *MockController) OnPeerDisconnected(ctx context.Context, accountID, peerID string) {
|
||||
func (m *MockController) OnPeerDisconnected(ctx context.Context, accountID, peerID string, session chan *UpdateMessage) bool {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "OnPeerDisconnected", ctx, accountID, peerID)
|
||||
ret := m.ctrl.Call(m, "OnPeerDisconnected", ctx, accountID, peerID, session)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// OnPeerDisconnected indicates an expected call of OnPeerDisconnected.
|
||||
func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID any) *gomock.Call {
|
||||
func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID, session any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockController)(nil).OnPeerDisconnected), ctx, accountID, peerID)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockController)(nil).OnPeerDisconnected), ctx, accountID, peerID, session)
|
||||
}
|
||||
|
||||
// OnPeersAdded mocks base method.
|
||||
|
||||
@@ -123,7 +123,6 @@ func canonicalize(nm *proto.NetworkMap) {
|
||||
slices.SortFunc(nm.Routes, cmpRoute)
|
||||
slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
|
||||
slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
|
||||
slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
|
||||
|
||||
for _, r := range nm.FirewallRules {
|
||||
slices.SortFunc(r.SourcePrefixes, bytes.Compare)
|
||||
@@ -353,16 +352,6 @@ func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
|
||||
return boolCmp(a.IsDynamic, b.IsDynamic)
|
||||
}
|
||||
|
||||
func cmpForwardingRule(a, b *proto.ForwardingRule) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
|
||||
}
|
||||
|
||||
func portInfoKey(pi *proto.PortInfo) string {
|
||||
if pi == nil {
|
||||
return ""
|
||||
|
||||
@@ -426,6 +426,9 @@ func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string)
|
||||
}
|
||||
out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
|
||||
}
|
||||
if def.CertificateCheck != nil {
|
||||
out.Checks.CertificateCheck = &posture.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkma
|
||||
case ModeEnvelope:
|
||||
components := nmData.GetPeerNetworkMapComponents(peerID, zone)
|
||||
peerGroups := maps.Keys(nmData.GetPeerGroups(peerID))
|
||||
resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil,
|
||||
resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components,
|
||||
dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort)
|
||||
res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain, false)
|
||||
require.NoError(t, err, "expand envelope")
|
||||
|
||||
@@ -6,6 +6,10 @@ type PeersUpdateManager interface {
|
||||
SendUpdate(ctx context.Context, peerID string, update *UpdateMessage)
|
||||
CreateChannel(ctx context.Context, peerID string) chan *UpdateMessage
|
||||
CloseChannel(ctx context.Context, peerID string)
|
||||
// CloseSessionChannel closes the peer's channel and returns true, unless a channel of
|
||||
// another session is registered, in which case it returns false and closes nothing.
|
||||
// A nil session is the newest session and closes any registered channel.
|
||||
CloseSessionChannel(ctx context.Context, peerID string, session chan *UpdateMessage) bool
|
||||
CountStreams() int
|
||||
HasChannel(peerID string) bool
|
||||
CloseChannels(ctx context.Context, peerIDs []string)
|
||||
|
||||
@@ -133,6 +133,30 @@ func (p *PeersUpdateManager) CloseChannel(ctx context.Context, peerID string) {
|
||||
p.closeChannel(ctx, peerID)
|
||||
}
|
||||
|
||||
// CloseSessionChannel closes the peer's updates channel only while it is still session's channel.
|
||||
// It returns false when a newer stream has registered a different channel, which the stale
|
||||
// session must leave to its new owner. A nil session belongs to a stream that failed before
|
||||
// registering its own channel; it is the newest session and closes any channel still registered.
|
||||
func (p *PeersUpdateManager) CloseSessionChannel(ctx context.Context, peerID string, session chan *network_map.UpdateMessage) bool {
|
||||
start := time.Now()
|
||||
|
||||
p.channelsMux.Lock()
|
||||
defer func() {
|
||||
p.channelsMux.Unlock()
|
||||
if p.metrics != nil {
|
||||
p.metrics.UpdateChannelMetrics().CountCloseChannelDuration(time.Since(start))
|
||||
}
|
||||
}()
|
||||
|
||||
if channel, ok := p.peerChannels[peerID]; ok && session != nil && channel != session {
|
||||
log.WithContext(ctx).Debugf("skipped closing updates channel: peer %s is owned by a newer session", peerID)
|
||||
return false
|
||||
}
|
||||
|
||||
p.closeChannel(ctx, peerID)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAllConnectedPeers returns a copy of the connected peers map
|
||||
func (p *PeersUpdateManager) GetAllConnectedPeers() map[string]struct{} {
|
||||
start := time.Now()
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
@@ -84,3 +87,55 @@ func TestCloseChannel(t *testing.T) {
|
||||
t.Error("Error closing the channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSessionChannel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const peer = "test-close-session"
|
||||
|
||||
t.Run("own channel is closed", func(t *testing.T) {
|
||||
peersUpdater := NewPeersUpdateManager(nil)
|
||||
session := peersUpdater.CreateChannel(ctx, peer)
|
||||
|
||||
require.True(t, peersUpdater.CloseSessionChannel(ctx, peer, session))
|
||||
assert.False(t, peersUpdater.HasChannel(peer))
|
||||
_, open := <-session
|
||||
assert.False(t, open, "own channel must be closed")
|
||||
})
|
||||
|
||||
t.Run("newer session channel is kept", func(t *testing.T) {
|
||||
peersUpdater := NewPeersUpdateManager(nil)
|
||||
stale := peersUpdater.CreateChannel(ctx, peer)
|
||||
current := peersUpdater.CreateChannel(ctx, peer)
|
||||
|
||||
require.False(t, peersUpdater.CloseSessionChannel(ctx, peer, stale))
|
||||
require.True(t, peersUpdater.HasChannel(peer))
|
||||
assert.Equal(t, current, peersUpdater.peerChannels[peer])
|
||||
|
||||
peersUpdater.SendUpdate(ctx, peer, &network_map.UpdateMessage{})
|
||||
select {
|
||||
case _, open := <-current:
|
||||
assert.True(t, open, "newer session channel must stay open")
|
||||
default:
|
||||
t.Fatal("newer session channel did not receive the update")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no registered channel", func(t *testing.T) {
|
||||
peersUpdater := NewPeersUpdateManager(nil)
|
||||
session := peersUpdater.CreateChannel(ctx, peer)
|
||||
peersUpdater.CloseChannel(ctx, peer)
|
||||
|
||||
assert.True(t, peersUpdater.CloseSessionChannel(ctx, peer, session))
|
||||
assert.True(t, peersUpdater.CloseSessionChannel(ctx, peer, nil))
|
||||
})
|
||||
|
||||
t.Run("nil session closes the registered channel", func(t *testing.T) {
|
||||
peersUpdater := NewPeersUpdateManager(nil)
|
||||
current := peersUpdater.CreateChannel(ctx, peer)
|
||||
|
||||
require.True(t, peersUpdater.CloseSessionChannel(ctx, peer, nil))
|
||||
assert.False(t, peersUpdater.HasChannel(peer))
|
||||
_, open := <-current
|
||||
assert.False(t, open, "registered channel must be closed")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// TestCleanupAccessLogs_RealStore_DeletedAccount covers a deleted account's access logs.
|
||||
// The sweep is driven by settings rows, which go with the account, so without a fallback
|
||||
// those logs would never expire. They get the default retention instead. A live account
|
||||
// can delete its own settings row, so "no settings" must not be mistaken for "deleted":
|
||||
// that account's logs are left alone, as are those of an account that keeps logs forever.
|
||||
func TestCleanupAccessLogs_RealStore_DeletedAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
deletedAccountID = "acc-deleted"
|
||||
keepAccountID = "acc-keep-forever"
|
||||
noSettingsAccountID = "acc-live-no-settings"
|
||||
)
|
||||
old := time.Now().UTC().AddDate(0, 0, -(types.DefaultAccessLogRetentionDays + 10))
|
||||
recent := time.Now().UTC().AddDate(0, 0, -1)
|
||||
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: keepAccountID}))
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: noSettingsAccountID}))
|
||||
|
||||
keepSettings := types.DefaultSettings(keepAccountID)
|
||||
keepSettings.Domain = "keep.gw.example.com"
|
||||
keepSettings.AccessLogRetentionDays = 0
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, keepSettings))
|
||||
|
||||
mkLog := func(id, accountID string, ts time.Time) {
|
||||
t.Helper()
|
||||
entry := &types.AgentNetworkAccessLog{
|
||||
ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, StatusCode: 200, Model: "gpt-4o",
|
||||
}
|
||||
groups := []types.AgentNetworkAccessLogGroup{{LogID: id, GroupID: "grp-eng", AccountID: accountID}}
|
||||
require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, entry, groups))
|
||||
}
|
||||
mkLog("deleted-old", deletedAccountID, old)
|
||||
mkLog("deleted-recent", deletedAccountID, recent)
|
||||
mkLog("keep-old", keepAccountID, old)
|
||||
mkLog("no-settings-old", noSettingsAccountID, old)
|
||||
|
||||
m := &managerImpl{store: s}
|
||||
m.cleanupAccessLogsOnce(ctx)
|
||||
|
||||
logIDs := func(accountID string) []string {
|
||||
t.Helper()
|
||||
logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID,
|
||||
types.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50})
|
||||
require.NoError(t, err)
|
||||
ids := make([]string, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
assert.Equal(t, []string{"deleted-recent"}, logIDs(deletedAccountID),
|
||||
"a deleted account should have logs past the default retention swept")
|
||||
assert.Equal(t, []string{"keep-old"}, logIDs(keepAccountID),
|
||||
"an account with retention disabled should keep its old logs")
|
||||
assert.Equal(t, []string{"no-settings-old"}, logIDs(noSettingsAccountID),
|
||||
"a live account without a settings row should keep its old logs")
|
||||
}
|
||||
@@ -80,6 +80,9 @@ type Manager interface {
|
||||
ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error)
|
||||
GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error)
|
||||
StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int)
|
||||
// RemoveAccountGateway drops the account's gateway mappings from the
|
||||
// proxies. It runs as an account deletion hook.
|
||||
RemoveAccountGateway(ctx context.Context, accountID string) error
|
||||
RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error
|
||||
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
|
||||
RecordUsage(ctx context.Context, in RecordUsageInput) error
|
||||
@@ -1350,8 +1353,8 @@ func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID
|
||||
|
||||
// StartAccessLogCleanup launches a background sweep that periodically deletes
|
||||
// each account's agent-network access-log rows older than that account's
|
||||
// AccessLogRetentionDays. Usage records are never swept. A non-positive
|
||||
// interval defaults to 24h.
|
||||
// AccessLogRetentionDays, and the consumption counters of deleted accounts.
|
||||
// Usage records are never swept. A non-positive interval defaults to 24h.
|
||||
func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) {
|
||||
if cleanupIntervalHours <= 0 {
|
||||
cleanupIntervalHours = 24
|
||||
@@ -1362,21 +1365,40 @@ func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupInterval
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
m.cleanupAccessLogsOnce(ctx) // run once on startup
|
||||
m.cleanupOnce(ctx) // run once on startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.cleanupAccessLogsOnce(ctx)
|
||||
m.cleanupOnce(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *managerImpl) cleanupOnce(ctx context.Context) {
|
||||
m.cleanupAccessLogsOnce(ctx)
|
||||
m.cleanupDeletedAccountConsumption(ctx)
|
||||
}
|
||||
|
||||
// cleanupDeletedAccountConsumption deletes the consumption counters of accounts
|
||||
// that no longer exist. Best-effort: a failure is logged and retried next sweep.
|
||||
func (m *managerImpl) cleanupDeletedAccountConsumption(ctx context.Context) {
|
||||
deleted, err := m.store.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("agent-network consumption cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.WithContext(ctx).Infof("agent-network consumption cleanup: deleted %d counters of deleted accounts", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupAccessLogsOnce sweeps every account's expired access-log rows against
|
||||
// its configured retention. Best-effort: a per-account failure is logged and
|
||||
// the sweep continues.
|
||||
// its configured retention. Deleted accounts, whose settings rows went with
|
||||
// them, get the default retention. Best-effort: a per-account failure is
|
||||
// logged and the sweep continues.
|
||||
func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) {
|
||||
settings, err := m.store.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone)
|
||||
if err != nil {
|
||||
@@ -1384,18 +1406,31 @@ func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, s := range settings {
|
||||
if s.AccessLogRetentionDays <= 0 {
|
||||
continue // keep indefinitely
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -s.AccessLogRetentionDays)
|
||||
deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, s.AccountID, cutoff)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", s.AccountID, err)
|
||||
continue
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, s.AccountID, s.AccessLogRetentionDays)
|
||||
}
|
||||
m.cleanupAccountAccessLogs(ctx, s.AccountID, s.AccessLogRetentionDays)
|
||||
}
|
||||
|
||||
deleted, err := m.store.GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("agent-network access-log cleanup: list deleted accounts: %v", err)
|
||||
return
|
||||
}
|
||||
for _, accountID := range deleted {
|
||||
m.cleanupAccountAccessLogs(ctx, accountID, types.DefaultAccessLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managerImpl) cleanupAccountAccessLogs(ctx context.Context, accountID string, retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
return // keep indefinitely
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
|
||||
deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, accountID, cutoff)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", accountID, err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, accountID, retentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1545,6 +1580,8 @@ func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.Age
|
||||
|
||||
func (*mockManager) StartAccessLogCleanup(_ context.Context, _ int) {}
|
||||
|
||||
func (*mockManager) RemoveAccountGateway(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (*mockManager) RecordConsumption(_ context.Context, _ string, _ types.ConsumptionDimension, _ string, _, _, _ int64, _ float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
goproto "google.golang.org/protobuf/proto"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
@@ -81,18 +83,66 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
|
||||
}
|
||||
m.reconcileMu.Unlock()
|
||||
|
||||
for _, entry := range creates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
m.sendMappings(ctx, accountID, creates, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
|
||||
m.sendMappings(ctx, accountID, updates, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
|
||||
m.sendMappings(ctx, accountID, deletes, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED)
|
||||
}
|
||||
|
||||
// sendMappings sends each entry as updateType. It sends a copy: the entries'
|
||||
// mappings are shared with reconcileCache, which another reconcile or
|
||||
// RemoveAccountGateway may be reading, so they are never written.
|
||||
func (m *managerImpl) sendMappings(ctx context.Context, accountID string, entries []syntheticMapping, updateType proto.ProxyMappingUpdateType) {
|
||||
for _, entry := range entries {
|
||||
update := goproto.Clone(entry.mapping).(*proto.ProxyMapping)
|
||||
update.Type = updateType
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, update, entry.cluster)
|
||||
}
|
||||
for _, entry := range updates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
|
||||
// RemoveAccountGateway tells the proxies to drop every mapping of the account's
|
||||
// gateway, so a deleted account's proxy config, provider API keys included, does
|
||||
// not linger in proxy memory until the next resync. It is an account deletion
|
||||
// hook: it runs before the account's data is removed, the last point at which
|
||||
// the mappings can be synthesised from the store. The cache alone would miss
|
||||
// them, since it is per instance and empty after a restart. If the deletion
|
||||
// then fails, the gateway stays down until the account's next change reconciles
|
||||
// it back.
|
||||
func (m *managerImpl) RemoveAccountGateway(ctx context.Context, accountID string) error {
|
||||
if m.proxyController == nil {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range deletes {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
|
||||
services, err := SynthesizeServices(ctx, m.store, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("synthesise agent network services: %w", err)
|
||||
}
|
||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||
removed := make(map[string]syntheticMapping, len(services))
|
||||
for _, svc := range services {
|
||||
if svc == nil || svc.ID == "" {
|
||||
continue
|
||||
}
|
||||
removed[svc.ID] = syntheticMapping{
|
||||
mapping: svc.ToProtoMapping(rpservice.Delete, "", oidcCfg),
|
||||
cluster: svc.ProxyCluster,
|
||||
}
|
||||
}
|
||||
|
||||
m.reconcileMu.Lock()
|
||||
for id, entry := range m.reconcileCache[accountID] {
|
||||
if _, ok := removed[id]; !ok {
|
||||
removed[id] = entry
|
||||
}
|
||||
}
|
||||
delete(m.reconcileCache, accountID)
|
||||
m.reconcileMu.Unlock()
|
||||
|
||||
entries := make([]syntheticMapping, 0, len(removed))
|
||||
for _, entry := range removed {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
m.sendMappings(ctx, accountID, entries, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED)
|
||||
return nil
|
||||
}
|
||||
|
||||
// diffMappings classifies the previous→current transition for a single
|
||||
|
||||
@@ -2,6 +2,8 @@ package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) {
|
||||
@@ -287,3 +290,154 @@ func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) {
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveAccountGateway_EmitsRemovedFromStore — account deletion runs on an
|
||||
// instance that may never have reconciled the account, so its cache is empty.
|
||||
// The mappings are synthesised from the store, still intact before the delete,
|
||||
// and each is sent as REMOVED to the cluster that serves it.
|
||||
func TestRemoveAccountGateway_EmitsRemovedFromStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
provider := newReconcileTestProvider()
|
||||
policy := newReconcileTestPolicy(provider.ID, "grp-eng")
|
||||
|
||||
expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{})
|
||||
|
||||
var sent []*proto.ProxyMapping
|
||||
mockProxy.EXPECT().
|
||||
SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io").
|
||||
Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) {
|
||||
sent = append(sent, m)
|
||||
})
|
||||
|
||||
require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1"))
|
||||
|
||||
require.Len(t, sent, 1, "the account's one gateway mapping must be removed")
|
||||
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type, "the update must be a removal")
|
||||
assert.Equal(t, "agent-net-svc-acct-1", sent[0].Id, "the removal must name the account's gateway service")
|
||||
}
|
||||
|
||||
// TestRemoveAccountGateway_AlsoRemovesCachedMappings — a mapping this instance
|
||||
// last sent but the store no longer synthesises (here, one on another cluster)
|
||||
// is removed too, and the account's cache entry is cleared.
|
||||
func TestRemoveAccountGateway_AlsoRemovesCachedMappings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
mgr.reconcileCache["acct-1"] = map[string]syntheticMapping{
|
||||
"stale-svc": {mapping: &proto.ProxyMapping{Id: "stale-svc"}, cluster: "us.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
// Settings but no providers: the store synthesises nothing.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(newReconcileTestSettings(), nil)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return([]*types.Provider{}, nil)
|
||||
mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{})
|
||||
|
||||
var sent []*proto.ProxyMapping
|
||||
mockProxy.EXPECT().
|
||||
SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "us.proxy.netbird.io").
|
||||
Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) {
|
||||
sent = append(sent, m)
|
||||
})
|
||||
|
||||
require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1"))
|
||||
|
||||
require.Len(t, sent, 1, "the cached mapping must be removed from its own cluster")
|
||||
assert.Equal(t, "stale-svc", sent[0].Id)
|
||||
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type)
|
||||
mgr.reconcileMu.Lock()
|
||||
_, present := mgr.reconcileCache["acct-1"]
|
||||
mgr.reconcileMu.Unlock()
|
||||
assert.False(t, present, "the deleted account's cache entry must be cleared")
|
||||
}
|
||||
|
||||
// TestRemoveAccountGateway_SynthFailureAbortsDeletion — if the mappings cannot
|
||||
// be read, nothing is sent and the error is returned, which as an account
|
||||
// deletion hook keeps the account rather than leaving its gateway running.
|
||||
func TestRemoveAccountGateway_SynthFailureAbortsDeletion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, _ := newReconcileMgr(t, ctrl)
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(nil, status.Errorf(status.Internal, "store unavailable"))
|
||||
|
||||
assert.Error(t, mgr.RemoveAccountGateway(ctx, "acct-1"), "a failed synthesis must fail the hook")
|
||||
}
|
||||
|
||||
func TestRemoveAccountGateway_NilProxyController_NoOp(t *testing.T) {
|
||||
mgr := &managerImpl{reconcileCache: make(map[string]map[string]syntheticMapping)}
|
||||
// Must not panic and must not query the store.
|
||||
assert.NoError(t, mgr.RemoveAccountGateway(context.Background(), "acct-1"))
|
||||
}
|
||||
|
||||
// TestReconcile_ConcurrentWithGatewayChanges — while an account's gateway
|
||||
// flaps (its policy is removed and re-added between reads), concurrent
|
||||
// reconciles and RemoveAccountGateway share the cached mappings: one caches a
|
||||
// mapping and sends it, another finds it gone and sends its removal. Run under
|
||||
// -race: neither path may write a cached mapping, only copies of it.
|
||||
func TestReconcile_ConcurrentWithGatewayChanges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
// gomock serialises every call on the controller's mutex, which would give
|
||||
// the race detector the ordering the code under test lacks. The sends go
|
||||
// through a fake that takes no lock.
|
||||
mgr.proxyController = unsyncedSender{MockController: mockProxy}
|
||||
provider := newReconcileTestProvider()
|
||||
policy := newReconcileTestPolicy(provider.ID, "grp-eng")
|
||||
|
||||
var reads atomic.Int64
|
||||
mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil).AnyTimes()
|
||||
mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil).AnyTimes()
|
||||
mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").
|
||||
DoAndReturn(func(context.Context, store.LockingStrength, string) ([]*types.Policy, error) {
|
||||
if reads.Add(1)%2 == 0 {
|
||||
return []*types.Policy{}, nil
|
||||
}
|
||||
return []*types.Policy{policy}, nil
|
||||
}).AnyTimes()
|
||||
mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil).AnyTimes()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(remove bool) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
if remove && j%10 == 0 {
|
||||
_ = mgr.RemoveAccountGateway(ctx, "acct-1")
|
||||
continue
|
||||
}
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
}
|
||||
}(i == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// unsyncedSender answers the calls reconcile makes on every pass without any
|
||||
// locking, so concurrent callers are not ordered by the fake itself.
|
||||
type unsyncedSender struct {
|
||||
*proxy.MockController
|
||||
}
|
||||
|
||||
func (unsyncedSender) GetOIDCValidationConfig() proxy.OIDCValidationConfig {
|
||||
return proxy.OIDCValidationConfig{}
|
||||
}
|
||||
|
||||
func (unsyncedSender) SendServiceUpdateToCluster(context.Context, string, *proto.ProxyMapping, string) {}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string)
|
||||
return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
|
||||
}
|
||||
|
||||
return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "")
|
||||
return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
@@ -18,14 +19,16 @@ import (
|
||||
)
|
||||
|
||||
type managerImpl struct {
|
||||
repo accesslogs.Repository
|
||||
store store.Store
|
||||
permissionsManager permissions.Manager
|
||||
geo geolocation.Geolocation
|
||||
cleanupCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager {
|
||||
func NewManager(repo accesslogs.Repository, store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager {
|
||||
return &managerImpl{
|
||||
repo: repo,
|
||||
store: store,
|
||||
permissionsManager: permissionsManager,
|
||||
geo: geo,
|
||||
@@ -54,7 +57,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.store.CreateAccessLog(ctx, logEntry); err != nil {
|
||||
if err := m.repo.Create(ctx, logEntry); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"service_id": logEntry.ServiceID,
|
||||
"method": logEntry.Method,
|
||||
@@ -82,7 +85,7 @@ func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID st
|
||||
log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err)
|
||||
}
|
||||
|
||||
logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter)
|
||||
logs, totalCount, err := m.repo.ListByAccount(ctx, db.LockingStrengthNone, accountID, *filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays in
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
|
||||
deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime)
|
||||
deletedCount, err := m.repo.DeleteOlderThan(ctx, cutoffTime)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err)
|
||||
return 0, err
|
||||
|
||||
@@ -5,27 +5,27 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
)
|
||||
|
||||
func TestCleanupOldAccessLogs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
retentionDays int
|
||||
setupMock func(*store.MockStore)
|
||||
setupMock func(*accesslogs.MockRepository)
|
||||
expectedCount int64
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "cleanup logs older than retention period",
|
||||
retentionDays: 30,
|
||||
setupMock: func(mockStore *store.MockStore) {
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
setupMock: func(mockRepo *accesslogs.MockRepository) {
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
expectedCutoff := time.Now().AddDate(0, 0, -30)
|
||||
timeDiff := olderThan.Sub(expectedCutoff)
|
||||
@@ -41,9 +41,9 @@ func TestCleanupOldAccessLogs(t *testing.T) {
|
||||
{
|
||||
name: "no logs to cleanup",
|
||||
retentionDays: 30,
|
||||
setupMock: func(mockStore *store.MockStore) {
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
setupMock: func(mockRepo *accesslogs.MockRepository) {
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(0), nil)
|
||||
},
|
||||
expectedCount: 0,
|
||||
@@ -52,8 +52,8 @@ func TestCleanupOldAccessLogs(t *testing.T) {
|
||||
{
|
||||
name: "zero retention days skips cleanup",
|
||||
retentionDays: 0,
|
||||
setupMock: func(mockStore *store.MockStore) {
|
||||
// No expectations - DeleteOldAccessLogs should not be called
|
||||
setupMock: func(mockRepo *accesslogs.MockRepository) {
|
||||
// No expectations - DeleteOlderThan should not be called
|
||||
},
|
||||
expectedCount: 0,
|
||||
expectedError: false,
|
||||
@@ -61,8 +61,8 @@ func TestCleanupOldAccessLogs(t *testing.T) {
|
||||
{
|
||||
name: "negative retention days skips cleanup",
|
||||
retentionDays: -10,
|
||||
setupMock: func(mockStore *store.MockStore) {
|
||||
// No expectations - DeleteOldAccessLogs should not be called
|
||||
setupMock: func(mockRepo *accesslogs.MockRepository) {
|
||||
// No expectations - DeleteOlderThan should not be called
|
||||
},
|
||||
expectedCount: 0,
|
||||
expectedError: false,
|
||||
@@ -74,11 +74,11 @@ func TestCleanupOldAccessLogs(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
tt.setupMock(mockStore)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
tt.setupMock(mockRepo)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -98,10 +98,10 @@ func TestCleanupWithExactBoundary(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
expectedCutoff := time.Now().AddDate(0, 0, -30)
|
||||
timeDiff := olderThan.Sub(expectedCutoff)
|
||||
@@ -110,7 +110,7 @@ func TestCleanupWithExactBoundary(t *testing.T) {
|
||||
})
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -125,11 +125,11 @@ func TestStartPeriodicCleanup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
// No expectations - cleanup should not run
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -139,22 +139,22 @@ func TestStartPeriodicCleanup(t *testing.T) {
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// If DeleteOldAccessLogs was called, the test will fail due to unexpected call
|
||||
// If DeleteOlderThan was called, the test will fail due to unexpected call
|
||||
})
|
||||
|
||||
t.Run("periodic cleanup runs immediately on start", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(2), nil).
|
||||
Times(1)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -171,15 +171,15 @@ func TestStartPeriodicCleanup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(1), nil).
|
||||
Times(1)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -198,15 +198,15 @@ func TestStartPeriodicCleanup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(0), nil).
|
||||
Times(1)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -223,15 +223,15 @@ func TestStartPeriodicCleanup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(3), nil).
|
||||
Times(1)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -249,15 +249,15 @@ func TestStopPeriodicCleanup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockRepo := accesslogs.NewMockRepository(ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
DeleteOldAccessLogs(gomock.Any(), gomock.Any()).
|
||||
mockRepo.EXPECT().
|
||||
DeleteOlderThan(gomock.Any(), gomock.Any()).
|
||||
Return(int64(1), nil).
|
||||
Times(1)
|
||||
|
||||
manager := &managerImpl{
|
||||
store: mockStore,
|
||||
repo: mockRepo,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type sqlRepository struct {
|
||||
conn *db.Conn
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRepository returns the access log repository backed by conn.
|
||||
func NewRepository(conn *db.Conn) accesslogs.Repository {
|
||||
return &sqlRepository{conn: conn, db: conn.DB(nil)}
|
||||
}
|
||||
|
||||
func (r *sqlRepository) WithTx(tx *db.Tx) accesslogs.Repository {
|
||||
return &sqlRepository{conn: r.conn, db: r.conn.DB(tx)}
|
||||
}
|
||||
|
||||
func (r *sqlRepository) Create(ctx context.Context, entry *accesslogs.AccessLogEntry) error {
|
||||
if err := r.db.Create(entry).Error; err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"service_id": entry.ServiceID,
|
||||
"method": entry.Method,
|
||||
"host": entry.Host,
|
||||
"path": entry.Path,
|
||||
}).Errorf("failed to create access log entry in store: %v", err)
|
||||
return status.Errorf(status.Internal, "failed to create access log entry in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByAccount returns one page of an account's access logs together with the
|
||||
// total number of entries matching the filter.
|
||||
func (r *sqlRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
|
||||
var totalCount int64
|
||||
countQuery := applyFilters(r.db.Model(&accesslogs.AccessLogEntry{}).Where("account_id = ?", accountID), filter)
|
||||
if err := countQuery.Count(&totalCount).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to count access logs: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to count access logs")
|
||||
}
|
||||
|
||||
query := applyFilters(r.db.Where("account_id = ?", accountID), filter)
|
||||
sortOrder := strings.ToUpper(filter.GetSortOrder())
|
||||
for _, column := range strings.Split(filter.GetSortColumn(), ",") {
|
||||
if column = strings.TrimSpace(column); column != "" {
|
||||
query = query.Order(column + " " + sortOrder)
|
||||
}
|
||||
}
|
||||
query = query.Limit(filter.GetLimit()).Offset(filter.GetOffset())
|
||||
if lockStrength != db.LockingStrengthNone {
|
||||
query = query.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var logs []*accesslogs.AccessLogEntry
|
||||
if err := query.Find(&logs).Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get access logs from store: %v", err)
|
||||
return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store")
|
||||
}
|
||||
|
||||
return logs, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *sqlRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
result := r.db.Where("timestamp < ?", olderThan).Delete(&accesslogs.AccessLogEntry{})
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error)
|
||||
return 0, status.Errorf(status.Internal, "failed to delete old access logs")
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func applyFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB {
|
||||
if filter.Search != nil {
|
||||
searchPattern := "%" + *filter.Search + "%"
|
||||
query = query.Where(
|
||||
"id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
if filter.SourceIP != nil {
|
||||
query = query.Where("location_connection_ip = ?", *filter.SourceIP)
|
||||
}
|
||||
|
||||
if filter.Host != nil {
|
||||
query = query.Where("host = ?", *filter.Host)
|
||||
}
|
||||
|
||||
if filter.Path != nil {
|
||||
query = query.Where("path LIKE ?", "%"+*filter.Path+"%")
|
||||
}
|
||||
|
||||
if filter.UserID != nil {
|
||||
query = query.Where("user_id = ?", *filter.UserID)
|
||||
}
|
||||
|
||||
if filter.Method != nil {
|
||||
query = query.Where("method = ?", *filter.Method)
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
switch *filter.Status {
|
||||
case "success":
|
||||
query = query.Where("(status_code >= ? AND status_code < ?)", 200, 400)
|
||||
case "failed":
|
||||
query = query.Where("((status_code >= ? AND status_code < ?) OR status_code >= ?)", 100, 200, 400)
|
||||
}
|
||||
}
|
||||
|
||||
if filter.StatusCode != nil {
|
||||
query = query.Where("status_code = ?", *filter.StatusCode)
|
||||
}
|
||||
|
||||
if filter.StartDate != nil {
|
||||
query = query.Where("timestamp >= ?", *filter.StartDate)
|
||||
}
|
||||
|
||||
if filter.EndDate != nil {
|
||||
query = query.Where("timestamp <= ?", *filter.EndDate)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db/dbtest"
|
||||
)
|
||||
|
||||
func newTestRepository(t *testing.T) (accesslogs.Repository, *db.Conn) {
|
||||
conn := dbtest.NewConn(t, &accesslogs.AccessLogEntry{})
|
||||
return NewRepository(conn), conn
|
||||
}
|
||||
|
||||
func newEntry(id, accountID, method string, age time.Duration) *accesslogs.AccessLogEntry {
|
||||
return &accesslogs.AccessLogEntry{
|
||||
ID: id,
|
||||
AccountID: accountID,
|
||||
Method: method,
|
||||
Host: "app.example.com",
|
||||
Path: "/",
|
||||
StatusCode: 200,
|
||||
Timestamp: time.Now().Add(-age),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlRepository_ListByAccount(t *testing.T) {
|
||||
repo, _ := newTestRepository(t)
|
||||
ctx := context.Background()
|
||||
for _, entry := range []*accesslogs.AccessLogEntry{
|
||||
newEntry("a1", "acc-a", "GET", 3*time.Hour),
|
||||
newEntry("a2", "acc-a", "POST", 2*time.Hour),
|
||||
newEntry("a3", "acc-a", "GET", time.Hour),
|
||||
newEntry("b1", "acc-b", "GET", time.Hour),
|
||||
} {
|
||||
require.NoError(t, repo.Create(ctx, entry))
|
||||
}
|
||||
|
||||
logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 2})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 3, total)
|
||||
require.Len(t, logs, 2)
|
||||
assert.Equal(t, "a3", logs[0].ID)
|
||||
assert.Equal(t, "a2", logs[1].ID)
|
||||
|
||||
method := "GET"
|
||||
logs, total, err = repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Method: &method, SortOrder: "asc"})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, total)
|
||||
require.Len(t, logs, 2)
|
||||
assert.Equal(t, "a1", logs[0].ID)
|
||||
assert.Equal(t, "a3", logs[1].ID)
|
||||
}
|
||||
|
||||
func TestSqlRepository_DeleteOlderThan(t *testing.T) {
|
||||
repo, _ := newTestRepository(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, repo.Create(ctx, newEntry("old", "acc", "GET", 48*time.Hour)))
|
||||
require.NoError(t, repo.Create(ctx, newEntry("new", "acc", "GET", time.Hour)))
|
||||
|
||||
deleted, err := repo.DeleteOlderThan(ctx, time.Now().Add(-24*time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, deleted)
|
||||
|
||||
logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, total)
|
||||
require.Len(t, logs, 1)
|
||||
assert.Equal(t, "new", logs[0].ID)
|
||||
}
|
||||
|
||||
func TestSqlRepository_CreateInsideTransactionRollsBack(t *testing.T) {
|
||||
repo, conn := newTestRepository(t)
|
||||
ctx := context.Background()
|
||||
failure := errors.New("abort")
|
||||
|
||||
err := conn.RunInTx(ctx, func(tx *db.Tx) error {
|
||||
txRepo := repo.WithTx(tx)
|
||||
require.NoError(t, txRepo.Create(ctx, newEntry("tx", "acc", "GET", 0)))
|
||||
_, total, err := txRepo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, total)
|
||||
return failure
|
||||
})
|
||||
require.ErrorIs(t, err, failure)
|
||||
|
||||
_, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, total)
|
||||
}
|
||||
|
||||
func TestSqlRepository_ListByAccount_StatusFilter(t *testing.T) {
|
||||
repo, _ := newTestRepository(t)
|
||||
ctx := context.Background()
|
||||
statusCodes := map[string]int{"l4": 0, "info": 101, "ok": 200, "notfound": 404}
|
||||
for id, code := range statusCodes {
|
||||
entry := newEntry(id, "acc", "GET", time.Hour)
|
||||
entry.StatusCode = code
|
||||
require.NoError(t, repo.Create(ctx, entry))
|
||||
}
|
||||
foreign := newEntry("foreign", "other", "GET", time.Hour)
|
||||
foreign.StatusCode = 500
|
||||
require.NoError(t, repo.Create(ctx, foreign))
|
||||
|
||||
listIDs := func(status string) []string {
|
||||
logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Status: &status, SortBy: "status_code", SortOrder: "asc"})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, len(logs), total)
|
||||
ids := make([]string, 0, len(logs))
|
||||
for _, entry := range logs {
|
||||
ids = append(ids, entry.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"info", "notfound"}, listIDs("failed"))
|
||||
assert.Equal(t, []string{"ok"}, listIDs("success"))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package accesslogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod
|
||||
|
||||
// Repository persists reverse proxy access log entries.
|
||||
type Repository interface {
|
||||
WithTx(tx *db.Tx) Repository
|
||||
Create(ctx context.Context, entry *AccessLogEntry) error
|
||||
ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error)
|
||||
DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./repository.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod
|
||||
//
|
||||
|
||||
// Package accesslogs is a generated GoMock package.
|
||||
package accesslogs
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
db "github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRepository is a mock of Repository interface.
|
||||
type MockRepository struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRepositoryMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRepositoryMockRecorder is the mock recorder for MockRepository.
|
||||
type MockRepositoryMockRecorder struct {
|
||||
mock *MockRepository
|
||||
}
|
||||
|
||||
// NewMockRepository creates a new mock instance.
|
||||
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
|
||||
mock := &MockRepository{ctrl: ctrl}
|
||||
mock.recorder = &MockRepositoryMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Create mocks base method.
|
||||
func (m *MockRepository) Create(ctx context.Context, entry *AccessLogEntry) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Create", ctx, entry)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Create indicates an expected call of Create.
|
||||
func (mr *MockRepositoryMockRecorder) Create(ctx, entry any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, entry)
|
||||
}
|
||||
|
||||
// DeleteOlderThan mocks base method.
|
||||
func (m *MockRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteOlderThan", ctx, olderThan)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteOlderThan indicates an expected call of DeleteOlderThan.
|
||||
func (mr *MockRepositoryMockRecorder) DeleteOlderThan(ctx, olderThan any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOlderThan", reflect.TypeOf((*MockRepository)(nil).DeleteOlderThan), ctx, olderThan)
|
||||
}
|
||||
|
||||
// ListByAccount mocks base method.
|
||||
func (m *MockRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListByAccount", ctx, lockStrength, accountID, filter)
|
||||
ret0, _ := ret[0].([]*AccessLogEntry)
|
||||
ret1, _ := ret[1].(int64)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// ListByAccount indicates an expected call of ListByAccount.
|
||||
func (mr *MockRepositoryMockRecorder) ListByAccount(ctx, lockStrength, accountID, filter any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByAccount", reflect.TypeOf((*MockRepository)(nil).ListByAccount), ctx, lockStrength, accountID, filter)
|
||||
}
|
||||
|
||||
// WithTx mocks base method.
|
||||
func (m *MockRepository) WithTx(tx *db.Tx) Repository {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "WithTx", tx)
|
||||
ret0, _ := ret[0].(Repository)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// WithTx indicates an expected call of WithTx.
|
||||
func (mr *MockRepositoryMockRecorder) WithTx(tx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithTx", reflect.TypeOf((*MockRepository)(nil).WithTx), tx)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
)
|
||||
|
||||
func TestDeleteDomain_ServiceDependencies(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
domainName string
|
||||
serviceHost string
|
||||
accountID string
|
||||
enabled bool
|
||||
protected bool
|
||||
}{
|
||||
{"exact", "example.com", "example.com", accountA, true, true},
|
||||
{"subdomain", "example.com", "deep.app.example.com", accountA, true, true},
|
||||
{"disabled", "example.com", "app.example.com", accountA, false, true},
|
||||
// A service is authorized by its own account's registration, so another
|
||||
// account's service under this namespace is not a dependency of it.
|
||||
{"other account", "example.com", "app.example.com", accountB, true, false},
|
||||
{"case and trailing dot", "example.com", "APP.EXAMPLE.COM.", accountA, true, true},
|
||||
{"suffix boundary", "example.com", "notexample.com", accountA, true, false},
|
||||
{"literal underscore", "a_b.example.com", "app.a_b.example.com", accountA, true, true},
|
||||
{"underscore wildcard", "a_b.example.com", "app.axb.example.com", accountA, true, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
events := captureDomainEvents(env)
|
||||
d, err := env.store.CreateCustomDomain(ctx, accountA, tt.domainName, testCluster, true)
|
||||
require.NoError(t, err)
|
||||
svc := &rpservice.Service{
|
||||
ID: "dependent", AccountID: tt.accountID, Domain: tt.serviceHost,
|
||||
Enabled: tt.enabled, ProxyCluster: testCluster,
|
||||
}
|
||||
require.NoError(t, env.store.CreateService(ctx, svc))
|
||||
router := mux.NewRouter()
|
||||
RegisterEndpoints(router, env.manager)
|
||||
deleteDomain := func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/domains/"+d.ID, nil)
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: accountA, UserId: accountAUser})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, req)
|
||||
return response
|
||||
}
|
||||
|
||||
response := deleteDomain()
|
||||
if tt.protected {
|
||||
require.Equal(t, http.StatusPreconditionFailed, response.Code, "dependent services must block deletion: %s", response.Body.String())
|
||||
assert.NotContains(t, response.Body.String(), tt.accountID, "the error must not reveal the service's account")
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountA, d.Domain), "the namespace must remain reserved")
|
||||
assert.Empty(t, events.get(), "rejected deletion must not emit DomainDeleted")
|
||||
stored, err := env.store.GetServiceByID(ctx, nbstore.LockingStrengthNone, tt.accountID, svc.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, svc.Enabled, stored.Enabled, "rejected deletion must preserve the service")
|
||||
require.NoError(t, env.store.DeleteService(ctx, tt.accountID, svc.ID))
|
||||
response = deleteDomain()
|
||||
}
|
||||
require.Equal(t, http.StatusNoContent, response.Code, "deletion must succeed without dependencies: %s", response.Body.String())
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "the registration must be deleted")
|
||||
captured := events.get()
|
||||
require.Len(t, captured, 1, "only successful deletion may emit an event")
|
||||
assert.Equal(t, activity.DomainDeleted, captured[0].Activity, "the event must describe the successful deletion")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,26 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
|
||||
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
|
||||
}
|
||||
|
||||
// ValidateServiceDomain holds custom domain authorization through a service write transaction.
|
||||
func (m Manager) ValidateServiceDomain(ctx context.Context, tx nbstore.Store, accountID, serviceDomain, cluster string) error {
|
||||
if _, ok := ExtractClusterFromFreeDomain(serviceDomain, []string{cluster}); ok {
|
||||
return nil
|
||||
}
|
||||
name, err := nbdomain.FromString(serviceDomain)
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid service domain: %v", err)
|
||||
}
|
||||
customDomains, err := tx.LockCustomDomains(ctx, accountID, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, match := extractClusterFromCustomDomains(serviceDomain, customDomains)
|
||||
if match != customDomainValidated || target != cluster {
|
||||
return status.Errorf(status.PreconditionFailed, "custom domain authorization changed; retry the service operation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) {
|
||||
byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -99,7 +99,7 @@ func setupDomainTest(t *testing.T) *domainTestEnv {
|
||||
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil)
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", "", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resolver := &stubResolver{cnames: make(map[string]string)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// Manager defines the interface for proxy operations
|
||||
type Manager interface {
|
||||
Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error)
|
||||
Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error)
|
||||
Disconnect(ctx context.Context, proxyID, sessionID string) error
|
||||
Heartbeat(ctx context.Context, p *Proxy) error
|
||||
GetActiveClusterAddresses(ctx context.Context) ([]string, error)
|
||||
@@ -20,6 +20,8 @@ type Manager interface {
|
||||
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool
|
||||
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
|
||||
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)
|
||||
CountAccountProxies(ctx context.Context, accountID string) (int64, error)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// store defines the interface for proxy persistence operations
|
||||
@@ -22,6 +23,8 @@ type store interface {
|
||||
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error)
|
||||
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
|
||||
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
|
||||
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
|
||||
@@ -29,6 +32,8 @@ type store interface {
|
||||
DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error
|
||||
}
|
||||
|
||||
const minSessionCodeVersion = "0.81.0"
|
||||
|
||||
// Manager handles all proxy operations
|
||||
type Manager struct {
|
||||
store store
|
||||
@@ -50,7 +55,7 @@ func NewManager(store store, meter metric.Meter) (*Manager, error) {
|
||||
|
||||
// Connect registers a new proxy connection in the database.
|
||||
// capabilities may be nil for old proxies that do not report them.
|
||||
func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) {
|
||||
func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) {
|
||||
now := time.Now()
|
||||
var caps proxy.Capabilities
|
||||
if capabilities != nil {
|
||||
@@ -61,6 +66,7 @@ func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddres
|
||||
SessionID: sessionID,
|
||||
ClusterAddress: clusterAddress,
|
||||
IPAddress: ipAddress,
|
||||
Version: truncateVersion(version),
|
||||
AccountID: accountID,
|
||||
LastSeen: now,
|
||||
ConnectedAt: &now,
|
||||
@@ -78,6 +84,7 @@ func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddres
|
||||
"sessionID": sessionID,
|
||||
"clusterAddress": clusterAddress,
|
||||
"ipAddress": ipAddress,
|
||||
"version": p.Version,
|
||||
}).Info("proxy connected")
|
||||
|
||||
return p, nil
|
||||
@@ -143,6 +150,27 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string)
|
||||
return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterAllProxiesPrivate reports whether every active proxy claims the private capability (nil = unreported).
|
||||
func (m Manager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool {
|
||||
return m.store.GetClusterAllProxiesPrivate(ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterSupportsSessionCode reports whether all active proxies support session codes.
|
||||
func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool {
|
||||
versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr)
|
||||
if err != nil || len(versions) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, version := range versions {
|
||||
if supported, err := nbversion.MeetsMinVersion(minSessionCodeVersion, version); err != nil || !supported {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CleanupStale removes proxies that haven't sent heartbeat in the specified duration
|
||||
func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error {
|
||||
if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil {
|
||||
@@ -184,3 +212,13 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, acco
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateVersion cuts a proxy-reported version to the column width so an
|
||||
// oversized value cannot fail the save and block the connect.
|
||||
func truncateVersion(version string) string {
|
||||
runes := []rune(version)
|
||||
if len(runes) <= proxy.MaxVersionLength {
|
||||
return version
|
||||
}
|
||||
return string(runes[:proxy.MaxVersionLength])
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -20,6 +22,7 @@ type mockStore struct {
|
||||
updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error
|
||||
getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error)
|
||||
getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error)
|
||||
getActiveProxyVersionsFunc func(ctx context.Context, clusterAddress string) ([]string, error)
|
||||
cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error
|
||||
getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error)
|
||||
countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error)
|
||||
@@ -102,6 +105,15 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo
|
||||
func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) GetClusterAllProxiesPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) {
|
||||
if m.getActiveProxyVersionsFunc != nil {
|
||||
return m.getActiveProxyVersionsFunc(ctx, clusterAddress)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newTestManager(s store) *Manager {
|
||||
meter := noop.NewMeterProvider().Meter("test")
|
||||
@@ -112,6 +124,34 @@ func newTestManager(s store) *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestClusterSupportsSessionCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
versions []string
|
||||
storeErr error
|
||||
want bool
|
||||
}{
|
||||
{name: "all supported", versions: []string{"0.81.0", "0.81.2"}, want: true},
|
||||
{name: "one old proxy", versions: []string{"0.81.0", "0.80.0"}},
|
||||
{name: "missing version", versions: []string{"0.81.0", ""}},
|
||||
{name: "no active proxies"},
|
||||
{name: "store error", storeErr: errors.New("db error")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &mockStore{
|
||||
getActiveProxyVersionsFunc: func(_ context.Context, _ string) ([]string, error) {
|
||||
return tt.versions, tt.storeErr
|
||||
},
|
||||
}
|
||||
|
||||
got := newTestManager(s).ClusterSupportsSessionCode(context.Background(), "cluster.example.com")
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_WithAccountID(t *testing.T) {
|
||||
accountID := "acc-123"
|
||||
|
||||
@@ -124,7 +164,7 @@ func TestConnect_WithAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
mgr := newTestManager(s)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", &accountID, nil)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", "0.60.0", &accountID, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, savedProxy)
|
||||
@@ -132,6 +172,7 @@ func TestConnect_WithAccountID(t *testing.T) {
|
||||
assert.Equal(t, "session-1", savedProxy.SessionID)
|
||||
assert.Equal(t, "cluster.example.com", savedProxy.ClusterAddress)
|
||||
assert.Equal(t, "10.0.0.1", savedProxy.IPAddress)
|
||||
assert.Equal(t, "0.60.0", savedProxy.Version, "reported proxy version should be stored")
|
||||
assert.Equal(t, &accountID, savedProxy.AccountID)
|
||||
assert.Equal(t, proxy.StatusConnected, savedProxy.Status)
|
||||
assert.NotNil(t, savedProxy.ConnectedAt)
|
||||
@@ -147,7 +188,7 @@ func TestConnect_WithoutAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
mgr := newTestManager(s)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "eu.proxy.netbird.io", "10.0.0.1", nil, nil)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "eu.proxy.netbird.io", "10.0.0.1", "", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, savedProxy)
|
||||
@@ -155,6 +196,29 @@ func TestConnect_WithoutAccountID(t *testing.T) {
|
||||
assert.Equal(t, proxy.StatusConnected, savedProxy.Status)
|
||||
}
|
||||
|
||||
func TestConnect_TruncatesOversizedVersion(t *testing.T) {
|
||||
var savedProxy *proxy.Proxy
|
||||
s := &mockStore{
|
||||
saveProxyFunc: func(_ context.Context, p *proxy.Proxy) error {
|
||||
savedProxy = p
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Multi-byte runes make sure the cut counts characters, as varchar does,
|
||||
// and never splits a rune into invalid UTF-8.
|
||||
version := strings.Repeat("ü", proxy.MaxVersionLength+10)
|
||||
|
||||
mgr := newTestManager(s)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", version, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, savedProxy)
|
||||
assert.Equal(t, proxy.MaxVersionLength, utf8.RuneCountInString(savedProxy.Version), "stored version should be cut to the column width")
|
||||
assert.True(t, utf8.ValidString(savedProxy.Version), "stored version should remain valid UTF-8")
|
||||
assert.True(t, strings.HasPrefix(version, savedProxy.Version), "stored version should be a prefix of the reported one")
|
||||
}
|
||||
|
||||
func TestConnect_StoreError(t *testing.T) {
|
||||
s := &mockStore{
|
||||
saveProxyFunc: func(_ context.Context, _ *proxy.Proxy) error {
|
||||
@@ -163,7 +227,7 @@ func TestConnect_StoreError(t *testing.T) {
|
||||
}
|
||||
|
||||
mgr := newTestManager(s)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", nil, nil)
|
||||
_, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", "", nil, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,20 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration any) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration)
|
||||
}
|
||||
|
||||
// ClusterAllProxiesPrivate mocks base method.
|
||||
func (m *MockManager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ClusterAllProxiesPrivate", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(*bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ClusterAllProxiesPrivate indicates an expected call of ClusterAllProxiesPrivate.
|
||||
func (mr *MockManagerMockRecorder) ClusterAllProxiesPrivate(ctx, clusterAddr any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterAllProxiesPrivate", reflect.TypeOf((*MockManager)(nil).ClusterAllProxiesPrivate), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// ClusterRequireSubdomain mocks base method.
|
||||
func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -112,19 +126,33 @@ func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// Connect mocks base method.
|
||||
func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) {
|
||||
// ClusterSupportsSessionCode mocks base method.
|
||||
func (m *MockManager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
|
||||
ret := m.ctrl.Call(m, "ClusterSupportsSessionCode", ctx, clusterAddr)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ClusterSupportsSessionCode indicates an expected call of ClusterSupportsSessionCode.
|
||||
func (mr *MockManagerMockRecorder) ClusterSupportsSessionCode(ctx, clusterAddr any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsSessionCode", reflect.TypeOf((*MockManager)(nil).ClusterSupportsSessionCode), ctx, clusterAddr)
|
||||
}
|
||||
|
||||
// Connect mocks base method.
|
||||
func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities)
|
||||
ret0, _ := ret[0].(*Proxy)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Connect indicates an expected call of Connect.
|
||||
func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities any) *gomock.Call {
|
||||
func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities)
|
||||
}
|
||||
|
||||
// CountAccountProxies mocks base method.
|
||||
|
||||
@@ -9,6 +9,9 @@ const (
|
||||
StatusDisconnected = "disconnected"
|
||||
)
|
||||
|
||||
// MaxVersionLength is the width of the Version column, in characters.
|
||||
const MaxVersionLength = 255
|
||||
|
||||
// Capabilities describes what a proxy can handle, as reported via gRPC.
|
||||
// Nil fields mean the proxy never reported this capability.
|
||||
type Capabilities struct {
|
||||
@@ -31,6 +34,7 @@ type Proxy struct {
|
||||
SessionID string `gorm:"type:varchar(36)"`
|
||||
ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"`
|
||||
IPAddress string `gorm:"type:varchar(45)"`
|
||||
Version string `gorm:"type:varchar(255)"`
|
||||
AccountID *string `gorm:"type:varchar(255);index:idx_proxy_account_id"`
|
||||
LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"`
|
||||
ConnectedAt *time.Time
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxytoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -18,13 +19,29 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// RevocationGuard vetoes the tenant-facing revocation of a proxy access
|
||||
// token. Implementations are supplied by integrations; none is installed by
|
||||
// default, so every token the caller's account owns may be revoked. It is
|
||||
// consulted after the ownership check and before the token is revoked. A
|
||||
// returned status error is written with util.WriteError: its type selects the
|
||||
// HTTP status and its message is shown to the caller, so it must not carry
|
||||
// internal detail. Any other error is reported as a generic internal error.
|
||||
type RevocationGuard interface {
|
||||
CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
store store.Store
|
||||
permissionsManager permissions.Manager
|
||||
// revocationGuard vetoes revocations. Optional — when nil every owned
|
||||
// token may be revoked.
|
||||
revocationGuard RevocationGuard
|
||||
}
|
||||
|
||||
func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, router *mux.Router) {
|
||||
h := &handler{store: s, permissionsManager: permissionsManager}
|
||||
// RegisterEndpoints registers the proxy token endpoints. revocationGuard is
|
||||
// optional; pass nil for no revocation policy.
|
||||
func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, revocationGuard RevocationGuard, router *mux.Router) {
|
||||
h := &handler{store: s, permissionsManager: permissionsManager, revocationGuard: revocationGuard}
|
||||
router.HandleFunc("/reverse-proxies/proxy-tokens", h.listTokens).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies/proxy-tokens", h.createToken).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies/proxy-tokens/{tokenId}", h.revokeToken).Methods("DELETE", "OPTIONS")
|
||||
@@ -154,6 +171,13 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.revocationGuard != nil {
|
||||
if err := h.revocationGuard.CheckProxyAccessTokenRevocation(ctx, token); err != nil {
|
||||
util.WriteError(ctx, err, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil {
|
||||
util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func authContext(accountID, userID string) context.Context {
|
||||
@@ -273,3 +275,152 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) {
|
||||
h.revokeToken(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
type revocationGuardFunc func(ctx context.Context, token *types.ProxyAccessToken) error
|
||||
|
||||
func (f revocationGuardFunc) CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error {
|
||||
return f(ctx, token)
|
||||
}
|
||||
|
||||
func TestRevokeToken_GuardRefuses(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
accountID := "acc-123"
|
||||
|
||||
// No RevokeProxyAccessToken expectation: a refused revocation must not
|
||||
// reach the store.
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{
|
||||
ID: "tok-1",
|
||||
AccountID: &accountID,
|
||||
}, nil)
|
||||
|
||||
permsMgr := permissions.NewMockManager(ctrl)
|
||||
permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
|
||||
|
||||
var checked *types.ProxyAccessToken
|
||||
h := &handler{
|
||||
store: mockStore,
|
||||
permissionsManager: permsMgr,
|
||||
revocationGuard: revocationGuardFunc(func(_ context.Context, token *types.ProxyAccessToken) error {
|
||||
checked = token
|
||||
return status.Errorf(status.PreconditionFailed, "token is in use")
|
||||
}),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil)
|
||||
req = req.WithContext(authContext(accountID, "user-1"))
|
||||
req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.revokeToken(w, req)
|
||||
assert.Equal(t, http.StatusPreconditionFailed, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "token is in use")
|
||||
require.NotNil(t, checked)
|
||||
assert.Equal(t, "tok-1", checked.ID)
|
||||
}
|
||||
|
||||
func TestRevokeToken_GuardAllows(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
accountID := "acc-123"
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{
|
||||
ID: "tok-1",
|
||||
AccountID: &accountID,
|
||||
}, nil)
|
||||
mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil)
|
||||
|
||||
permsMgr := permissions.NewMockManager(ctrl)
|
||||
permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
|
||||
|
||||
h := &handler{
|
||||
store: mockStore,
|
||||
permissionsManager: permsMgr,
|
||||
revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error {
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil)
|
||||
req = req.WithContext(authContext(accountID, "user-1"))
|
||||
req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.revokeToken(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRevokeToken_GuardFailure(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
accountID := "acc-123"
|
||||
|
||||
// No RevokeProxyAccessToken expectation: a guard that cannot decide must
|
||||
// not let the revocation through.
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{
|
||||
ID: "tok-1",
|
||||
AccountID: &accountID,
|
||||
}, nil)
|
||||
|
||||
permsMgr := permissions.NewMockManager(ctrl)
|
||||
permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
|
||||
|
||||
h := &handler{
|
||||
store: mockStore,
|
||||
permissionsManager: permsMgr,
|
||||
revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error {
|
||||
return errors.New("connection refused")
|
||||
}),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil)
|
||||
req = req.WithContext(authContext(accountID, "user-1"))
|
||||
req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.revokeToken(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "internal server error")
|
||||
assert.NotContains(t, w.Body.String(), "connection refused")
|
||||
}
|
||||
|
||||
func TestRevokeToken_GuardNotConsultedForForeignToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
otherAccount := "acc-other"
|
||||
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{
|
||||
ID: "tok-1",
|
||||
AccountID: &otherAccount,
|
||||
}, nil)
|
||||
|
||||
permsMgr := permissions.NewMockManager(ctrl)
|
||||
permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
|
||||
|
||||
// A foreign token must read as not found, not reveal through the guard's
|
||||
// answer that it belongs to some account's managed proxy.
|
||||
h := &handler{
|
||||
store: mockStore,
|
||||
permissionsManager: permsMgr,
|
||||
revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error {
|
||||
t.Fatal("guard consulted for a token the caller does not own")
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil)
|
||||
req = req.WithContext(authContext("acc-123", "user-1"))
|
||||
req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.revokeToken(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
+51
-1
@@ -30,7 +30,7 @@ func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) {
|
||||
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil)
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", "", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountMgr := &mock_server.MockAccountManager{
|
||||
@@ -125,3 +125,53 @@ func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain")
|
||||
}
|
||||
|
||||
func TestCreateService_DomainDeletedBeforeWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
|
||||
d, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true)
|
||||
require.NoError(t, err)
|
||||
svc := newTestService("app.proven.example.com")
|
||||
require.NoError(t, mgr.initializeServiceForCreate(ctx, testAccountID, svc))
|
||||
|
||||
// Delete after the initial authorization check, before the service transaction starts.
|
||||
require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID))
|
||||
err = mgr.persistNewService(ctx, testAccountID, svc)
|
||||
require.Error(t, err, "an earlier validation result must not authorize a deleted registration")
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "the caller must receive a typed precondition error")
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the service must require current domain authorization")
|
||||
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "the failed write must not leave a service")
|
||||
}
|
||||
|
||||
func TestUpdateService_DomainDeletedBeforeWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "original.example.com", validationTestCluster, true)
|
||||
require.NoError(t, err)
|
||||
d, err := testStore.CreateCustomDomain(ctx, testAccountID, "destination.example.com", validationTestCluster, true)
|
||||
require.NoError(t, err)
|
||||
svc, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.original.example.com"))
|
||||
require.NoError(t, err)
|
||||
moved := svc.Copy()
|
||||
moved.Domain = "app.destination.example.com"
|
||||
cluster, err := mgr.resolveEffectiveCluster(ctx, testAccountID, moved)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID))
|
||||
err = testStore.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
||||
return mgr.executeServiceUpdate(ctx, tx, testAccountID, moved, &serviceUpdateInfo{}, nil, cluster)
|
||||
})
|
||||
require.Error(t, err, "a domain deleted after cluster resolution must reject the update")
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "the caller must receive a typed precondition error")
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the move must require current domain authorization")
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, svc.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, svc.Domain, stored.Domain, "the service must retain its authorized domain")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
@@ -74,6 +75,7 @@ const unknownHostPlaceholder = "unknown"
|
||||
// ClusterDeriver derives the proxy cluster from a domain.
|
||||
type ClusterDeriver interface {
|
||||
DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error)
|
||||
ValidateServiceDomain(ctx context.Context, tx store.Store, accountID, domain, cluster string) error
|
||||
GetClusterDomains() []string
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ type CapabilityProvider interface {
|
||||
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -331,7 +334,17 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := validateTargetAccessControl(svc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
if svc.Domain != "" {
|
||||
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil {
|
||||
return err
|
||||
@@ -365,6 +378,43 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service)
|
||||
return m.capabilities.ClusterSupportsCustomPorts(ctx, svc.ProxyCluster)
|
||||
}
|
||||
|
||||
// validatePrivateClusterTargets rejects cluster and direct upstream targets unless
|
||||
// every active proxy in the service's cluster reports the private capability. The
|
||||
// mapping reaches all proxies in the cluster, so one non-private proxy would serve
|
||||
// these targets too. An unreported capability is treated as unsupported. Must be
|
||||
// called outside a transaction, like clusterCustomPorts.
|
||||
func (m *Manager) validatePrivateClusterTargets(ctx context.Context, targets []*service.Target, cluster string) error {
|
||||
target := firstPrivateClusterTarget(targets)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if private := m.capabilities.ClusterAllProxiesPrivate(ctx, cluster); private != nil && *private {
|
||||
return nil
|
||||
}
|
||||
|
||||
if target.TargetType == service.TargetTypeCluster {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"target_type %q requires a proxy cluster with private mode enabled, cluster %s does not support it",
|
||||
service.TargetTypeCluster, cluster)
|
||||
}
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"direct_upstream requires a proxy cluster with private mode enabled, cluster %s does not support it", cluster)
|
||||
}
|
||||
|
||||
// firstPrivateClusterTarget returns the first target that only a private cluster may serve.
|
||||
func firstPrivateClusterTarget(targets []*service.Target) *service.Target {
|
||||
for _, target := range targets {
|
||||
if target == nil {
|
||||
continue
|
||||
}
|
||||
if target.TargetType == service.TargetTypeCluster || target.Options.DirectUpstream {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureL4Port auto-assigns a listen port when needed and validates cluster support.
|
||||
// customPorts must be pre-computed via clusterCustomPorts before entering a transaction.
|
||||
func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error {
|
||||
@@ -460,7 +510,14 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -577,6 +634,10 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := m.validatePrivateClusterTargets(ctx, service.Targets, effectiveCluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate subdomain requirement *before* the transaction: the underlying
|
||||
// capability lookup talks to the main DB pool, and SQLite's single-connection
|
||||
// pool would self-deadlock if this ran while the tx already held the only
|
||||
@@ -622,6 +683,9 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string,
|
||||
}
|
||||
|
||||
func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error {
|
||||
if err := m.validateServiceDomain(ctx, transaction, accountID, service, effectiveCluster); err != nil {
|
||||
return err
|
||||
}
|
||||
existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -647,6 +711,12 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
|
||||
}
|
||||
|
||||
m.preserveExistingAuthSecrets(service, existingService)
|
||||
if err := preserveTargetAccessActions(service, existingService); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTargetAccessControl(service); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -677,6 +747,75 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, accountID string, svc *service.Service, cluster string) error {
|
||||
if m.clusterDeriver == nil {
|
||||
return nil
|
||||
}
|
||||
return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster)
|
||||
}
|
||||
|
||||
func validateTargetAccessControl(svc *service.Service) error {
|
||||
if !svc.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
// Legacy requests may acquire an existing action during the update merge.
|
||||
if err := svc.Validate(); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "%s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveTargetAccessActions(updated, existing *service.Service) error {
|
||||
if !existing.HasTargetAccessControl() {
|
||||
return nil
|
||||
}
|
||||
if slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return target == nil
|
||||
}) {
|
||||
return status.Errorf(status.InvalidArgument, "target must not be nil")
|
||||
}
|
||||
if len(updated.Targets) > 0 && !slices.ContainsFunc(updated.Targets, func(target *service.Target) bool {
|
||||
return !target.AccessActionProvided
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
byPath := make(map[string]*service.Target, len(existing.Targets))
|
||||
for _, target := range existing.Targets {
|
||||
location := effectiveTargetPath(target)
|
||||
if _, duplicate := byPath[location]; duplicate {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"cannot preserve target access control for duplicate location %q", location)
|
||||
}
|
||||
byPath[location] = target
|
||||
}
|
||||
actionsProvided := false
|
||||
for _, target := range updated.Targets {
|
||||
actionsProvided = actionsProvided || target.AccessActionProvided
|
||||
location := effectiveTargetPath(target)
|
||||
if previous := byPath[location]; previous != nil && !target.AccessActionProvided {
|
||||
target.AccessAction = previous.AccessAction
|
||||
}
|
||||
delete(byPath, location)
|
||||
}
|
||||
if actionsProvided {
|
||||
return nil
|
||||
}
|
||||
for location, target := range byPath {
|
||||
if target.AccessAction != "" && target.AccessAction != service.TargetAccessActionInherit {
|
||||
return status.Errorf(status.InvalidArgument,
|
||||
"include access_action when removing or changing controlled target location %q", location)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveTargetPath(target *service.Target) string {
|
||||
if target.Path == nil || *target.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return *target.Path
|
||||
}
|
||||
|
||||
// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters.
|
||||
// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode.
|
||||
// Returns an error if validation fails, otherwise returns nil.
|
||||
@@ -1012,15 +1151,16 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
|
||||
|
||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||
|
||||
var errs []error
|
||||
for _, s := range services {
|
||||
err = m.replaceHostByLookup(ctx, accountID, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
|
||||
if err := m.replaceHostByLookup(ctx, accountID, s); err != nil {
|
||||
errs = append(errs, fmt.Errorf("replace host by lookup for service %s: %w", s.ID, err))
|
||||
continue
|
||||
}
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (m *Manager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -433,8 +434,8 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
|
||||
newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer {
|
||||
t.Helper()
|
||||
tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t))
|
||||
pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||
srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -655,6 +656,10 @@ func (d *testClusterDeriver) GetClusterDomains() []string {
|
||||
return d.domains
|
||||
}
|
||||
|
||||
func (d *testClusterDeriver) ValidateServiceDomain(context.Context, store.Store, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
testAccountID = "test-account"
|
||||
testPeerID = "test-peer-1"
|
||||
@@ -722,8 +727,8 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) {
|
||||
}
|
||||
|
||||
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t))
|
||||
proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
|
||||
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
@@ -1146,8 +1151,8 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
|
||||
mockAcct := account.NewMockManager(ctrl)
|
||||
|
||||
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t))
|
||||
proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
||||
|
||||
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
@@ -1477,3 +1482,42 @@ func TestReplaceHostByLookup_SkipsClusterTarget(t *testing.T) {
|
||||
require.NoError(t, mgr.replaceHostByLookup(ctx, accountID, svc), "cluster target must not trigger peer/resource lookup")
|
||||
assert.Equal(t, "127.0.0.1", svc.Targets[0].Host, "operator-supplied host must be preserved for cluster target")
|
||||
}
|
||||
|
||||
func TestReloadAllServicesForAccount_ContinuesAfterFailedService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
proxyController := proxy.NewMockController(ctrl)
|
||||
accountID := "test-account"
|
||||
|
||||
broken := &rpservice.Service{
|
||||
ID: "svc-broken",
|
||||
AccountID: accountID,
|
||||
ProxyCluster: "eu.proxy.netbird.io",
|
||||
Targets: []*rpservice.Target{{TargetId: "x", TargetType: "unknown"}},
|
||||
}
|
||||
healthy := &rpservice.Service{
|
||||
ID: "svc-healthy",
|
||||
AccountID: accountID,
|
||||
ProxyCluster: "eu.proxy.netbird.io",
|
||||
Targets: []*rpservice.Target{{TargetId: "peer-1", TargetType: rpservice.TargetTypePeer, Host: "100.64.0.1", Protocol: "http", Port: 8080, Enabled: true}},
|
||||
}
|
||||
|
||||
mockStore.EXPECT().GetAccountServices(ctx, store.LockingStrengthNone, accountID).Return([]*rpservice.Service{broken, healthy}, nil)
|
||||
mockStore.EXPECT().GetPeerByID(ctx, store.LockingStrengthNone, accountID, "peer-1").
|
||||
Return(&nbpeer.Peer{ID: "peer-1", IP: netip.MustParseAddr("100.70.0.9")}, nil)
|
||||
proxyController.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{})
|
||||
proxyController.EXPECT().SendServiceUpdateToCluster(ctx, accountID, gomock.Any(), "eu.proxy.netbird.io").
|
||||
Do(func(_ context.Context, _ string, mapping *proto.ProxyMapping, _ string) {
|
||||
assert.Equal(t, "svc-healthy", mapping.GetId())
|
||||
require.Len(t, mapping.GetPath(), 1)
|
||||
assert.Contains(t, mapping.GetPath()[0].GetTarget(), "100.70.0.9", "mapping must carry the peer's current IP")
|
||||
})
|
||||
|
||||
mgr := &Manager{store: mockStore, proxyController: proxyController}
|
||||
|
||||
err := mgr.ReloadAllServicesForAccount(ctx, accountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "svc-broken")
|
||||
assert.NotContains(t, err.Error(), "svc-healthy")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// setupPrivateClusterTest wires the real proxy manager as the capability
|
||||
// provider and connects one proxy to testCluster reporting the given private
|
||||
// capability. A nil private connects no proxy, so the capability is unreported.
|
||||
func setupPrivateClusterTest(t *testing.T, private *bool) (*Manager, store.Store) {
|
||||
t.Helper()
|
||||
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
|
||||
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
mgr.capabilities = proxyMgr
|
||||
|
||||
if private != nil {
|
||||
connectTestProxy(t, proxyMgr, "proxy-1", &proxy.Capabilities{Private: private})
|
||||
}
|
||||
|
||||
return mgr, testStore
|
||||
}
|
||||
|
||||
func connectTestProxy(t *testing.T, proxyMgr *proxymanager.Manager, proxyID string, caps *proxy.Capabilities) {
|
||||
t.Helper()
|
||||
_, err := proxyMgr.Connect(context.Background(), proxyID, "session-"+proxyID, testCluster, "127.0.0.1", "", nil, caps)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func clusterTarget() *rpservice.Target {
|
||||
return &rpservice.Target{
|
||||
TargetId: testCluster,
|
||||
TargetType: rpservice.TargetTypeCluster,
|
||||
Host: "backend.lan",
|
||||
Port: 8080,
|
||||
Protocol: "http",
|
||||
Enabled: true,
|
||||
Options: rpservice.TargetOptions{DirectUpstream: true},
|
||||
}
|
||||
}
|
||||
|
||||
func directUpstreamPeerTarget() *rpservice.Target {
|
||||
return &rpservice.Target{
|
||||
TargetId: testPeerID,
|
||||
TargetType: rpservice.TargetTypePeer,
|
||||
Host: "backend.lan",
|
||||
Port: 8080,
|
||||
Protocol: "http",
|
||||
Enabled: true,
|
||||
Options: rpservice.TargetOptions{DirectUpstream: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateService_PrivateClusterTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
private *bool
|
||||
target *rpservice.Target
|
||||
wantErr string
|
||||
}{
|
||||
{name: "cluster target on private cluster", private: boolPtr(true), target: clusterTarget()},
|
||||
{name: "direct upstream on private cluster", private: boolPtr(true), target: directUpstreamPeerTarget()},
|
||||
{name: "cluster target on non-private cluster", private: boolPtr(false), target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`},
|
||||
{name: "direct upstream on non-private cluster", private: boolPtr(false), target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"},
|
||||
{name: "cluster target with unreported capability", private: nil, target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`},
|
||||
{name: "direct upstream with unreported capability", private: nil, target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupPrivateClusterTest(t, tc.private)
|
||||
|
||||
svc := newTestService("app.test.netbird.io")
|
||||
svc.Targets = []*rpservice.Target{tc.target}
|
||||
|
||||
_, err := mgr.CreateService(ctx, testAccountID, testUserID, svc)
|
||||
|
||||
services, listErr := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
|
||||
require.NoError(t, listErr)
|
||||
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 1, "the service should be persisted")
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "the caller must receive a typed error")
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "the rejection should be an invalid argument")
|
||||
assert.Empty(t, services, "a rejected service must not be persisted")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A cluster where only some proxies run in private mode must not accept these
|
||||
// targets: the mapping is delivered to every proxy in the cluster, so the
|
||||
// non-private ones would serve the target from their host network as well.
|
||||
func TestCreateService_MixedClusterRejectsPrivateTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secondCaps *proxy.Capabilities
|
||||
}{
|
||||
{name: "second proxy reports not private", secondCaps: &proxy.Capabilities{Private: boolPtr(false)}},
|
||||
{name: "second proxy predates capability reporting", secondCaps: nil},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
for _, target := range []*rpservice.Target{clusterTarget(), directUpstreamPeerTarget()} {
|
||||
t.Run(tc.name+"/"+string(target.TargetType), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupPrivateClusterTest(t, boolPtr(true))
|
||||
connectTestProxy(t, mgr.capabilities.(*proxymanager.Manager), "proxy-2", tc.secondCaps)
|
||||
|
||||
svc := newTestService("app.test.netbird.io")
|
||||
svc.Targets = []*rpservice.Target{target}
|
||||
|
||||
_, err := mgr.CreateService(ctx, testAccountID, testUserID, svc)
|
||||
require.Error(t, err, "a cluster with a non-private proxy must not accept the target")
|
||||
assert.Contains(t, err.Error(), "requires a proxy cluster with private mode enabled")
|
||||
|
||||
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "a rejected service must not be persisted")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateService_RegularTargetIgnoresPrivateCapability(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, _ := setupPrivateClusterTest(t, boolPtr(false))
|
||||
|
||||
_, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io"))
|
||||
require.NoError(t, err, "a peer target without direct upstream must not need a private cluster")
|
||||
}
|
||||
|
||||
func TestUpdateService_PrivateClusterTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target *rpservice.Target
|
||||
wantErr string
|
||||
}{
|
||||
{name: "switch to cluster target", target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`},
|
||||
{name: "enable direct upstream", target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupPrivateClusterTest(t, boolPtr(false))
|
||||
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io"))
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := newTestService("app.test.netbird.io")
|
||||
updated.ID = created.ID
|
||||
updated.AccountID = testAccountID
|
||||
updated.Targets = []*rpservice.Target{tc.target}
|
||||
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 1)
|
||||
assert.Equal(t, rpservice.TargetTypePeer, stored.Targets[0].TargetType, "the stored target must be unchanged")
|
||||
assert.False(t, stored.Targets[0].Options.DirectUpstream, "the stored target must keep direct upstream disabled")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateService_PrivateClusterAllowsClusterTarget(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupPrivateClusterTest(t, boolPtr(true))
|
||||
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io"))
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := newTestService("app.test.netbird.io")
|
||||
updated.ID = created.ID
|
||||
updated.AccountID = testAccountID
|
||||
updated.Targets = []*rpservice.Target{clusterTarget()}
|
||||
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 1)
|
||||
assert.Equal(t, rpservice.TargetTypeCluster, stored.Targets[0].TargetType, "the cluster target should be stored")
|
||||
}
|
||||
|
||||
func TestValidatePrivateClusterTargets_NoLookupWithoutPrivateTargets(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
// No ClusterAllProxiesPrivate expectation: a lookup would fail the test.
|
||||
mgr := &Manager{capabilities: proxy.NewMockManager(ctrl)}
|
||||
|
||||
targets := []*rpservice.Target{{TargetId: testPeerID, TargetType: rpservice.TargetTypePeer}}
|
||||
require.NoError(t, mgr.validatePrivateClusterTargets(context.Background(), targets, testCluster))
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
func TestCreateServiceTargetAccessWithoutActiveProxies(t *testing.T) {
|
||||
for _, action := range []rpservice.TargetAccessAction{
|
||||
rpservice.TargetAccessActionBlock,
|
||||
rpservice.TargetAccessActionBypass,
|
||||
} {
|
||||
t.Run(string(action), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
svc := &rpservice.Service{
|
||||
Name: "target access", Domain: "target.test.netbird.io", Mode: rpservice.ModeHTTP,
|
||||
Enabled: true,
|
||||
Targets: []*rpservice.Target{{
|
||||
AccountID: testAccountID, TargetId: testPeerID,
|
||||
TargetType: rpservice.TargetTypePeer, Protocol: "http", Port: 8080,
|
||||
Enabled: true, AccessAction: action,
|
||||
}},
|
||||
}
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, svc)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 1)
|
||||
assert.Equal(t, action, stored.Targets[0].AccessAction,
|
||||
"accepted target actions must survive persistence")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateServicePreservesOmittedTargetAccessAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Name = "renamed by an older client"
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
rootPath := "/"
|
||||
updated.Targets[0].Path = &rootPath
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, updated.Name, stored.Name, "legacy updates must still apply unrelated edits")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an omitted action must preserve the stored block across equivalent root locations")
|
||||
|
||||
emptyPath := ""
|
||||
updated.Targets[0].Path = &emptyPath
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"an older client's empty root location must preserve the stored block")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rpservice.TargetAccessActionInherit, stored.Targets[0].AccessAction,
|
||||
"explicit inherit must clear the stored target block")
|
||||
}
|
||||
|
||||
func TestUpdateServiceActivatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Enabled = false
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Enabled = true
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.Enabled, "activating target access must not require an active proxy")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction,
|
||||
"activation must retain an omitted stored access action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceValidatesPreservedTargetAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "public", "http", "target.test.netbird.io", testCluster, 0)
|
||||
existing.Targets[0].AccessAction = rpservice.TargetAccessActionBypass
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Private = true
|
||||
updated.AccessGroups = []string{testGroupID}
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.ErrorContains(t, err, "bypass access_action is not supported for private services")
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.Private, "an invalid preserved bypass must prevent the private-service update")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, stored.Targets[0].AccessAction,
|
||||
"a rejected update must retain the existing target action")
|
||||
}
|
||||
|
||||
func TestUpdateServiceLegacyClientCannotRemoveControlledPath(t *testing.T) {
|
||||
for _, operation := range []string{"rename", "delete"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore, _ := setupL4Test(t, nil)
|
||||
existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0)
|
||||
blockedPath := "/blocked"
|
||||
existing.Targets = append(existing.Targets, &rpservice.Target{
|
||||
AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer,
|
||||
Protocol: "http", Port: 8080, Enabled: true, Path: &blockedPath,
|
||||
AccessAction: rpservice.TargetAccessActionBlock,
|
||||
})
|
||||
require.NoError(t, testStore.UpdateService(ctx, existing))
|
||||
|
||||
updated := existing.Copy()
|
||||
updated.Targets[1].AccessAction = rpservice.TargetAccessActionInherit
|
||||
if operation == "rename" {
|
||||
renamedPath := "/renamed"
|
||||
updated.Targets[1].Path = &renamedPath
|
||||
} else {
|
||||
updated.Targets = updated.Targets[:1]
|
||||
}
|
||||
_, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.Error(t, err)
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored.Targets, 2, "a legacy structural edit must retain the controlled target")
|
||||
var blocked *rpservice.Target
|
||||
for _, target := range stored.Targets {
|
||||
if effectiveTargetPath(target) == blockedPath {
|
||||
blocked = target
|
||||
}
|
||||
}
|
||||
require.NotNil(t, blocked, "the original controlled location must still exist")
|
||||
assert.Equal(t, rpservice.TargetAccessActionBlock, blocked.AccessAction,
|
||||
"a rejected legacy edit must not erase the block action")
|
||||
|
||||
updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit
|
||||
updated.Targets[0].AccessActionProvided = true
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated)
|
||||
require.NoError(t, err)
|
||||
stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.HasTargetAccessControl(),
|
||||
"a feature-aware client must be able to intentionally remove or rename the controlled path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveTargetAccessActionsExplicitUpdate(t *testing.T) {
|
||||
existing := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBlock},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit},
|
||||
}}
|
||||
updated := &rpservice.Service{Targets: []*rpservice.Target{
|
||||
{Enabled: true, AccessAction: rpservice.TargetAccessActionBypass, AccessActionProvided: true},
|
||||
{Enabled: false, AccessAction: rpservice.TargetAccessActionInherit, AccessActionProvided: true},
|
||||
}}
|
||||
require.NoError(t, preserveTargetAccessActions(updated, existing))
|
||||
assert.Equal(t, rpservice.TargetAccessActionBypass, updated.Targets[0].AccessAction,
|
||||
"explicit actions must not need an ambiguous location-based legacy merge")
|
||||
|
||||
updated.Targets[0].AccessActionProvided = false
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an omitted action must be rejected when its stored location is ambiguous")
|
||||
|
||||
existing.Targets = existing.Targets[:1]
|
||||
updated.Targets = nil
|
||||
assert.Error(t, preserveTargetAccessActions(updated, existing),
|
||||
"an empty target list must not count as an explicit removal of the stored block")
|
||||
}
|
||||
@@ -116,18 +116,20 @@ type MiddlewareConfig struct {
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
||||
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
||||
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
||||
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
||||
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
||||
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
||||
Options TargetOptions `gorm:"embedded" json:"options"`
|
||||
ProxyProtocol bool `json:"proxy_protocol"`
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
||||
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
||||
Path *string `json:"path,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
||||
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
||||
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
||||
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
||||
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
||||
AccessAction TargetAccessAction `gorm:"type:varchar(16);not null;default:'inherit'" json:"access_action"`
|
||||
AccessActionProvided bool `gorm:"-" json:"-"`
|
||||
Options TargetOptions `gorm:"embedded" json:"options"`
|
||||
ProxyProtocol bool `json:"proxy_protocol"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -309,14 +311,16 @@ func (s *Service) ToAPIResponse() *api.Service {
|
||||
// Convert internal targets to API targets
|
||||
apiTargets := make([]api.ServiceTarget, 0, len(s.Targets))
|
||||
for _, target := range s.Targets {
|
||||
accessAction := api.ServiceTargetAccessAction(target.effectiveAccessAction())
|
||||
st := api.ServiceTarget{
|
||||
Path: target.Path,
|
||||
Host: &target.Host,
|
||||
Port: int(target.Port),
|
||||
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
||||
TargetId: target.TargetId,
|
||||
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
||||
Enabled: target.Enabled && !s.Terminated,
|
||||
Path: target.Path,
|
||||
Host: &target.Host,
|
||||
Port: int(target.Port),
|
||||
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
||||
TargetId: target.TargetId,
|
||||
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
||||
Enabled: target.Enabled && !s.Terminated,
|
||||
AccessAction: &accessAction,
|
||||
}
|
||||
opts := targetOptionsToAPI(target.Options)
|
||||
if opts == nil {
|
||||
@@ -463,8 +467,9 @@ func (s *Service) buildPathMappings() []*proto.PathMapping {
|
||||
}
|
||||
|
||||
pm := &proto.PathMapping{
|
||||
Path: path,
|
||||
Target: targetURL.String(),
|
||||
Path: path,
|
||||
Target: targetURL.String(),
|
||||
AccessAction: targetAccessActionToProto(target.effectiveAccessAction()),
|
||||
}
|
||||
pm.Options = targetOptionsToProto(target.Options)
|
||||
pathMappings = append(pathMappings, pm)
|
||||
@@ -727,13 +732,18 @@ func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Ta
|
||||
targets := make([]*Target, 0, len(apiTargets))
|
||||
for i, apiTarget := range apiTargets {
|
||||
target := &Target{
|
||||
AccountID: accountID,
|
||||
Path: apiTarget.Path,
|
||||
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
||||
Protocol: string(apiTarget.Protocol),
|
||||
TargetId: apiTarget.TargetId,
|
||||
TargetType: TargetType(apiTarget.TargetType),
|
||||
Enabled: apiTarget.Enabled,
|
||||
AccountID: accountID,
|
||||
Path: apiTarget.Path,
|
||||
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
||||
Protocol: string(apiTarget.Protocol),
|
||||
TargetId: apiTarget.TargetId,
|
||||
TargetType: TargetType(apiTarget.TargetType),
|
||||
Enabled: apiTarget.Enabled,
|
||||
AccessAction: TargetAccessActionInherit,
|
||||
}
|
||||
if apiTarget.AccessAction != nil {
|
||||
target.AccessAction = TargetAccessAction(*apiTarget.AccessAction)
|
||||
target.AccessActionProvided = true
|
||||
}
|
||||
if apiTarget.Host != nil {
|
||||
target.Host = *apiTarget.Host
|
||||
@@ -953,41 +963,70 @@ func (s *Service) validateTLSMode() error {
|
||||
}
|
||||
|
||||
func (s *Service) validateHTTPTargets() error {
|
||||
strictPaths := false
|
||||
for i, target := range s.Targets {
|
||||
switch target.TargetType {
|
||||
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
||||
// Host is normally overwritten by replaceHostByLookup with the
|
||||
// resolved peer IP / resource address; operator-supplied values
|
||||
// are honored only when DirectUpstream is set. Validate the
|
||||
// override here so misconfigured hosts fail fast at API time.
|
||||
if err := validateDirectUpstreamHost(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeSubnet:
|
||||
if err := validateSubnetTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeCluster:
|
||||
if err := validateClusterTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
|
||||
}
|
||||
if target.TargetId == "" {
|
||||
return fmt.Errorf("target %d has empty target_id", i)
|
||||
}
|
||||
if target.ProxyProtocol {
|
||||
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i)
|
||||
}
|
||||
if err := validateTargetOptions(i, &target.Options); err != nil {
|
||||
if err := s.validateHTTPTarget(i, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Enabled && target.effectiveAccessAction() != TargetAccessActionInherit {
|
||||
strictPaths = true
|
||||
}
|
||||
}
|
||||
if !strictPaths {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := make(map[string]int, len(s.Targets))
|
||||
for i, target := range s.Targets {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
}
|
||||
normalizedPath := normalizedTargetPath(target.Path)
|
||||
if previous, ok := paths[normalizedPath]; ok {
|
||||
return fmt.Errorf("targets %d and %d have duplicate path %q", previous, i, normalizedPath)
|
||||
}
|
||||
paths[normalizedPath] = i
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateHTTPTarget(idx int, target *Target) error {
|
||||
if target == nil {
|
||||
return fmt.Errorf("target %d is nil", idx)
|
||||
}
|
||||
if err := validateTargetAccessAction(idx, target, s.Private); err != nil {
|
||||
return err
|
||||
}
|
||||
switch target.TargetType {
|
||||
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
||||
// Host is normally overwritten by replaceHostByLookup with the
|
||||
// resolved peer IP / resource address; operator-supplied values
|
||||
// are honored only when DirectUpstream is set. Validate the
|
||||
// override here so misconfigured hosts fail fast at API time.
|
||||
if err := validateDirectUpstreamHost(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeSubnet:
|
||||
if err := validateSubnetTarget(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
case TargetTypeCluster:
|
||||
if err := validateClusterTarget(idx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("target %d has invalid target_type %q", idx, target.TargetType)
|
||||
}
|
||||
if target.TargetId == "" {
|
||||
return fmt.Errorf("target %d has empty target_id", idx)
|
||||
}
|
||||
if target.ProxyProtocol {
|
||||
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", idx)
|
||||
}
|
||||
return validateTargetOptions(idx, &target.Options)
|
||||
}
|
||||
|
||||
func validateSubnetTarget(idx int, target *Target) error {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" {
|
||||
@@ -1063,10 +1102,20 @@ func validateDirectUpstreamHost(idx int, target *Target) error {
|
||||
}
|
||||
|
||||
func (s *Service) validateL4Target(target *Target) error {
|
||||
if target == nil {
|
||||
return errors.New("target 0 is nil")
|
||||
}
|
||||
// L4 services have a single target; per-target disable is meaningless
|
||||
// (use the service-level Enabled flag instead). Force it on so that
|
||||
// buildPathMappings always includes the target in the proto.
|
||||
target.Enabled = true
|
||||
action, err := validatedTargetAccessAction(0, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action != TargetAccessActionInherit {
|
||||
return errors.New("access_action is only supported for HTTP services")
|
||||
}
|
||||
|
||||
if target.TargetId == "" {
|
||||
return errors.New("target_id is required for L4 services")
|
||||
|
||||
@@ -62,9 +62,11 @@ func TestValidate_InvalidTargetType(t *testing.T) {
|
||||
|
||||
func TestValidate_ResourceTarget(t *testing.T) {
|
||||
rp := validProxy()
|
||||
resourcePath := "/resource"
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "resource-1",
|
||||
TargetType: TargetTypeHost,
|
||||
Path: &resourcePath,
|
||||
Host: "example.org",
|
||||
Port: 443,
|
||||
Protocol: "https",
|
||||
@@ -73,6 +75,232 @@ func TestValidate_ResourceTarget(t *testing.T) {
|
||||
require.NoError(t, rp.Validate())
|
||||
}
|
||||
|
||||
func TestValidateTargetAccessAction(t *testing.T) {
|
||||
t.Run("valid actions", func(t *testing.T) {
|
||||
for _, action := range []TargetAccessAction{
|
||||
TargetAccessActionInherit,
|
||||
TargetAccessActionBypass,
|
||||
TargetAccessActionBlock,
|
||||
} {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = action
|
||||
require.NoError(t, rp.Validate(), "action %q should be valid", action)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown action", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = "allow"
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action "allow"`)
|
||||
})
|
||||
|
||||
t.Run("explicit empty action", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessActionProvided = true
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
|
||||
})
|
||||
|
||||
t.Run("private bypass", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Private = true
|
||||
rp.AccessGroups = []string{"group-1"}
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.ErrorContains(t, rp.Validate(), "not supported for private services")
|
||||
})
|
||||
|
||||
t.Run("Agent Network bypass", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
rp.Targets[0].Options.AgentNetwork = true
|
||||
assert.ErrorContains(t, rp.Validate(), "not supported for Agent Network targets")
|
||||
})
|
||||
|
||||
t.Run("block remains available to private services", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Private = true
|
||||
rp.AccessGroups = []string{"group-1"}
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("non HTTP service", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Mode = ModeTCP
|
||||
rp.ListenPort = 8080
|
||||
rp.Targets[0].Protocol = TargetProtoTCP
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
assert.ErrorContains(t, rp.Validate(), "only supported for HTTP services")
|
||||
})
|
||||
|
||||
t.Run("nil non HTTP target", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Mode = ModeTCP
|
||||
rp.ListenPort = 8080
|
||||
rp.Targets[0] = nil
|
||||
assert.ErrorContains(t, rp.Validate(), "target 0 is nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateTargetAccessActionPath(t *testing.T) {
|
||||
for _, value := range []string{"api", "/api/../admin", "/api/./admin", "/api//admin", "/api%2Fadmin", `/api\admin`, "/api?x=1", "/api#fragment", "/api;admin", "/api/\x00"} {
|
||||
t.Run(fmt.Sprintf("path_%q", value), func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.ErrorContains(t, rp.Validate(), "access_action path")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil path is canonical root", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("empty path is legacy root", func(t *testing.T) {
|
||||
value := ""
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("trailing slash is a distinct valid prefix", func(t *testing.T) {
|
||||
value := "/api/"
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &value
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateDuplicateEnabledTargetPaths(t *testing.T) {
|
||||
emptyPath := ""
|
||||
rootPath := "/"
|
||||
rootAliases := []struct {
|
||||
name string
|
||||
path *string
|
||||
}{
|
||||
{name: "nil", path: nil},
|
||||
{name: "empty", path: &emptyPath},
|
||||
{name: "slash", path: &rootPath},
|
||||
}
|
||||
|
||||
for _, alias := range rootAliases {
|
||||
t.Run("legacy all-inherit "+alias.name, func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: alias.path,
|
||||
Host: "10.0.0.2",
|
||||
Port: 443,
|
||||
Protocol: TargetProtoHTTPS,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, rp.Validate(), "legacy all-inherit path and protocol twins must remain valid")
|
||||
})
|
||||
|
||||
t.Run("access-controlled "+alias.name, func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: alias.path,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
})
|
||||
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("access action elsewhere enables strict paths", func(t *testing.T) {
|
||||
publicPath := "/public"
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets,
|
||||
&Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
},
|
||||
&Target{
|
||||
TargetId: "peer-3",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &publicPath,
|
||||
Host: "10.0.0.3",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBypass,
|
||||
},
|
||||
)
|
||||
assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`)
|
||||
})
|
||||
|
||||
t.Run("disabled action preserves legacy validation", func(t *testing.T) {
|
||||
publicPath := "/public"
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets,
|
||||
&Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
},
|
||||
&Target{
|
||||
TargetId: "peer-3",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &publicPath,
|
||||
Host: "10.0.0.3",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: false,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
},
|
||||
)
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("disabled target does not collide", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: false,
|
||||
})
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
|
||||
t.Run("trailing slash remains distinct", func(t *testing.T) {
|
||||
apiPath := "/api"
|
||||
apiSlashPath := "/api/"
|
||||
rp := validProxy()
|
||||
rp.Targets[0].Path = &apiPath
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
TargetId: "peer-2",
|
||||
TargetType: TargetTypePeer,
|
||||
Path: &apiSlashPath,
|
||||
Host: "10.0.0.2",
|
||||
Port: 80,
|
||||
Protocol: TargetProtoHTTP,
|
||||
Enabled: true,
|
||||
AccessAction: TargetAccessActionBlock,
|
||||
})
|
||||
require.NoError(t, rp.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidate_MultipleTargetsOneInvalid(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets = append(rp.Targets, &Target{
|
||||
@@ -308,6 +536,90 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
|
||||
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
|
||||
}
|
||||
|
||||
func TestTargetAccessActionConversions(t *testing.T) {
|
||||
t.Run("API presence", func(t *testing.T) {
|
||||
bypass := api.ServiceTargetAccessActionBypass
|
||||
empty := api.ServiceTargetAccessAction("")
|
||||
apiTargets := []api.ServiceTarget{
|
||||
{
|
||||
TargetId: "peer-1",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
TargetId: "peer-2",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
AccessAction: &bypass,
|
||||
},
|
||||
{
|
||||
TargetId: "peer-3",
|
||||
TargetType: api.ServiceTargetTargetTypePeer,
|
||||
Protocol: api.ServiceTargetProtocolHttp,
|
||||
Port: 80,
|
||||
Enabled: true,
|
||||
AccessAction: &empty,
|
||||
},
|
||||
}
|
||||
|
||||
targets, err := targetsFromAPI("account-1", &apiTargets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, targets, 3)
|
||||
assert.Equal(t, TargetAccessActionInherit, targets[0].AccessAction)
|
||||
assert.False(t, targets[0].AccessActionProvided)
|
||||
assert.Equal(t, TargetAccessActionBypass, targets[1].AccessAction)
|
||||
assert.True(t, targets[1].AccessActionProvided)
|
||||
assert.Empty(t, targets[2].AccessAction)
|
||||
assert.True(t, targets[2].AccessActionProvided)
|
||||
|
||||
rp := &Service{Name: "test", Domain: "example.com", Targets: []*Target{targets[2]}}
|
||||
assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`)
|
||||
})
|
||||
|
||||
t.Run("API response", func(t *testing.T) {
|
||||
rp := validProxy()
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBlock
|
||||
response := rp.ToAPIResponse()
|
||||
require.Len(t, response.Targets, 1)
|
||||
require.NotNil(t, response.Targets[0].AccessAction)
|
||||
assert.Equal(t, api.ServiceTargetAccessActionBlock, *response.Targets[0].AccessAction)
|
||||
})
|
||||
|
||||
t.Run("proto mapping", func(t *testing.T) {
|
||||
root := "/"
|
||||
bypassPath := "/bypass"
|
||||
blockPath := "/block"
|
||||
rp := &Service{
|
||||
ID: "svc-1",
|
||||
AccountID: "acc-1",
|
||||
Domain: "example.com",
|
||||
Targets: []*Target{
|
||||
{Path: &root, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true},
|
||||
{Path: &bypassPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBypass},
|
||||
{Path: &blockPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBlock},
|
||||
},
|
||||
}
|
||||
|
||||
mapping := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||
require.Len(t, mapping.Path, 3)
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, mapping.Path[0].GetAccessAction())
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, mapping.Path[1].GetAccessAction())
|
||||
assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, mapping.Path[2].GetAccessAction())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasTargetAccessControl(t *testing.T) {
|
||||
rp := validProxy()
|
||||
assert.False(t, rp.HasTargetAccessControl())
|
||||
|
||||
rp.Targets[0].AccessAction = TargetAccessActionBypass
|
||||
assert.True(t, rp.HasTargetAccessControl())
|
||||
}
|
||||
|
||||
// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session
|
||||
// cookies on: without it the proxy can only check a cookie's signature, which
|
||||
// makes a token minted for a user outside the groups a bearer credential.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// TargetAccessAction controls authentication for a selected HTTP target.
|
||||
type TargetAccessAction string
|
||||
|
||||
const (
|
||||
TargetAccessActionInherit TargetAccessAction = "inherit"
|
||||
TargetAccessActionBypass TargetAccessAction = "bypass"
|
||||
TargetAccessActionBlock TargetAccessAction = "block"
|
||||
)
|
||||
|
||||
func (t *Target) effectiveAccessAction() TargetAccessAction {
|
||||
if t.AccessAction == "" {
|
||||
return TargetAccessActionInherit
|
||||
}
|
||||
return t.AccessAction
|
||||
}
|
||||
|
||||
func targetAccessActionToProto(action TargetAccessAction) proto.TargetAccessAction {
|
||||
switch action {
|
||||
case TargetAccessActionInherit:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT
|
||||
case TargetAccessActionBypass:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS
|
||||
case TargetAccessActionBlock:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
|
||||
default:
|
||||
return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK
|
||||
}
|
||||
}
|
||||
|
||||
// HasTargetAccessControl reports whether any target overrides service authentication.
|
||||
func (s *Service) HasTargetAccessControl() bool {
|
||||
for _, target := range s.Targets {
|
||||
if target != nil && target.effectiveAccessAction() != TargetAccessActionInherit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateTargetAccessAction(idx int, target *Target, private bool) error {
|
||||
action, err := validatedTargetAccessAction(idx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == TargetAccessActionBypass {
|
||||
if private {
|
||||
return fmt.Errorf("target %d: bypass access_action is not supported for private services", idx)
|
||||
}
|
||||
if target.Options.AgentNetwork {
|
||||
return fmt.Errorf("target %d: bypass access_action is not supported for Agent Network targets", idx)
|
||||
}
|
||||
}
|
||||
|
||||
if action == TargetAccessActionInherit {
|
||||
return nil
|
||||
}
|
||||
return validateAccessActionPath(idx, target.Path)
|
||||
}
|
||||
|
||||
func validatedTargetAccessAction(idx int, target *Target) (TargetAccessAction, error) {
|
||||
if target.AccessActionProvided && target.AccessAction == "" {
|
||||
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
|
||||
}
|
||||
action := target.effectiveAccessAction()
|
||||
switch action {
|
||||
case TargetAccessActionInherit, TargetAccessActionBypass, TargetAccessActionBlock:
|
||||
return action, nil
|
||||
default:
|
||||
return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedTargetPath(configured *string) string {
|
||||
if configured == nil || *configured == "" {
|
||||
return "/"
|
||||
}
|
||||
return *configured
|
||||
}
|
||||
|
||||
func validateAccessActionPath(idx int, configured *string) error {
|
||||
if configured == nil {
|
||||
return nil
|
||||
}
|
||||
value := *configured
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
return fmt.Errorf("target %d: access_action path %q must start with /", idx, value)
|
||||
}
|
||||
if !utf8.ValidString(value) || strings.ContainsAny(value, "%\\?#;") || strings.IndexFunc(value, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("target %d: access_action path %q contains invalid characters", idx, value)
|
||||
}
|
||||
segments := strings.Split(strings.TrimPrefix(value, "/"), "/")
|
||||
for i, segment := range segments {
|
||||
if segment == "." || segment == ".." {
|
||||
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
|
||||
}
|
||||
if segment == "" && value != "/" && i != len(segments)-1 {
|
||||
return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,15 +3,16 @@ package manager
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -69,6 +70,9 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
|
||||
}
|
||||
|
||||
zone = zones.NewZone(accountID, zone.Name, zone.Domain, zone.Enabled, zone.EnableSearchDomain, zone.DistributionGroups)
|
||||
var snap *affectedpeers.Snapshot
|
||||
change := affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups}
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
existingZone, err := transaction.GetZoneByDomain(ctx, accountID, zone.Domain)
|
||||
if err != nil {
|
||||
@@ -88,7 +92,15 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
|
||||
}
|
||||
|
||||
if err = transaction.CreateZone(ctx, zone); err != nil {
|
||||
return fmt.Errorf("failed to create zone: %w", err)
|
||||
return fmt.Errorf("create zone: %w", err)
|
||||
}
|
||||
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||
return fmt.Errorf("increment network serial: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -99,6 +111,8 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneCreated, zone.EventMeta())
|
||||
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
@@ -111,21 +125,26 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get zone: %w", err)
|
||||
}
|
||||
|
||||
if zone.Domain != updatedZone.Domain {
|
||||
return nil, status.Errorf(status.InvalidArgument, "zone domain cannot be updated")
|
||||
}
|
||||
|
||||
zone.Name = updatedZone.Name
|
||||
zone.Enabled = updatedZone.Enabled
|
||||
zone.EnableSearchDomain = updatedZone.EnableSearchDomain
|
||||
zone.DistributionGroups = updatedZone.DistributionGroups
|
||||
var zone *zones.Zone
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get zone: %w", err)
|
||||
}
|
||||
|
||||
if zone.Domain != updatedZone.Domain {
|
||||
return status.Errorf(status.InvalidArgument, "zone domain cannot be updated")
|
||||
}
|
||||
|
||||
oldGroups := zone.DistributionGroups
|
||||
zone.Name = updatedZone.Name
|
||||
zone.Enabled = updatedZone.Enabled
|
||||
zone.EnableSearchDomain = updatedZone.EnableSearchDomain
|
||||
zone.DistributionGroups = updatedZone.DistributionGroups
|
||||
|
||||
for _, groupID := range zone.DistributionGroups {
|
||||
_, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
|
||||
if err != nil {
|
||||
@@ -134,7 +153,16 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
|
||||
}
|
||||
|
||||
if err = transaction.UpdateZone(ctx, zone); err != nil {
|
||||
return fmt.Errorf("failed to update zone: %w", err)
|
||||
return fmt.Errorf("update zone: %w", err)
|
||||
}
|
||||
|
||||
change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(zone.DistributionGroups, oldGroups)}
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil {
|
||||
return fmt.Errorf("increment network serial: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -145,7 +173,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta())
|
||||
|
||||
go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationUpdate})
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return zone, nil
|
||||
}
|
||||
@@ -159,13 +187,23 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get zone: %w", err)
|
||||
}
|
||||
|
||||
var zone *zones.Zone
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
var eventsToStore []func()
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get zone: %w", err)
|
||||
}
|
||||
|
||||
// Load before delete: the post-delete state no longer references the groups.
|
||||
change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups}
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
records, err := transaction.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get records: %w", err)
|
||||
@@ -207,7 +245,7 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID
|
||||
event()
|
||||
}
|
||||
|
||||
go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationDelete})
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -65,6 +65,8 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
}
|
||||
|
||||
var zone *zones.Zone
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
|
||||
record = records.NewRecord(accountID, zoneID, record.Name, record.Type, record.Content, record.TTL)
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
@@ -82,6 +84,11 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
return fmt.Errorf("failed to create dns record: %w", err)
|
||||
}
|
||||
|
||||
change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups}
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
err = transaction.IncrementNetworkSerial(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increment network serial: %w", err)
|
||||
@@ -96,7 +103,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
meta := record.EventMeta(zone.ID, zone.Name)
|
||||
m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta)
|
||||
|
||||
go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationCreate})
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -112,6 +119,8 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
|
||||
var zone *zones.Zone
|
||||
var record *records.Record
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
|
||||
@@ -141,6 +150,11 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
return fmt.Errorf("failed to update dns record: %w", err)
|
||||
}
|
||||
|
||||
change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups}
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
err = transaction.IncrementNetworkSerial(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increment network serial: %w", err)
|
||||
@@ -155,7 +169,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
|
||||
meta := record.EventMeta(zone.ID, zone.Name)
|
||||
m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta)
|
||||
|
||||
go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationUpdate})
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -171,6 +185,8 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI
|
||||
|
||||
var record *records.Record
|
||||
var zone *zones.Zone
|
||||
var snap *affectedpeers.Snapshot
|
||||
var change affectedpeers.Change
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID)
|
||||
@@ -188,6 +204,11 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI
|
||||
return fmt.Errorf("failed to delete dns record: %w", err)
|
||||
}
|
||||
|
||||
change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups}
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
|
||||
return fmt.Errorf("load affected peers: %w", err)
|
||||
}
|
||||
|
||||
err = transaction.IncrementNetworkSerial(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increment network serial: %w", err)
|
||||
@@ -202,7 +223,7 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI
|
||||
meta := record.EventMeta(zone.ID, zone.Name)
|
||||
m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta)
|
||||
|
||||
go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationDelete})
|
||||
m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const (
|
||||
GetPeersQuery = `
|
||||
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
|
||||
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
|
||||
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
|
||||
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
|
||||
location_country_code, location_city_name, location_connection_ip
|
||||
from peers
|
||||
where account_id = $1
|
||||
|
||||
@@ -181,6 +181,7 @@ type Peer struct {
|
||||
MetaKernelVersion sql.NullString `nmap:"skip"`
|
||||
MetaNetworkAddresses []byte `nmap:"skip,json"`
|
||||
MetaFiles []byte `nmap:"skip,json"`
|
||||
MetaCertificates []byte `nmap:"skip,json"`
|
||||
MetaCapabilities []byte `nmap:"skip,json"`
|
||||
MetaFlags []byte `nmap:"skip,json"`
|
||||
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
|
||||
@@ -341,6 +342,12 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
|
||||
return toret, nil, err
|
||||
}
|
||||
}
|
||||
if p.MetaCertificates != nil {
|
||||
err := json.Unmarshal(p.MetaCertificates, &dp.Meta.Certificates)
|
||||
if err != nil {
|
||||
return toret, nil, err
|
||||
}
|
||||
}
|
||||
if p.MetaCapabilities != nil {
|
||||
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecordTypeAndRdata(t *testing.T) {
|
||||
@@ -36,3 +37,24 @@ func TestRecordTypeAndRdata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertToNmdataPeers_CarriesTheVerifiedCertificates: the certificate posture check is
|
||||
// evaluated on the chains stored in the peer meta, so a peer loaded through this path with
|
||||
// its certificates dropped would silently fail every certificate check.
|
||||
func TestConvertToNmdataPeers_CarriesTheVerifiedCertificates(t *testing.T) {
|
||||
peers, _, err := ConvertToNmdataPeers([]Peer{
|
||||
{MetaCertificates: []byte(`["-----BEGIN CERTIFICATE-----\nleaf\n-----END CERTIFICATE-----\n","chain-2"]`)},
|
||||
{},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peers, 2)
|
||||
assert.Equal(t, []string{"-----BEGIN CERTIFICATE-----\nleaf\n-----END CERTIFICATE-----\n", "chain-2"}, peers[0].Meta.Certificates)
|
||||
assert.Empty(t, peers[1].Meta.Certificates, "a peer that proved nothing has no certificates")
|
||||
}
|
||||
|
||||
func TestConvertToNmdataPeers_RejectsMalformedCertificates(t *testing.T) {
|
||||
_, _, err := ConvertToNmdataPeers([]Peer{{MetaCertificates: []byte(`{"not":"a list"}`)}})
|
||||
|
||||
assert.Error(t, err, "a stored value that is not a list of chains must not load as no certificates")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const (
|
||||
GetPeersQuery = `
|
||||
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
|
||||
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
|
||||
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version,
|
||||
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
|
||||
location_country_code, location_city_name, location_connection_ip
|
||||
from peers
|
||||
where account_id = ?
|
||||
|
||||
@@ -32,9 +32,11 @@ import (
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/management/server/api/v1alpha1"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
||||
@@ -43,10 +45,14 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/ratelimit"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
const apiPrefix = "/api"
|
||||
const (
|
||||
apiPrefix = "/api"
|
||||
apiV1Prefix = "/api/v1alpha1"
|
||||
)
|
||||
|
||||
var (
|
||||
kaep = keepalive.EnforcementPolicy{
|
||||
@@ -84,9 +90,20 @@ func (s *BaseServer) CacheStore() nbcache.Store {
|
||||
})
|
||||
}
|
||||
|
||||
// DBConn opens the database connection shared by the store and the domain repositories.
|
||||
func (s *BaseServer) DBConn() *db.Conn {
|
||||
return Create(s, func() *db.Conn {
|
||||
conn, err := store.OpenConn(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open database connection: %v", err)
|
||||
}
|
||||
return conn
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) Store() store.Store {
|
||||
return Create(s, func() store.Store {
|
||||
store, err := store.NewStore(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir, s.Metrics(), false)
|
||||
store, err := store.NewSqlStore(context.Background(), s.DBConn(), s.Metrics(), false)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
@@ -146,13 +163,31 @@ func (s *BaseServer) EventStore() activity.Store {
|
||||
}
|
||||
|
||||
func (s *BaseServer) APIHandler() http.Handler {
|
||||
return Create(s, func() http.Handler {
|
||||
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager())
|
||||
_ = CreateNamed(s, "http_v1api", func() http.Handler {
|
||||
apiv1Router := s.ApiV1Router()
|
||||
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create API handler: %v", err)
|
||||
}
|
||||
return httpAPIHandler
|
||||
|
||||
return apiv1Router
|
||||
})
|
||||
|
||||
_ = CreateNamed(s, "http_v0api", func() http.Handler {
|
||||
apiRouter := s.ApiRouter()
|
||||
_, err := nbhttp.NewAPIHandler(
|
||||
context.Background(), apiRouter, s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(),
|
||||
s.GroupsManager(), s.GeoLocationManager(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(),
|
||||
s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(),
|
||||
s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies,
|
||||
s.AgentNetworkManager(), nil)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create API handler: %v", err)
|
||||
}
|
||||
return apiRouter
|
||||
})
|
||||
|
||||
return s.Router()
|
||||
}
|
||||
|
||||
// IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if
|
||||
@@ -165,16 +200,35 @@ func (s *BaseServer) IDPHandler() http.Handler {
|
||||
return cors.AllowAll().Handler(embeddedIdP.Handler())
|
||||
}
|
||||
|
||||
// Router returns the root HTTP router with the shared API middleware applied.
|
||||
func (s *BaseServer) Router() *mux.Router {
|
||||
return Create(s, func() *mux.Router {
|
||||
return mux.NewRouter().PathPrefix(apiPrefix).Subrouter()
|
||||
router := mux.NewRouter()
|
||||
router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics())...)
|
||||
return router
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
|
||||
return Create(s, func() *middleware.APIRateLimiter {
|
||||
cfg, enabled := middleware.RateLimiterConfigFromEnv()
|
||||
limiter := middleware.NewAPIRateLimiter(cfg)
|
||||
// ApiV1Router returns the subrouter for the versioned API under apiV1Prefix.
|
||||
func (s *BaseServer) ApiV1Router() *mux.Router {
|
||||
return CreateNamed(s, "apiv1_router", func() *mux.Router {
|
||||
return s.Router().PathPrefix(apiV1Prefix).Subrouter()
|
||||
})
|
||||
}
|
||||
|
||||
// ApiRouter returns the subrouter for the unversioned API under apiPrefix.
|
||||
func (s *BaseServer) ApiRouter() *mux.Router {
|
||||
return CreateNamed(s, "apiv0_router", func() *mux.Router {
|
||||
// The nested versioned prefix must be registered before the broader apiPrefix.
|
||||
s.ApiV1Router()
|
||||
return s.Router().PathPrefix(apiPrefix).Subrouter()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) RateLimiter() *ratelimit.APIRateLimiter {
|
||||
return Create(s, func() *ratelimit.APIRateLimiter {
|
||||
cfg, enabled := ratelimit.RateLimiterConfigFromEnv()
|
||||
limiter := ratelimit.NewAPIRateLimiter(cfg)
|
||||
limiter.SetEnabled(enabled)
|
||||
return limiter
|
||||
})
|
||||
@@ -236,7 +290,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
|
||||
func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
|
||||
return Create(s, func() *nbgrpc.ProxyServiceServer {
|
||||
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
|
||||
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.SingleUseStore(), 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())
|
||||
@@ -293,9 +347,9 @@ func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore {
|
||||
return Create(s, func() *nbgrpc.PKCEVerifierStore {
|
||||
return nbgrpc.NewPKCEVerifierStore(context.Background(), s.CacheStore())
|
||||
func (s *BaseServer) SingleUseStore() *nbgrpc.SingleUseStore {
|
||||
return Create(s, func() *nbgrpc.SingleUseStore {
|
||||
return nbgrpc.NewSingleUseStore(context.Background(), s.CacheStore())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,7 +362,7 @@ func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager {
|
||||
|
||||
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
|
||||
return Create(s, func() accesslogs.Manager {
|
||||
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())
|
||||
accessLogManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(s.DBConn()), s.Store(), s.PermissionsManager(), s.GeoLocationManager())
|
||||
accessLogManager.StartPeriodicCleanup(
|
||||
context.Background(),
|
||||
s.Config.ReverseProxy.AccessLogRetentionDays,
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||
|
||||
@@ -20,7 +18,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/auth"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
)
|
||||
@@ -52,12 +49,6 @@ func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValida
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) ProxyController() port_forwarding.Controller {
|
||||
return Create(s, func() port_forwarding.Controller {
|
||||
return integrations.NewController(s.Store())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) SecretsManager() grpc.SecretsManager {
|
||||
return Create(s, func() grpc.SecretsManager {
|
||||
secretsManager, err := grpc.NewTimeBasedAuthSecretsManager(s.PeersUpdateManager(), s.Config.TURNConfig, s.Config.Relay, s.SettingsManager(), s.GroupsManager())
|
||||
@@ -123,7 +114,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager {
|
||||
|
||||
func (s *BaseServer) NetworkMapController() network_map.Controller {
|
||||
return Create(s, func() network_map.Controller {
|
||||
return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore())
|
||||
return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.EphemeralManager(), s.Config, s.NetworkMapStore())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
@@ -20,7 +21,6 @@ import (
|
||||
recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
@@ -96,13 +96,14 @@ func (s *BaseServer) PeersManager() peers.Manager {
|
||||
|
||||
func (s *BaseServer) AccountManager() account.Manager {
|
||||
return Create(s, func() account.Manager {
|
||||
accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore())
|
||||
accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create account service: %v", err)
|
||||
}
|
||||
|
||||
s.AfterInit(func(s *BaseServer) {
|
||||
accountManager.SetServiceManager(s.ServiceManager())
|
||||
accountManager.AddAccountDeletionHook(s.AgentNetworkManager().RemoveAccountGateway)
|
||||
})
|
||||
|
||||
return accountManager
|
||||
@@ -247,7 +248,3 @@ func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
|
||||
return &m
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/metrics"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/lifecycle"
|
||||
"github.com/netbirdio/netbird/shared/profiling"
|
||||
"github.com/netbirdio/netbird/util/wsproxy"
|
||||
wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
@@ -36,6 +38,8 @@ const (
|
||||
DefaultSelfHostedDomain = "netbird.selfhosted"
|
||||
|
||||
ContainerKeyBaseServer = "baseServer"
|
||||
|
||||
applicationName = "management"
|
||||
)
|
||||
|
||||
type Server interface {
|
||||
@@ -64,6 +68,7 @@ type BaseServer struct {
|
||||
mgmtMetricsPort int
|
||||
mgmtPort int
|
||||
disableLegacyManagementPort bool
|
||||
letsEncryptListenAddress string
|
||||
autoResolveDomains bool
|
||||
|
||||
proxyAuthClose func()
|
||||
@@ -78,10 +83,14 @@ type BaseServer struct {
|
||||
tlsConfig *tls.Config
|
||||
certManager *autocert.Manager
|
||||
update *version.Update
|
||||
// certListener serves Let's Encrypt challenges when mgmtPort is not 443.
|
||||
certListener net.Listener
|
||||
|
||||
errCh chan error
|
||||
wg sync.WaitGroup
|
||||
cancel context.CancelFunc
|
||||
|
||||
lifecycle.StopHandlers
|
||||
}
|
||||
|
||||
// Config holds the configuration parameters for creating a new server
|
||||
@@ -97,6 +106,9 @@ type Config struct {
|
||||
UserDeleteFromIDPEnabled bool
|
||||
AutoResolveDomains bool
|
||||
TLSConfig *tls.Config
|
||||
// LetsEncryptListenAddress is the separate Let's Encrypt challenge listener
|
||||
// used when MgmtPort is not 443. Empty disables it.
|
||||
LetsEncryptListenAddress string
|
||||
}
|
||||
|
||||
// NewServer initializes and configures a new Server instance
|
||||
@@ -111,12 +123,16 @@ func NewServer(cfg *Config) *BaseServer {
|
||||
userDeleteFromIDPEnabled: cfg.UserDeleteFromIDPEnabled,
|
||||
mgmtPort: cfg.MgmtPort,
|
||||
disableLegacyManagementPort: cfg.DisableLegacyManagementPort,
|
||||
letsEncryptListenAddress: cfg.LetsEncryptListenAddress,
|
||||
mgmtMetricsPort: cfg.MgmtMetricsPort,
|
||||
autoResolveDomains: cfg.AutoResolveDomains,
|
||||
tlsConfig: cfg.TLSConfig,
|
||||
}
|
||||
s.container[ContainerKeyBaseServer] = s
|
||||
|
||||
stopProfiling := profiling.Start(applicationName)
|
||||
s.OnStop(stopProfiling)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -126,6 +142,14 @@ func (s *BaseServer) AfterInit(fn func(s *BaseServer)) {
|
||||
|
||||
// Start begins listening for HTTP requests on the configured address
|
||||
func (s *BaseServer) Start(ctx context.Context) error {
|
||||
if err := s.start(ctx); err != nil {
|
||||
s.RunStopHandlers()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaseServer) start(ctx context.Context) error {
|
||||
srvCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.errCh = make(chan error, 4)
|
||||
@@ -193,19 +217,18 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.IDPHandler(), s.Metrics().GetMeter())
|
||||
switch {
|
||||
case s.certManager != nil:
|
||||
// a call to certManager.Listener() always creates a new listener so we do it once
|
||||
cml := s.certManager.Listener()
|
||||
if s.mgmtPort == 443 {
|
||||
// CertManager, HTTP and gRPC API all on the same port
|
||||
rootHandler = s.certManager.HTTPHandler(rootHandler)
|
||||
s.listener = cml
|
||||
s.listener = s.certManager.Listener()
|
||||
} else {
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.certManager.TLSConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err)
|
||||
}
|
||||
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
|
||||
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
|
||||
if err := s.serveLetsEncryptChallenges(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case s.tlsConfig != nil:
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig)
|
||||
@@ -278,6 +301,7 @@ func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) {
|
||||
func (s *BaseServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
defer s.RunStopHandlers()
|
||||
if s.domainCleanupStop != nil {
|
||||
s.domainCleanupStop()
|
||||
}
|
||||
@@ -291,8 +315,8 @@ func (s *BaseServer) Stop() error {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
if s.certManager != nil {
|
||||
_ = s.certManager.Listener().Close()
|
||||
if s.certListener != nil {
|
||||
_ = s.certListener.Close()
|
||||
}
|
||||
s.GRPCServer().Stop()
|
||||
if s.proxyAuthClose != nil {
|
||||
@@ -398,6 +422,26 @@ func (s *BaseServer) serveGRPC(ctx context.Context, grpcServer *grpc.Server, por
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
// serveLetsEncryptChallenges starts the separate Let's Encrypt challenge listener
|
||||
// unless it is disabled. The main TLS listener uses the cert manager's TLS
|
||||
// config, so it still answers TLS-ALPN-01 challenges when public port 443 is
|
||||
// forwarded to it.
|
||||
func (s *BaseServer) serveLetsEncryptChallenges(ctx context.Context) error {
|
||||
if s.letsEncryptListenAddress == "" {
|
||||
log.WithContext(ctx).Infof("LetsEncrypt challenge server disabled, challenges are answered on port %d", s.mgmtPort)
|
||||
return nil
|
||||
}
|
||||
|
||||
cml, err := tls.Listen("tcp", s.letsEncryptListenAddress, s.certManager.TLSConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create LetsEncrypt challenge listener on %s: %w", s.letsEncryptListenAddress, err)
|
||||
}
|
||||
s.certListener = cml
|
||||
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
|
||||
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaseServer) serveHTTP(ctx context.Context, httpListener net.Listener, handler http.Handler) {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
)
|
||||
|
||||
func newLetsEncryptTestServer(address string) *BaseServer {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}, MgmtPort: 8443, LetsEncryptListenAddress: address})
|
||||
srv.certManager = &autocert.Manager{}
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestServeLetsEncryptChallenges_Disabled(t *testing.T) {
|
||||
srv := newLetsEncryptTestServer("")
|
||||
|
||||
require.NoError(t, srv.serveLetsEncryptChallenges(context.Background()))
|
||||
require.Nil(t, srv.certListener, "no challenge listener should be created when the address is empty")
|
||||
}
|
||||
|
||||
func TestServeLetsEncryptChallenges_CustomAddress(t *testing.T) {
|
||||
srv := newLetsEncryptTestServer("127.0.0.1:0")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
if srv.certListener != nil {
|
||||
_ = srv.certListener.Close()
|
||||
}
|
||||
srv.wg.Wait()
|
||||
})
|
||||
|
||||
require.NoError(t, srv.serveLetsEncryptChallenges(ctx))
|
||||
require.NotNil(t, srv.certListener, "challenge listener should be created on the configured address")
|
||||
|
||||
conn, err := net.DialTimeout("tcp", srv.certListener.Addr().String(), time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.Close())
|
||||
}
|
||||
|
||||
func TestServeLetsEncryptChallenges_BindFailure(t *testing.T) {
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = occupied.Close() })
|
||||
srv := newLetsEncryptTestServer(occupied.Addr().String())
|
||||
|
||||
require.Error(t, srv.serveLetsEncryptChallenges(context.Background()))
|
||||
require.Nil(t, srv.certListener, "no challenge listener should be stored when the bind fails")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTransactionTimeout = 5 * time.Minute
|
||||
connMaxLifetime = time.Hour
|
||||
connMaxIdleTime = 3 * time.Minute
|
||||
)
|
||||
|
||||
// TxMetrics receives the duration of every committed top-level transaction.
|
||||
type TxMetrics interface {
|
||||
CountTransactionDuration(duration time.Duration)
|
||||
}
|
||||
|
||||
// Conn is the database connection shared by all repositories: one gorm handle,
|
||||
// the pgx pool of a Postgres deployment and the engine they talk to.
|
||||
type Conn struct {
|
||||
db *gorm.DB
|
||||
pool *pgxpool.Pool
|
||||
engine Engine
|
||||
txTimeout time.Duration
|
||||
metrics TxMetrics
|
||||
}
|
||||
|
||||
// NewConn takes ownership of an open gorm handle and pool once it returns
|
||||
// without error, applying the connection limits and transaction timeout
|
||||
// configured through the environment.
|
||||
func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool.Pool) (*Conn, error) {
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
txTimeout := defaultTransactionTimeout
|
||||
if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" {
|
||||
if parsed, err := time.ParseDuration(v); err == nil {
|
||||
txTimeout = parsed
|
||||
}
|
||||
}
|
||||
log.WithContext(ctx).Infof("Setting transaction timeout to %v", txTimeout)
|
||||
|
||||
conns := runtime.NumCPU()
|
||||
configuredConns, err := strconv.Atoi(os.Getenv("NB_SQL_MAX_OPEN_CONNS"))
|
||||
connsConfigured := err == nil
|
||||
if connsConfigured {
|
||||
conns = configuredConns
|
||||
}
|
||||
if engine == SqliteStoreEngine {
|
||||
if connsConfigured {
|
||||
log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1")
|
||||
}
|
||||
conns = 1
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(conns)
|
||||
sqlDB.SetMaxIdleConns(conns)
|
||||
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||
sqlDB.SetConnMaxIdleTime(connMaxIdleTime)
|
||||
|
||||
log.WithContext(ctx).Infof("Set max open db connections to %d, max idle to %d, max lifetime to %v, max idle time to %v",
|
||||
conns, conns, connMaxLifetime, connMaxIdleTime)
|
||||
|
||||
return &Conn{db: gormDB, pool: pool, engine: engine, txTimeout: txTimeout}, nil
|
||||
}
|
||||
|
||||
// DB returns the handle a query must run on: the transaction when tx is set,
|
||||
// otherwise the shared connection.
|
||||
func (c *Conn) DB(tx *Tx) *gorm.DB {
|
||||
if tx != nil {
|
||||
return tx.db
|
||||
}
|
||||
return c.db
|
||||
}
|
||||
|
||||
// Pool returns the pgx pool for read paths that bypass gorm. It is nil on
|
||||
// engines other than Postgres and inside a transaction, where the pool would
|
||||
// not see the uncommitted writes.
|
||||
func (c *Conn) Pool(tx *Tx) *pgxpool.Pool {
|
||||
if tx != nil {
|
||||
return nil
|
||||
}
|
||||
return c.pool
|
||||
}
|
||||
|
||||
func (c *Conn) Engine() Engine {
|
||||
return c.engine
|
||||
}
|
||||
|
||||
// SetTxMetrics registers the sink that receives transaction durations.
|
||||
func (c *Conn) SetTxMetrics(metrics TxMetrics) {
|
||||
c.metrics = metrics
|
||||
}
|
||||
|
||||
// AutoMigrate creates or updates the tables of the given models.
|
||||
func (c *Conn) AutoMigrate(models ...any) error {
|
||||
return c.db.AutoMigrate(models...)
|
||||
}
|
||||
|
||||
// Close releases the gorm connection and the pgx pool.
|
||||
func (c *Conn) Close() error {
|
||||
if c.pool != nil {
|
||||
c.pool.Close()
|
||||
}
|
||||
sqlDB, err := c.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get db: %w", err)
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string
|
||||
}
|
||||
|
||||
func openTestConn(t *testing.T) *Conn {
|
||||
t.Helper()
|
||||
conn, err := OpenSqliteFile(context.Background(), t.TempDir(), SqliteFileName)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||
require.NoError(t, conn.AutoMigrate(&testRow{}))
|
||||
return conn
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, conn *Conn) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
require.NoError(t, conn.DB(nil).Model(&testRow{}).Count(&count).Error)
|
||||
return count
|
||||
}
|
||||
|
||||
func TestNewConn_ReadsTransactionTimeoutFromEnv(t *testing.T) {
|
||||
t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s")
|
||||
conn := openTestConn(t)
|
||||
assert.Equal(t, time.Second, conn.txTimeout)
|
||||
assert.Equal(t, SqliteStoreEngine, conn.Engine())
|
||||
}
|
||||
|
||||
func TestRunInTx_CommitsOnSuccess(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
|
||||
err := conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
return conn.DB(tx).Create(&testRow{Name: "a"}).Error
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, countRows(t, conn))
|
||||
}
|
||||
|
||||
func TestRunInTx_RollsBackOnError(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
failure := errors.New("boom")
|
||||
|
||||
err := conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error)
|
||||
return failure
|
||||
})
|
||||
require.ErrorIs(t, err, failure)
|
||||
assert.EqualValues(t, 0, countRows(t, conn))
|
||||
}
|
||||
|
||||
func TestRunInTx_RollsBackOnPanic(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
|
||||
require.Panics(t, func() {
|
||||
_ = conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error)
|
||||
panic("boom")
|
||||
})
|
||||
})
|
||||
assert.EqualValues(t, 0, countRows(t, conn))
|
||||
}
|
||||
|
||||
func TestRunInTx_FailsWhenTimeoutExceeded(t *testing.T) {
|
||||
t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "50ms")
|
||||
conn := openTestConn(t)
|
||||
|
||||
err := conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return conn.DB(tx).Create(&testRow{Name: "a"}).Error
|
||||
})
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.EqualValues(t, 0, countRows(t, conn))
|
||||
}
|
||||
|
||||
func TestRunInTx_ReportsDurationToMetrics(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
metrics := &recordingMetrics{}
|
||||
conn.SetTxMetrics(metrics)
|
||||
|
||||
require.NoError(t, conn.RunInTx(context.Background(), func(*Tx) error { return nil }))
|
||||
assert.Equal(t, 1, metrics.calls)
|
||||
}
|
||||
|
||||
func TestConn_DBSelectsTransactionHandle(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
assert.Same(t, conn.db, conn.DB(nil))
|
||||
|
||||
err := conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
assert.Same(t, tx.db, conn.DB(tx))
|
||||
assert.NotSame(t, conn.db, conn.DB(tx))
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConn_PoolIsUnavailableInsideTransaction(t *testing.T) {
|
||||
conn := openTestConn(t)
|
||||
conn.pool = &pgxpool.Pool{}
|
||||
defer func() { conn.pool = nil }()
|
||||
|
||||
assert.Same(t, conn.pool, conn.Pool(nil))
|
||||
err := conn.RunInTx(context.Background(), func(tx *Tx) error {
|
||||
assert.Nil(t, conn.Pool(tx))
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type recordingMetrics struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *recordingMetrics) CountTransactionDuration(time.Duration) {
|
||||
m.calls++
|
||||
}
|
||||
|
||||
func TestNewConn_MaxOpenConnsFromEnv(t *testing.T) {
|
||||
t.Setenv("NB_SQL_MAX_OPEN_CONNS", "7")
|
||||
|
||||
gormDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "store.db")), GormConfig())
|
||||
require.NoError(t, err)
|
||||
conn, err := NewConn(context.Background(), gormDB, PostgresStoreEngine, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
sqlDB, err := conn.DB(nil).DB()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 7, sqlDB.Stats().MaxOpenConnections)
|
||||
|
||||
sqliteDB, err := openTestConn(t).DB(nil).DB()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sqliteDB.Stats().MaxOpenConnections)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
)
|
||||
|
||||
// NewConn opens a fresh SQLite database in a temporary directory, migrates the
|
||||
// given models and closes the connection when the test ends. It ignores
|
||||
// NB_STORE_ENGINE_SQLITE_FILE, so a developer's configured database is never
|
||||
// touched, and is safe to call from parallel tests.
|
||||
func NewConn(t testing.TB, models ...any) *db.Conn {
|
||||
t.Helper()
|
||||
conn, err := db.OpenSqliteFile(context.Background(), t.TempDir(), db.SqliteFileName)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
require.NoError(t, conn.AutoMigrate(models...))
|
||||
return conn
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/shared/db"
|
||||
)
|
||||
|
||||
func TestNewConn_IgnoresSqliteFileOverride(t *testing.T) {
|
||||
override := filepath.Join(t.TempDir(), "configured.db")
|
||||
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", override)
|
||||
|
||||
conn := NewConn(t)
|
||||
|
||||
assert.Equal(t, db.SqliteStoreEngine, conn.Engine())
|
||||
_, err := os.Stat(override)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestNewConn_Parallel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := NewConn(t)
|
||||
|
||||
assert.Equal(t, db.SqliteStoreEngine, conn.Engine())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package db
|
||||
|
||||
// Engine identifies the SQL engine behind a Conn.
|
||||
type Engine string
|
||||
|
||||
const (
|
||||
SqliteStoreEngine Engine = "sqlite"
|
||||
PostgresStoreEngine Engine = "postgres"
|
||||
MysqlStoreEngine Engine = "mysql"
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package db
|
||||
|
||||
// LockingStrength is the row lock a query holds until its transaction ends.
|
||||
type LockingStrength string
|
||||
|
||||
const (
|
||||
LockingStrengthUpdate LockingStrength = "UPDATE"
|
||||
LockingStrengthShare LockingStrength = "SHARE"
|
||||
LockingStrengthNoKeyUpdate LockingStrength = "NO KEY UPDATE"
|
||||
LockingStrengthKeyShare LockingStrength = "KEY SHARE"
|
||||
LockingStrengthNone LockingStrength = "NONE"
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// SqliteFileName is the default SQLite database file inside the data directory.
|
||||
const SqliteFileName = "store.db"
|
||||
|
||||
// PoolConfig sizes the pgx pool a Postgres deployment uses for the read paths
|
||||
// that bypass gorm.
|
||||
type PoolConfig struct {
|
||||
MaxConns int32
|
||||
MinConns int32
|
||||
MaxConnLifetime time.Duration
|
||||
HealthCheckPeriod time.Duration
|
||||
}
|
||||
|
||||
var DefaultPoolConfig = PoolConfig{
|
||||
MaxConns: 30,
|
||||
MinConns: 1,
|
||||
MaxConnLifetime: 60 * time.Minute,
|
||||
HealthCheckPeriod: time.Minute,
|
||||
}
|
||||
|
||||
// GormConfig is the configuration every engine is opened with.
|
||||
func GormConfig() *gorm.Config {
|
||||
return &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
CreateBatchSize: 400,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenSqlite opens the SQLite database in dataDir, or the file named by
|
||||
// NB_STORE_ENGINE_SQLITE_FILE.
|
||||
func OpenSqlite(ctx context.Context, dataDir string) (*Conn, error) {
|
||||
storeFile := SqliteFileName
|
||||
if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" {
|
||||
storeFile = envFile
|
||||
}
|
||||
return OpenSqliteFile(ctx, dataDir, storeFile)
|
||||
}
|
||||
|
||||
// OpenSqliteFile opens the SQLite database storeFile, resolved against dataDir
|
||||
// when relative. storeFile may carry SQLite URI query parameters.
|
||||
func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, error) {
|
||||
// Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc")
|
||||
filePath, query, hasQuery := strings.Cut(storeFile, "?")
|
||||
|
||||
connStr := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
connStr = filepath.Join(dataDir, filePath)
|
||||
}
|
||||
|
||||
// Compose query parameters. User-provided ?_busy_timeout (or its mattn alias
|
||||
// ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at
|
||||
// most that long on a lock instead of blocking the only Go-side connection.
|
||||
// mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so
|
||||
// the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared
|
||||
// stays the default on non-Windows for the same reason as before.
|
||||
parsed, _ := url.ParseQuery(query)
|
||||
var defaults []string
|
||||
if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" {
|
||||
defaults = append(defaults, "_busy_timeout=30000")
|
||||
}
|
||||
if !hasQuery && runtime.GOOS != "windows" {
|
||||
// To avoid `The process cannot access the file because it is being used by another process` on Windows
|
||||
defaults = append(defaults, "cache=shared")
|
||||
}
|
||||
parts := defaults
|
||||
if hasQuery {
|
||||
parts = append(parts, query)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
connStr += "?" + strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
gormDB, err := gorm.Open(sqlite.Open(connStr), GormConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := NewConn(ctx, gormDB, SqliteStoreEngine, nil)
|
||||
if err != nil {
|
||||
closeGorm(gormDB)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool.
|
||||
func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, error) {
|
||||
gormDB, err := gorm.Open(postgres.Open(dsn), GormConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pgxPool, err := newPgxPool(ctx, dsn, pool)
|
||||
if err != nil {
|
||||
closeGorm(gormDB)
|
||||
return nil, err
|
||||
}
|
||||
conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool)
|
||||
if err != nil {
|
||||
pgxPool.Close()
|
||||
closeGorm(gormDB)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// MysqlDSN adds the connection parameters every MySQL handle needs, keeping
|
||||
// the options already present in dsn.
|
||||
func MysqlDSN(dsn string) string {
|
||||
separator := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dsn + separator + "charset=utf8&parseTime=True&loc=Local"
|
||||
}
|
||||
|
||||
// OpenMysql opens a MySQL database through gorm.
|
||||
func OpenMysql(ctx context.Context, dsn string) (*Conn, error) {
|
||||
gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil)
|
||||
if err != nil {
|
||||
closeGorm(gormDB)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) {
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse database config: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = cfg.MaxConns
|
||||
config.MinConns = cfg.MinConns
|
||||
config.MaxConnLifetime = cfg.MaxConnLifetime
|
||||
config.HealthCheckPeriod = cfg.HealthCheckPeriod
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("unable to ping database: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func closeGorm(gormDB *gorm.DB) {
|
||||
if sqlDB, err := gormDB.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMysqlDSN(t *testing.T) {
|
||||
assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db"))
|
||||
assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true"))
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tx is an open transaction handed to repository calls; nil means autocommit.
|
||||
type Tx struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// RunInTx runs fn in one transaction that commits when fn returns nil and rolls
|
||||
// back otherwise, bounded by the configured transaction timeout.
|
||||
func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, c.txTimeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
tx := c.db.WithContext(timeoutCtx).Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := c.applyStatementTimeouts(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err := c.withForeignKeyChecksDisabled(tx, func() error {
|
||||
return fn(&Tx{db: tx})
|
||||
})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
c.logIfTimedOut(ctx, timeoutCtx, err, "transaction commit", startTime)
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime))
|
||||
if c.metrics != nil {
|
||||
c.metrics.CountTransactionDuration(time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error {
|
||||
if c.engine != PostgresStoreEngine {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Exec("SET LOCAL statement_timeout = '1min'").Error; err != nil {
|
||||
return fmt.Errorf("failed to set statement timeout: %w", err)
|
||||
}
|
||||
if err := tx.Exec("SET LOCAL lock_timeout = '1min'").Error; err != nil {
|
||||
return fmt.Errorf("failed to set lock timeout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids
|
||||
// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is
|
||||
// session-scoped and survives a rollback, so it is turned back on whenever fn
|
||||
// returns or panics; otherwise the pooled connection would keep it disabled.
|
||||
func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) {
|
||||
if c.engine != MysqlStoreEngine {
|
||||
return fn()
|
||||
}
|
||||
if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil {
|
||||
return fmt.Errorf("failed to disable FK checks: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error
|
||||
if restoreErr == nil {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr)
|
||||
return
|
||||
}
|
||||
log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr)
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
|
||||
log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const certChallengeKeyDomain = "netbird-cert-challenge-key"
|
||||
|
||||
// newCertChallenger derives the nonce secret from the data store encryption key:
|
||||
// generated once, written back to the configuration, and read by every instance, so a
|
||||
// nonce stays verifiable across a restart and between instances.
|
||||
//
|
||||
// Where none is configured it falls back to the server's WireGuard key, which is
|
||||
// regenerated per process. Unpredictable is the property that has to hold either way: a
|
||||
// peer that could guess the secret would mint future windows' nonces, sign them while
|
||||
// its key is present, and keep passing after it is gone.
|
||||
func newCertChallenger(encryptionKey string, serverKey wgtypes.Key) *certposture.Challenger {
|
||||
secret := []byte(encryptionKey)
|
||||
if len(secret) == 0 {
|
||||
log.Warnf("no data store encryption key, deriving certificate challenges from the ephemeral server key: peers will be rejected once per restart and across instances")
|
||||
secret = serverKey[:]
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(certChallengeKeyDomain))
|
||||
h.Write(secret)
|
||||
return certposture.NewChallenger(h.Sum(nil))
|
||||
}
|
||||
|
||||
// stampCertificateChallenges fills the per-peer nonce into every certificate challenge
|
||||
// right before the response is encrypted for that peer, reporting whether it issued one.
|
||||
//
|
||||
// On a sync stream the answer registers the peer for renewal. A nonce is stateless, but
|
||||
// the renewal that keeps it fresh is local: only the instance holding a peer's stream
|
||||
// can push to it, so an instance renews exactly the peers it streams nonces to.
|
||||
func stampCertificateChallenges(checks []*proto.Checks, challenger *certposture.Challenger, peerKey wgtypes.Key) bool {
|
||||
var nonce []byte
|
||||
for _, check := range checks {
|
||||
challenge := check.GetCertificateChallenge()
|
||||
if challenge == nil {
|
||||
continue
|
||||
}
|
||||
if nonce == nil {
|
||||
nonce = challenger.Nonce(peerKey[:], time.Now())
|
||||
}
|
||||
challenge.Nonce = nonce
|
||||
}
|
||||
return nonce != nil
|
||||
}
|
||||
|
||||
// verifiedCertificates turns the peer's proofs into PEM chains for its meta. Possession
|
||||
// (nonce + signature) is verified here; trust against a check's CAs is evaluated by the
|
||||
// posture check itself. Each proof stands for its own key, so an invalid one is dropped
|
||||
// without the others: a user store answering with an unusable key must not cost the
|
||||
// device the machine certificate it proved.
|
||||
func (s *Server) verifiedCertificates(ctx context.Context, peerKey wgtypes.Key, proofs []*proto.CertificateProof) []string {
|
||||
if len(proofs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
chains := make([]string, 0, len(proofs))
|
||||
for _, p := range proofs {
|
||||
chain, err := s.challenger.Verify(certposture.Proof{
|
||||
Nonce: p.GetNonce(),
|
||||
Chain: p.GetChain(),
|
||||
SigAlg: p.GetSigAlg(),
|
||||
Signature: p.GetSignature(),
|
||||
}, peerKey[:], now)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("dropping a certificate proof of peer %s: %v", peerKey, err)
|
||||
continue
|
||||
}
|
||||
chains = append(chains, certposture.EncodeChainPEM(chain))
|
||||
}
|
||||
if len(chains) == 0 {
|
||||
return nil
|
||||
}
|
||||
return chains
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestCertificateChallenge_StampAndVerifyRoundTrip(t *testing.T) {
|
||||
const encryptionKey = "data-store-encryption-key"
|
||||
challenger := newCertChallenger(encryptionKey, generateKey(t))
|
||||
peerKey := generateKey(t).PublicKey()
|
||||
ca := certtest.NewCA(t, "corp-root")
|
||||
ctx := context.Background()
|
||||
|
||||
checks := toProtocolChecks(ctx, []*nmdata.PostureChecks{{
|
||||
ID: "cert-check",
|
||||
Checks: nmdata.ChecksDefinition{CertificateCheck: &nmdata.CertificateCheck{CACertificates: []string{ca.PEM}}},
|
||||
}})
|
||||
require.Len(t, checks, 1)
|
||||
require.Equal(t, []string{ca.PEM}, checks[0].GetCertificateChallenge().GetCaCertificates())
|
||||
require.Empty(t, checks[0].GetCertificateChallenge().GetNonce())
|
||||
|
||||
stampCertificateChallenges(checks, challenger, peerKey)
|
||||
nonce := checks[0].GetCertificateChallenge().GetNonce()
|
||||
require.NotEmpty(t, nonce)
|
||||
|
||||
deviceKey := certtest.ECDSAKey(t)
|
||||
leaf := ca.Issue(t, deviceKey, "device")
|
||||
sigAlg, sig, err := certposture.Sign(deviceKey, nonce, peerKey[:])
|
||||
require.NoError(t, err)
|
||||
proofs := []*proto.CertificateProof{{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}}
|
||||
|
||||
s := &Server{challenger: challenger}
|
||||
|
||||
chains := s.verifiedCertificates(ctx, peerKey, proofs)
|
||||
require.Len(t, chains, 1)
|
||||
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
|
||||
|
||||
t.Run("proof replayed by another peer is rejected", func(t *testing.T) {
|
||||
assert.Nil(t, s.verifiedCertificates(ctx, generateKey(t).PublicKey(), proofs))
|
||||
})
|
||||
t.Run("nonce from another deployment's secret is rejected", func(t *testing.T) {
|
||||
other := &Server{challenger: newCertChallenger("a-different-encryption-key", generateKey(t))}
|
||||
assert.Nil(t, other.verifiedCertificates(ctx, peerKey, proofs))
|
||||
})
|
||||
t.Run("a second instance with the same encryption key accepts it", func(t *testing.T) {
|
||||
// The point of deriving from the stored key: another instance, with its own
|
||||
// freshly generated WireGuard key, verifies what this one issued. This is also
|
||||
// what a restart looks like, and it used to reject.
|
||||
peer := &Server{challenger: newCertChallenger(encryptionKey, generateKey(t))}
|
||||
assert.Len(t, peer.verifiedCertificates(ctx, peerKey, proofs), 1)
|
||||
})
|
||||
t.Run("an invalid proof is dropped without the valid ones", func(t *testing.T) {
|
||||
// A device proving its machine certificate while a user store answers with a key
|
||||
// it cannot use must keep the machine certificate.
|
||||
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
|
||||
chains := s.verifiedCertificates(ctx, peerKey, append([]*proto.CertificateProof{bad}, proofs...))
|
||||
require.Len(t, chains, 1, "the valid proof survives the invalid one")
|
||||
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
|
||||
})
|
||||
t.Run("only invalid proofs yield no certificates", func(t *testing.T) {
|
||||
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
|
||||
assert.Empty(t, s.verifiedCertificates(ctx, peerKey, []*proto.CertificateProof{bad}))
|
||||
})
|
||||
t.Run("no proofs yields no certificates", func(t *testing.T) {
|
||||
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStampCertificateChallenges_SkipsFileOnlyChecks(t *testing.T) {
|
||||
checks := []*proto.Checks{{Files: []string{"/bin/agent"}}}
|
||||
stampCertificateChallenges(checks, newCertChallenger("key", generateKey(t)), generateKey(t).PublicKey())
|
||||
assert.Nil(t, checks[0].GetCertificateChallenge())
|
||||
}
|
||||
|
||||
func generateKey(t *testing.T) wgtypes.Key {
|
||||
t.Helper()
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
return key
|
||||
}
|
||||
@@ -28,10 +28,6 @@ type ComponentsEnvelopeInput struct {
|
||||
// SshAuth.UserIDClaim when reconstructing the NetworkMap. Empty value
|
||||
// is OK — client treats empty as "no SshAuth to build".
|
||||
UserIDClaim string
|
||||
// ProxyPatch carries pre-expanded NetworkMap fragments injected by
|
||||
// external controllers (BYOP/port-forwarding). Nil when no proxy data
|
||||
// is present; encoder skips the field in that case.
|
||||
ProxyPatch *proto.ProxyPatch
|
||||
}
|
||||
|
||||
// EncodeNetworkMapEnvelope converts NetworkMapComponents into the component
|
||||
@@ -69,7 +65,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
DnsForwarderPort: in.DNSForwarderPort,
|
||||
UserIdClaim: in.UserIDClaim,
|
||||
AccountSettings: &proto.AccountSettingsCompact{},
|
||||
ProxyPatch: in.ProxyPatch,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -101,7 +96,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
AccountSettings: toAccountSettingsCompact(c.AccountSettings),
|
||||
DnsForwarderPort: in.DNSForwarderPort,
|
||||
UserIdClaim: in.UserIDClaim,
|
||||
ProxyPatch: in.ProxyPatch,
|
||||
DnsSettings: enc.encodeDNSSettings(c.DNSSettings),
|
||||
DnsDomain: in.DNSDomain,
|
||||
CustomZoneDomain: c.CustomZoneDomain,
|
||||
|
||||
@@ -713,66 +713,6 @@ func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"user-4"}, full.GroupIdToUserIds["group-users"].UserIds)
|
||||
}
|
||||
|
||||
func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
|
||||
assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false, false))
|
||||
assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false, false),
|
||||
"empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field")
|
||||
}
|
||||
|
||||
func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
|
||||
nm := &types.NetworkMap{
|
||||
Peers: []*nmdata.Peer{{
|
||||
ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
|
||||
DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}},
|
||||
FirewallRules: []*types.FirewallRule{{
|
||||
PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
|
||||
}},
|
||||
}
|
||||
|
||||
patch := toProxyPatch(nm, "netbird.cloud", false, false, false)
|
||||
|
||||
require.NotNil(t, patch)
|
||||
assert.Len(t, patch.Peers, 1)
|
||||
assert.Len(t, patch.FirewallRules, 1)
|
||||
}
|
||||
|
||||
// TestEncodeNetworkMapEnvelope_ProxyPatchPropagated covers the ProxyPatch
|
||||
// pass-through in both encoder branches (normal path + nil-Components
|
||||
// graceful-degrade). Guards against a regression that drops `ProxyPatch:`
|
||||
// from one of the envelope struct literals.
|
||||
func TestEncodeNetworkMapEnvelope_ProxyPatchPropagated(t *testing.T) {
|
||||
patch := &proto.ProxyPatch{
|
||||
ForwardingRules: []*proto.ForwardingRule{{
|
||||
Protocol: proto.RuleProtocol_TCP,
|
||||
DestinationPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 80}},
|
||||
TranslatedAddress: net.IPv4(10, 0, 0, 1).To4(),
|
||||
TranslatedPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 8080}},
|
||||
}},
|
||||
}
|
||||
|
||||
t.Run("normal_path", func(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{
|
||||
Components: c,
|
||||
ProxyPatch: patch,
|
||||
}).GetFull()
|
||||
|
||||
require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the normal encode path")
|
||||
assert.Len(t, full.ProxyPatch.ForwardingRules, 1)
|
||||
})
|
||||
|
||||
t.Run("empty_components_graceful_degrade", func(t *testing.T) {
|
||||
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{
|
||||
Components: emptyNetworkMapComponents(),
|
||||
ProxyPatch: patch,
|
||||
}).GetFull()
|
||||
|
||||
require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the nil-Components branch too")
|
||||
assert.Len(t, full.ProxyPatch.ForwardingRules, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
|
||||
// nil Components → minimal envelope, no crash. Matches the legacy
|
||||
// behaviour for missing/unvalidated peers.
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
auth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
@@ -34,7 +33,6 @@ func ToComponentSyncResponse(
|
||||
turnCredentials *Token,
|
||||
relayCredentials *Token,
|
||||
components *types.NetworkMapComponents,
|
||||
proxyPatch *types.NetworkMap,
|
||||
dnsName string,
|
||||
checks []*nmdata.PostureChecks,
|
||||
settings *nmdata.AccountSettingsInfo,
|
||||
@@ -51,9 +49,6 @@ func ToComponentSyncResponse(
|
||||
enableSSH := computeSSHEnabledForPeer(components, peer)
|
||||
peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution)
|
||||
|
||||
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
|
||||
useSourcePrefixes := peer.SupportsSourcePrefixes()
|
||||
|
||||
userIDClaim := auth.DefaultUserIDClaim
|
||||
if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
|
||||
userIDClaim = httpConfig.AuthUserIDClaim
|
||||
@@ -65,7 +60,6 @@ func ToComponentSyncResponse(
|
||||
DNSDomain: dnsName,
|
||||
DNSForwarderPort: dnsFwdPort,
|
||||
UserIDClaim: userIDClaim,
|
||||
ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes, peer.ProxyMeta.Embedded),
|
||||
})
|
||||
|
||||
resp := &proto.SyncResponse{
|
||||
@@ -91,43 +85,6 @@ func ToComponentSyncResponse(
|
||||
return resp
|
||||
}
|
||||
|
||||
// toProxyPatch converts a proxy-injected *types.NetworkMap into the wire
|
||||
// patch the components envelope ships alongside. Returns nil when there are
|
||||
// no fragments to merge — proto3 omits a nil message field, so the receiver
|
||||
// sees no patch and skips the merge step entirely.
|
||||
//
|
||||
// We reuse the legacy proto-conversion helpers (toProtocolRoutes,
|
||||
// toProtocolFirewallRules, toProtocolRoutesFirewallRules,
|
||||
// appendRemotePeerConfig, ForwardingRule.ToProto) because the proxy
|
||||
// delivers fragments pre-expanded — there's no raw component shape to
|
||||
// derive them from. Components purity isn't violated: proxy data isn't
|
||||
// policy-graph-derived, it's externally injected post-Calculate, so the
|
||||
// client merges it on top of its locally-computed NetworkMap.
|
||||
func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes, localIsProxy bool) *proto.ProxyPatch {
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
if len(nm.Peers) == 0 && len(nm.OfflinePeers) == 0 && len(nm.FirewallRules) == 0 &&
|
||||
len(nm.Routes) == 0 && len(nm.RoutesFirewallRules) == 0 && len(nm.ForwardingRules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patch := &proto.ProxyPatch{
|
||||
Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6, localIsProxy),
|
||||
OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy),
|
||||
FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes),
|
||||
Routes: networkmap.ToProtocolRoutes(nm.Routes),
|
||||
RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules),
|
||||
}
|
||||
if len(nm.ForwardingRules) > 0 {
|
||||
patch.ForwardingRules = make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules))
|
||||
for _, r := range nm.ForwardingRules {
|
||||
patch.ForwardingRules = append(patch.ForwardingRules, r.ToProto())
|
||||
}
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
// computeSSHEnabledForPeer mirrors the SSH-server-activation bit that
|
||||
// Calculate() folds into NetworkMap.EnableSSH. Components-format peers
|
||||
// receive a freshly-computed PeerConfig.SshConfig.SshEnabled at sync time;
|
||||
|
||||
@@ -195,21 +195,12 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
|
||||
response.NetworkMap.RoutesFirewallRules = routesFirewallRules
|
||||
response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
|
||||
|
||||
if networkMap.ForwardingRules != nil {
|
||||
forwardingRules := make([]*proto.ForwardingRule, 0, len(networkMap.ForwardingRules))
|
||||
for _, rule := range networkMap.ForwardingRules {
|
||||
forwardingRules = append(forwardingRules, rule.ToProto())
|
||||
}
|
||||
response.NetworkMap.ForwardingRules = forwardingRules
|
||||
}
|
||||
|
||||
userIDClaim := auth.DefaultUserIDClaim
|
||||
if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
|
||||
userIDClaim = httpConfig.AuthUserIDClaim
|
||||
}
|
||||
|
||||
if networkMap.AuthorizedUsers != nil {
|
||||
hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers)
|
||||
userIDClaim := auth.DefaultUserIDClaim
|
||||
if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
|
||||
userIDClaim = httpConfig.AuthUserIDClaim
|
||||
}
|
||||
response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit
|
||||
reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban
|
||||
metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer
|
||||
maxBanLevel = 6 // Highest ban level; the ban duration doubles per level up to this one
|
||||
)
|
||||
|
||||
type lfConfig struct {
|
||||
@@ -21,6 +22,7 @@ type lfConfig struct {
|
||||
baseBlockDuration time.Duration
|
||||
reconnLimitForBan int
|
||||
metaChangeLimit int
|
||||
maxBanLevel int
|
||||
}
|
||||
|
||||
func initCfg() *lfConfig {
|
||||
@@ -29,6 +31,7 @@ func initCfg() *lfConfig {
|
||||
baseBlockDuration: baseBlockDuration,
|
||||
reconnLimitForBan: reconnLimitForBan,
|
||||
metaChangeLimit: metaChangeLimit,
|
||||
maxBanLevel: maxBanLevel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +105,18 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) {
|
||||
return
|
||||
}
|
||||
|
||||
if state.isBanned && now.After(state.banExpiresAt) {
|
||||
if state.isBanned {
|
||||
if now.Before(state.banExpiresAt) {
|
||||
return
|
||||
}
|
||||
state.isBanned = false
|
||||
}
|
||||
|
||||
if state.banLevel > 0 && now.Sub(state.lastSeen) > (2*l.cfg.baseBlockDuration) {
|
||||
quietSince := state.lastSeen
|
||||
if state.banExpiresAt.After(quietSince) {
|
||||
quietSince = state.banExpiresAt
|
||||
}
|
||||
if state.banLevel > 0 && now.Sub(quietSince) > (2*l.cfg.baseBlockDuration) {
|
||||
state.banLevel = 0
|
||||
}
|
||||
|
||||
@@ -124,10 +134,17 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) {
|
||||
return
|
||||
}
|
||||
|
||||
if now.Sub(state.sessionStart) >= l.cfg.reconnThreshold {
|
||||
state.sessionStart = now
|
||||
state.sessionCounter = 0
|
||||
}
|
||||
|
||||
state.sessionCounter++
|
||||
if state.sessionCounter > l.cfg.reconnLimitForBan && now.Sub(state.sessionStart) < l.cfg.reconnThreshold {
|
||||
if state.sessionCounter > l.cfg.reconnLimitForBan {
|
||||
state.isBanned = true
|
||||
state.banLevel++
|
||||
if state.banLevel < l.cfg.maxBanLevel {
|
||||
state.banLevel++
|
||||
}
|
||||
|
||||
backoffFactor := math.Pow(2, float64(state.banLevel-1))
|
||||
duration := time.Duration(float64(l.cfg.baseBlockDuration) * backoffFactor)
|
||||
|
||||
@@ -20,6 +20,7 @@ func testAdvancedCfg() *lfConfig {
|
||||
baseBlockDuration: 100 * time.Millisecond,
|
||||
reconnLimitForBan: 3,
|
||||
metaChangeLimit: 2,
|
||||
maxBanLevel: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +158,187 @@ func (s *LoginFilterTestSuite) TestMetaChangeIsAllowedAfterWindowResets() {
|
||||
s.Equal(1, s.filter.logged[pubKey].metaChangeCounter, "meta change counter should reset")
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestReconnectStormAfterQuietPeriodTriggersBan() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
s.Require().Contains(s.filter.logged, pubKey)
|
||||
s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second))
|
||||
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
s.Equal(1, s.filter.logged[pubKey].sessionCounter, "expired window should restart the count")
|
||||
|
||||
for i := 1; i < limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
s.True(s.filter.allowLogin(pubKey, meta))
|
||||
s.False(s.filter.logged[pubKey].isBanned)
|
||||
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
|
||||
s.False(s.filter.allowLogin(pubKey, meta))
|
||||
s.True(s.filter.logged[pubKey].isBanned)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestReconnectStormAfterBanExpiresTriggersBanAgain() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
s.Require().Contains(s.filter.logged, pubKey)
|
||||
s.Require().True(s.filter.logged[pubKey].isBanned)
|
||||
|
||||
expired := time.Now().Add(-(s.filter.cfg.baseBlockDuration + time.Second))
|
||||
s.filter.logged[pubKey].banExpiresAt = expired
|
||||
s.filter.logged[pubKey].sessionStart = expired
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
|
||||
s.True(s.filter.logged[pubKey].isBanned)
|
||||
s.Equal(2, s.filter.logged[pubKey].banLevel)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestSlowReconnectsAcrossWindowsDoNotBan() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
s.Require().Contains(s.filter.logged, pubKey)
|
||||
s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second))
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
|
||||
s.True(s.filter.allowLogin(pubKey, meta))
|
||||
s.False(s.filter.logged[pubKey].isBanned)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestBanLevelEscalatesWhenStormResumesRightAfterBan() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
banTime := time.Now().Add(-3 * s.filter.cfg.baseBlockDuration)
|
||||
|
||||
s.filter.logged[pubKey] = &peerState{
|
||||
currentHash: meta,
|
||||
isBanned: true,
|
||||
banLevel: 1,
|
||||
banExpiresAt: time.Now().Add(-time.Millisecond),
|
||||
sessionStart: banTime,
|
||||
lastSeen: banTime,
|
||||
}
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
|
||||
s.True(s.filter.logged[pubKey].isBanned)
|
||||
s.Equal(2, s.filter.logged[pubKey].banLevel)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestBanLevelResetsAfterQuietPeriodFollowingBan() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
quiet := 2*s.filter.cfg.baseBlockDuration + time.Second
|
||||
|
||||
s.filter.logged[pubKey] = &peerState{
|
||||
currentHash: meta,
|
||||
banLevel: 2,
|
||||
banExpiresAt: time.Now().Add(-s.filter.cfg.baseBlockDuration),
|
||||
lastSeen: time.Now().Add(-2 * quiet),
|
||||
}
|
||||
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
s.Equal(2, s.filter.logged[pubKey].banLevel, "ban ended more recently than the quiet period")
|
||||
|
||||
s.filter.logged[pubKey].banExpiresAt = time.Now().Add(-quiet)
|
||||
s.filter.logged[pubKey].lastSeen = time.Now().Add(-2 * quiet)
|
||||
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
s.Equal(0, s.filter.logged[pubKey].banLevel)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestBanDurationIsCappedAtMaxLevel() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
maxLevel := s.filter.cfg.maxBanLevel
|
||||
|
||||
s.filter.logged[pubKey] = &peerState{
|
||||
currentHash: meta,
|
||||
banLevel: maxLevel,
|
||||
sessionStart: time.Now(),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
|
||||
s.True(s.filter.logged[pubKey].isBanned)
|
||||
s.Equal(maxLevel, s.filter.logged[pubKey].banLevel)
|
||||
expected := s.filter.cfg.baseBlockDuration << (maxLevel - 1)
|
||||
s.InDelta(expected, s.filter.logged[pubKey].banExpiresAt.Sub(s.filter.logged[pubKey].lastSeen), float64(time.Millisecond))
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestEstablishedPeerReconnectingOnceIsAllowed() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
longAgo := time.Now().Add(-time.Hour)
|
||||
|
||||
s.filter.logged[pubKey] = &peerState{
|
||||
currentHash: meta,
|
||||
sessionCounter: 1,
|
||||
sessionStart: longAgo,
|
||||
lastSeen: longAgo,
|
||||
metaChangeWindowStart: longAgo,
|
||||
metaChangeCounter: 1,
|
||||
}
|
||||
|
||||
s.True(s.filter.allowLogin(pubKey, meta))
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
|
||||
s.True(s.filter.allowLogin(pubKey, meta))
|
||||
s.False(s.filter.logged[pubKey].isBanned)
|
||||
s.Equal(1, s.filter.logged[pubKey].sessionCounter)
|
||||
}
|
||||
|
||||
func (s *LoginFilterTestSuite) TestLoginsDuringActiveBanDoNotExtendIt() {
|
||||
pubKey := "PUB_KEY_A"
|
||||
meta := uint64(1)
|
||||
limit := s.filter.cfg.reconnLimitForBan
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
s.Require().Contains(s.filter.logged, pubKey)
|
||||
s.Require().True(s.filter.logged[pubKey].isBanned)
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
s.filter.logged[pubKey].banExpiresAt = expiresAt
|
||||
lastSeen := s.filter.logged[pubKey].lastSeen
|
||||
|
||||
for i := 0; i <= limit; i++ {
|
||||
s.filter.addLogin(pubKey, meta)
|
||||
}
|
||||
|
||||
s.True(s.filter.logged[pubKey].isBanned)
|
||||
s.Equal(1, s.filter.logged[pubKey].banLevel)
|
||||
s.Equal(expiresAt, s.filter.logged[pubKey].banExpiresAt)
|
||||
s.Equal(lastSeen, s.filter.logged[pubKey].lastSeen)
|
||||
s.Equal(0, s.filter.logged[pubKey].sessionCounter)
|
||||
}
|
||||
|
||||
func BenchmarkHashingMethods(b *testing.B) {
|
||||
meta := nbpeer.PeerSystemMeta{
|
||||
WtVersion: "1.25.1",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestExtractPeerMeta_RemovesNulAndInvalidUTF8(t *testing.T) {
|
||||
meta := extractPeerMeta(context.Background(), &proto.PeerSystemMeta{
|
||||
Hostname: "host\x00name",
|
||||
OS: "\x00\x00\x00\x00\x00\x00s",
|
||||
OSVersion: "11\xff",
|
||||
SysSerialNumber: "SN\x00\x00",
|
||||
Environment: &proto.Environment{Cloud: "\x00aws"},
|
||||
NetworkAddresses: []*proto.NetworkAddress{
|
||||
{NetIP: "192.168.0.1/24", Mac: "e0:d5\x00"},
|
||||
},
|
||||
Files: []*proto.File{{Path: "C:\\x\x00.exe"}},
|
||||
})
|
||||
|
||||
assert.Equal(t, "hostname", meta.Hostname)
|
||||
assert.Equal(t, "s", meta.OS)
|
||||
assert.Equal(t, "11", meta.OSVersion)
|
||||
assert.Equal(t, "SN", meta.SystemSerialNumber)
|
||||
assert.Equal(t, "aws", meta.Environment.Cloud)
|
||||
assert.Equal(t, "e0:d5", meta.NetworkAddresses[0].Mac)
|
||||
assert.Equal(t, "C:\\x.exe", meta.Files[0].Path)
|
||||
}
|
||||
|
||||
func TestCleanClientString(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "",
|
||||
"\x00\x00": "",
|
||||
"Wïndows": "Wïndows",
|
||||
"ssh-ed25519 AAA\x00": "ssh-ed25519 AAA",
|
||||
"bad\xc3\x28utf8": "bad(utf8",
|
||||
}
|
||||
for in, want := range tests {
|
||||
assert.Equal(t, want, cleanClientString(in), "input %q", in)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
@@ -18,16 +19,20 @@ func PeerUpdateHandlerFactory(
|
||||
peerKey wgtypes.Key,
|
||||
updates chan *network_map.UpdateMessage,
|
||||
secretsManager SecretsManager,
|
||||
challenger *certposture.Challenger,
|
||||
onChallengeStamped func(),
|
||||
srv proto.ManagementService_SyncServer,
|
||||
cleanupfunc func()) *PeerUpdateHandler {
|
||||
return &PeerUpdateHandler{
|
||||
peerKey: peerKey,
|
||||
updates: updates,
|
||||
secretsManager: secretsManager,
|
||||
srv: srv,
|
||||
encrypter: encryption.DefaultEncrypter{},
|
||||
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
|
||||
cleanupFunc: cleanupfunc,
|
||||
peerKey: peerKey,
|
||||
updates: updates,
|
||||
secretsManager: secretsManager,
|
||||
challenger: challenger,
|
||||
onChallengeStamped: onChallengeStamped,
|
||||
srv: srv,
|
||||
encrypter: encryption.DefaultEncrypter{},
|
||||
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
|
||||
cleanupFunc: cleanupfunc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +45,15 @@ type PeerUpdateHandler struct {
|
||||
updates chan *network_map.UpdateMessage
|
||||
appMetrics telemetry.AppMetrics
|
||||
secretsManager SecretsManager
|
||||
srv syncSender
|
||||
encrypter encryption.Encrypter
|
||||
debouncer Debouncer
|
||||
cleanupFunc func()
|
||||
challenger *certposture.Challenger
|
||||
// onChallengeStamped registers the account for renewal. Every push reaches this
|
||||
// path, including the ones a REST update on another instance broadcasts, which is
|
||||
// what lets an instance pick up an account whose peers never reconnect.
|
||||
onChallengeStamped func()
|
||||
srv syncSender
|
||||
encrypter encryption.Encrypter
|
||||
debouncer Debouncer
|
||||
cleanupFunc func()
|
||||
}
|
||||
|
||||
func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler {
|
||||
@@ -117,6 +127,9 @@ func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
|
||||
if stampCertificateChallenges(update.Update.GetChecks(), pu.challenger, pu.peerKey) && pu.onChallengeStamped != nil {
|
||||
pu.onChallengeStamped()
|
||||
}
|
||||
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
|
||||
if err != nil {
|
||||
pu.cleanupFunc()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/client/common"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestApplySessionExtendFlowPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flow *proto.PKCEAuthorizationFlow
|
||||
sessionExtend bool
|
||||
disablePromptLogin bool
|
||||
loginFlag uint32
|
||||
}{
|
||||
{
|
||||
name: "extend replaces max_age=0 so login_hint is honoured",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: false,
|
||||
LoginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
},
|
||||
sessionExtend: true,
|
||||
disablePromptLogin: false,
|
||||
loginFlag: uint32(common.LoginFlagPromptLogin),
|
||||
},
|
||||
{
|
||||
name: "extend replaces the none flag so the extend is not silent",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: false,
|
||||
LoginFlag: uint32(common.LoginFlagNone),
|
||||
},
|
||||
},
|
||||
sessionExtend: true,
|
||||
disablePromptLogin: false,
|
||||
loginFlag: uint32(common.LoginFlagPromptLogin),
|
||||
},
|
||||
{
|
||||
name: "extend respects DisablePromptLogin",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: true,
|
||||
LoginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
},
|
||||
sessionExtend: true,
|
||||
disablePromptLogin: true,
|
||||
loginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
{
|
||||
name: "login keeps the configured flow untouched",
|
||||
flow: &proto.PKCEAuthorizationFlow{
|
||||
ProviderConfig: &proto.ProviderConfig{
|
||||
DisablePromptLogin: false,
|
||||
LoginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
},
|
||||
sessionExtend: false,
|
||||
disablePromptLogin: false,
|
||||
loginFlag: uint32(common.LoginFlagMaxAge0),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
applySessionExtendFlowPolicy(tc.flow, tc.sessionExtend)
|
||||
cfg := tc.flow.GetProviderConfig()
|
||||
assert.Equal(t, tc.disablePromptLogin, cfg.GetDisablePromptLogin())
|
||||
assert.Equal(t, tc.loginFlag, cfg.GetLoginFlag())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A provider config is not guaranteed to be present on the response; clearing
|
||||
// the flag must not panic when the validator returned an empty flow.
|
||||
func TestApplySessionExtendFlowPolicyWithoutProviderConfig(t *testing.T) {
|
||||
assert.NotPanics(t, func() {
|
||||
applySessionExtendFlowPolicy(&proto.PKCEAuthorizationFlow{}, true)
|
||||
applySessionExtendFlowPolicy(nil, true)
|
||||
})
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
// PKCEVerifierStore manages PKCE verifiers for OAuth flows.
|
||||
// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var.
|
||||
type PKCEVerifierStore struct {
|
||||
cache nbcache.Store
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store.
|
||||
func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore {
|
||||
return &PKCEVerifierStore{
|
||||
cache: cacheStore,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// Store saves a PKCE verifier associated with an OAuth state parameter.
|
||||
// The verifier is stored with the specified TTL and will be automatically deleted after expiration.
|
||||
func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) error {
|
||||
if err := s.cache.Set(s.ctx, state, verifier, store.WithExpiration(ttl)); err != nil {
|
||||
return fmt.Errorf("failed to store PKCE verifier: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("Stored PKCE verifier for state (expires in %s)", ttl)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAndDelete retrieves and removes a PKCE verifier for the given state.
|
||||
// Returns the verifier and true if found, or empty string and false if not found.
|
||||
// This enforces single-use semantics for PKCE verifiers.
|
||||
func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) {
|
||||
verifier, found, err := s.cache.GetDel(s.ctx, state)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to consume PKCE verifier: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if !found {
|
||||
log.Debug("PKCE verifier not found for state")
|
||||
return "", false
|
||||
}
|
||||
|
||||
return verifier, true
|
||||
}
|
||||
@@ -27,8 +27,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"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"
|
||||
@@ -42,6 +40,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
nbstatus "github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
@@ -142,8 +141,8 @@ type ProxyServiceServer struct {
|
||||
// OIDC configuration for proxy authentication
|
||||
oidcConfig ProxyOIDCConfig
|
||||
|
||||
// Store for PKCE verifiers
|
||||
pkceVerifierStore *PKCEVerifierStore
|
||||
// singleUseStore backs both PKCE verifiers and OIDC session exchange codes.
|
||||
singleUseStore *SingleUseStore
|
||||
|
||||
// tokenTTL is the lifetime of one-time tokens generated for proxy
|
||||
// authentication. Defaults to defaultProxyTokenTTL when zero.
|
||||
@@ -153,15 +152,28 @@ type ProxyServiceServer struct {
|
||||
// initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE.
|
||||
snapshotBatchSize int
|
||||
|
||||
// snapshotAckTimeout bounds the wait for each snapshot batch ack.
|
||||
// Defaults to defaultSnapshotAckTimeout when zero.
|
||||
snapshotAckTimeout time.Duration
|
||||
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
const pkceVerifierTTL = 10 * time.Minute
|
||||
|
||||
const sessionCodeTTL = 60 * time.Second
|
||||
|
||||
const sessionCodeCacheNamespace = "proxy:session"
|
||||
|
||||
// The signed nonce binds the handoff mode without changing the state format.
|
||||
const sessionCodeNoncePrefix = "code."
|
||||
|
||||
const defaultProxyTokenTTL = 5 * time.Minute
|
||||
|
||||
const defaultSnapshotBatchSize = 500
|
||||
|
||||
const defaultSnapshotAckTimeout = 5 * time.Minute
|
||||
|
||||
func snapshotBatchSizeFromEnv() int {
|
||||
if v := os.Getenv("NB_PROXY_SNAPSHOT_BATCH_SIZE"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
@@ -179,6 +191,14 @@ func (s *ProxyServiceServer) proxyTokenTTL() time.Duration {
|
||||
return defaultProxyTokenTTL
|
||||
}
|
||||
|
||||
// snapshotAckWait returns the configured snapshot ack timeout or the default when unset.
|
||||
func (s *ProxyServiceServer) snapshotAckWait() time.Duration {
|
||||
if s.snapshotAckTimeout > 0 {
|
||||
return s.snapshotAckTimeout
|
||||
}
|
||||
return defaultSnapshotAckTimeout
|
||||
}
|
||||
|
||||
// proxyConnection represents a connected proxy
|
||||
type proxyConnection struct {
|
||||
proxyID string
|
||||
@@ -208,13 +228,13 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error {
|
||||
}
|
||||
|
||||
// NewProxyServiceServer creates a new proxy service server.
|
||||
func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
|
||||
func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, singleUseStore *SingleUseStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &ProxyServiceServer{
|
||||
accessLogManager: accessLogMgr,
|
||||
oidcConfig: oidcConfig,
|
||||
tokenStore: tokenStore,
|
||||
pkceVerifierStore: pkceStore,
|
||||
singleUseStore: singleUseStore,
|
||||
peersManager: peersManager,
|
||||
usersManager: usersManager,
|
||||
idpManager: idpManager,
|
||||
@@ -306,6 +326,16 @@ func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer {
|
||||
return s.connectAuthorizer
|
||||
}
|
||||
|
||||
// GenerateSessionCode creates a single-use code for the given session token.
|
||||
func (s *ProxyServiceServer) GenerateSessionCode(sessionToken string) (code string, ok bool) {
|
||||
code, err := s.singleUseStore.Generate(sessionCodeCacheNamespace, sessionToken, sessionCodeTTL)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("failed to generate proxy session code")
|
||||
return "", false
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
|
||||
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
|
||||
// forwarding an LLM request upstream. Delegates to the agent-network selector,
|
||||
// which scores applicable policies by remaining headroom and returns the
|
||||
@@ -414,6 +444,7 @@ func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller
|
||||
type proxyConnectParams struct {
|
||||
proxyID string
|
||||
address string
|
||||
version string
|
||||
capabilities *proto.ProxyCapabilities
|
||||
}
|
||||
|
||||
@@ -424,6 +455,7 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest
|
||||
return err
|
||||
}
|
||||
params.capabilities = req.GetCapabilities()
|
||||
params.version = req.GetVersion()
|
||||
|
||||
conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
|
||||
stream: stream,
|
||||
@@ -457,6 +489,7 @@ func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappings
|
||||
return err
|
||||
}
|
||||
params.capabilities = init.GetCapabilities()
|
||||
params.version = init.GetVersion()
|
||||
|
||||
conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
|
||||
syncStream: stream,
|
||||
@@ -465,7 +498,7 @@ func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappings
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil {
|
||||
if err := s.sendSnapshotSync(conn.ctx, conn, stream); err != nil {
|
||||
s.cleanupFailedSnapshot(stream.Context(), conn)
|
||||
return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
|
||||
}
|
||||
@@ -568,7 +601,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
|
||||
}
|
||||
}
|
||||
|
||||
proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps)
|
||||
proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, params.version, accountID, caps)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if accountID != nil {
|
||||
@@ -709,7 +742,7 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo
|
||||
return fmt.Errorf("send snapshot batch: %w", err)
|
||||
}
|
||||
|
||||
if err := waitForAck(stream); err != nil {
|
||||
if err := waitForAck(ctx, stream, s.snapshotAckWait()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -721,7 +754,7 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo
|
||||
return fmt.Errorf("send snapshot completion: %w", err)
|
||||
}
|
||||
|
||||
if err := waitForAck(stream); err != nil {
|
||||
if err := waitForAck(ctx, stream, s.snapshotAckWait()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -729,15 +762,38 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForAck(stream proto.ProxyService_SyncMappingsServer) error {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("receive ack: %w", err)
|
||||
// waitForAck waits for the proxy to ack a snapshot batch. It gives up when ctx
|
||||
// is canceled or no ack arrives within timeout, since a proxy that abandoned the
|
||||
// stream without closing it would otherwise block the handler indefinitely.
|
||||
func waitForAck(ctx context.Context, stream proto.ProxyService_SyncMappingsServer, timeout time.Duration) error {
|
||||
type recvResult struct {
|
||||
msg *proto.SyncMappingsRequest
|
||||
err error
|
||||
}
|
||||
if msg.GetAck() == nil {
|
||||
return fmt.Errorf("expected ack, got %T", msg.GetMsg())
|
||||
// Recv returns once the handler exits and gRPC tears the stream down.
|
||||
result := make(chan recvResult, 1)
|
||||
go func() {
|
||||
msg, err := stream.Recv()
|
||||
result <- recvResult{msg: msg, err: err}
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case r := <-result:
|
||||
if r.err != nil {
|
||||
return fmt.Errorf("receive ack: %w", r.err)
|
||||
}
|
||||
if r.msg.GetAck() == nil {
|
||||
return fmt.Errorf("expected ack, got %T", r.msg.GetMsg())
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for ack: %w", ctx.Err())
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("no ack within %s", timeout)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// heartbeat updates the proxy's last_seen timestamp every minute and
|
||||
@@ -1533,18 +1589,20 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU
|
||||
log.WithContext(ctx).Errorf("failed to get account services: %v", err)
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err)
|
||||
}
|
||||
var found bool
|
||||
var matchedService *rpservice.Service
|
||||
for _, service := range services {
|
||||
if service.Domain == redirectURL.Hostname() {
|
||||
found = true
|
||||
matchedService = service
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if matchedService == nil {
|
||||
log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname())
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "service not found in store")
|
||||
}
|
||||
|
||||
useSessionCode := s.proxyManager.ClusterSupportsSessionCode(ctx, matchedService.ProxyCluster)
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err)
|
||||
@@ -1564,15 +1622,18 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU
|
||||
return nil, status.Errorf(codes.Internal, "generate nonce: %v", err)
|
||||
}
|
||||
nonceB64 := base64.URLEncoding.EncodeToString(nonce)
|
||||
if useSessionCode {
|
||||
nonceB64 = sessionCodeNoncePrefix + nonceB64
|
||||
}
|
||||
|
||||
// Using an HMAC here to avoid redirection state being modified.
|
||||
// State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce)
|
||||
// State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce)
|
||||
payload := redirectURL.String() + "|" + nonceB64
|
||||
hmacSum := s.generateHMAC(payload)
|
||||
state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum)
|
||||
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil {
|
||||
if err := s.singleUseStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err)
|
||||
}
|
||||
@@ -1609,15 +1670,12 @@ func (s *ProxyServiceServer) generateHMAC(input string) string {
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateState validates the state parameter from an OAuth callback.
|
||||
// Returns the original redirect URL if valid, or an error if invalid.
|
||||
// The HMAC is verified before consuming the PKCE verifier to prevent
|
||||
// an attacker from invalidating a legitimate user's auth flow.
|
||||
func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) {
|
||||
// State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce)
|
||||
// ValidateState validates and consumes an OIDC state.
|
||||
func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, useSessionCode bool, err error) {
|
||||
// State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce)
|
||||
parts := strings.Split(state, "|")
|
||||
if len(parts) != 3 {
|
||||
return "", "", errors.New("invalid state format")
|
||||
return "", "", false, errors.New("invalid state format")
|
||||
}
|
||||
|
||||
encodedURL := parts[0]
|
||||
@@ -1626,7 +1684,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
|
||||
|
||||
redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid state encoding: %w", err)
|
||||
return "", "", false, fmt.Errorf("invalid state encoding: %w", err)
|
||||
}
|
||||
redirectURL = string(redirectURLBytes)
|
||||
|
||||
@@ -1634,16 +1692,17 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
|
||||
expectedHMAC := s.generateHMAC(payload)
|
||||
|
||||
if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) {
|
||||
return "", "", errors.New("invalid state signature")
|
||||
return "", "", false, errors.New("invalid state signature")
|
||||
}
|
||||
useSessionCode = strings.HasPrefix(nonce, sessionCodeNoncePrefix)
|
||||
|
||||
// Consume the PKCE verifier only after HMAC validation passes.
|
||||
verifier, ok := s.pkceVerifierStore.LoadAndDelete(state)
|
||||
verifier, ok := s.singleUseStore.LoadAndDelete(state)
|
||||
if !ok {
|
||||
return "", "", errors.New("no verifier for state")
|
||||
return "", "", false, errors.New("no verifier for state")
|
||||
}
|
||||
|
||||
return verifier, redirectURL, nil
|
||||
return verifier, redirectURL, useSessionCode, nil
|
||||
}
|
||||
|
||||
// Denied reasons reported to the proxy when access is refused because of the
|
||||
@@ -1701,6 +1760,40 @@ func sameAccount(userAccountID, serviceAccountID string) bool {
|
||||
return userAccountID != "" && serviceAccountID != "" && userAccountID == serviceAccountID
|
||||
}
|
||||
|
||||
// resolveUserEmail returns the user's stored email, else the email claim of
|
||||
// an already validated session token, else the IdP's email. The claim was
|
||||
// resolved the same way when the token was signed, so reusing it saves the
|
||||
// IdP round-trip on every validation. An IdP failure yields an empty string.
|
||||
func (s *ProxyServiceServer) resolveUserEmail(ctx context.Context, accountID string, user *types.User, tokenEmail string) string {
|
||||
if user.Email != "" {
|
||||
return user.Email
|
||||
}
|
||||
if tokenEmail != "" {
|
||||
return tokenEmail
|
||||
}
|
||||
return s.idpEmail(ctx, accountID, user.Id)
|
||||
}
|
||||
|
||||
// idpEmail looks up a user's email at the IdP, scoped to the given account.
|
||||
// It returns an empty string when no IdP is configured, the lookup fails or
|
||||
// the IdP knows no email for the user.
|
||||
func (s *ProxyServiceServer) idpEmail(ctx context.Context, accountID, userID string) string {
|
||||
if s.idpManager == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
userData, err := s.idpManager.GetUserDataByID(ctx, userID, idp.AppMetadata{WTAccountID: accountID})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{"user_id": userID, "error": err.Error()}).Debug("IdP email lookup failed; using stored identity")
|
||||
return ""
|
||||
}
|
||||
if userData == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return userData.Email
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed session JWT for the given domain and
|
||||
// user. The user's group memberships are embedded in the token so policy-aware
|
||||
// middlewares on the proxy can authorise without an extra management round-trip.
|
||||
@@ -1756,7 +1849,7 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
token, err := sessionkey.SignToken(
|
||||
service.SessionPrivateKey,
|
||||
userID,
|
||||
user.Email,
|
||||
s.resolveUserEmail(ctx, service.AccountID, user, ""),
|
||||
domain,
|
||||
method,
|
||||
groupIDs,
|
||||
@@ -1848,7 +1941,20 @@ func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, acco
|
||||
// ValidateSession validates a session token and checks if the user has access to the domain.
|
||||
func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) {
|
||||
domain := req.GetDomain()
|
||||
sessionToken := req.GetSessionToken()
|
||||
sessionToken := req.GetSessionToken() //nolint:staticcheck
|
||||
|
||||
// A one-time code from the OIDC callback is redeemed here for the durable
|
||||
// token, so the token never travels in a redirect URL. The redeemed token
|
||||
// is returned to the proxy (mintedToken) to install as the session cookie.
|
||||
mintedToken := ""
|
||||
if code := req.GetSessionCode(); code != "" {
|
||||
redeemed, found := s.singleUseStore.LoadAndDelete(singleUseCacheKey(sessionCodeCacheNamespace, code))
|
||||
if !found {
|
||||
return deniedSessionResponse("invalid or expired session code"), nil
|
||||
}
|
||||
sessionToken = redeemed
|
||||
mintedToken = redeemed
|
||||
}
|
||||
|
||||
if domain == "" || sessionToken == "" {
|
||||
return deniedSessionResponse("missing domain or session_token"), nil
|
||||
@@ -1868,7 +1974,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID, reason := sessionTokenSubject(domain, service, sessionToken)
|
||||
userID, tokenEmail, reason := sessionTokenSubject(domain, service, sessionToken)
|
||||
if reason != "" {
|
||||
return deniedSessionResponse(reason), nil
|
||||
}
|
||||
@@ -1897,12 +2003,13 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
userEmail := s.resolveUserEmail(ctx, service.AccountID, user, tokenEmail)
|
||||
|
||||
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
UserId: user.Id,
|
||||
UserEmail: user.Email,
|
||||
UserEmail: userEmail,
|
||||
DeniedReason: reason,
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
@@ -1912,15 +2019,16 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"email": user.Email,
|
||||
"email": userEmail,
|
||||
}).Debug("ValidateSession: access granted")
|
||||
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: true,
|
||||
UserId: user.Id,
|
||||
UserEmail: user.Email,
|
||||
UserEmail: userEmail,
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
SessionToken: mintedToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1934,28 +2042,28 @@ func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
|
||||
}
|
||||
|
||||
// sessionTokenSubject verifies the session token against the service's session
|
||||
// key and returns the user it was minted for, or the reason it cannot be
|
||||
// trusted.
|
||||
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
|
||||
// key and returns the user and email it was minted for, or the reason it
|
||||
// cannot be trusted.
|
||||
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, email, deniedReason string) {
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Error("ValidateSession: decode public key")
|
||||
return "", "invalid_service_config"
|
||||
return "", "", "invalid_service_config"
|
||||
}
|
||||
|
||||
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
userID, email, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: invalid session token")
|
||||
return "", "invalid_token"
|
||||
return "", "", "invalid_token"
|
||||
}
|
||||
|
||||
return userID, ""
|
||||
return userID, email, ""
|
||||
}
|
||||
|
||||
// accountUserDeniedReason gates a user of the service's own account, returning
|
||||
@@ -2236,12 +2344,8 @@ func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain strin
|
||||
// IdP enrichment wins when available — the stored email column is a
|
||||
// best-effort cache and is frequently empty for OIDC users. Enrichment
|
||||
// failures must never fail the RPC; we simply keep the stored/peer identity.
|
||||
if s.idpManager != nil {
|
||||
if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" {
|
||||
displayIdentity = ud.Email
|
||||
} else if uerr != nil {
|
||||
log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity")
|
||||
}
|
||||
if email := s.idpEmail(ctx, service.AccountID, peer.UserID); email != "" {
|
||||
displayIdentity = email
|
||||
}
|
||||
|
||||
return principalID, displayIdentity
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc/codes"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
versionTestProxyID = "proxy-a"
|
||||
versionTestCluster = "cluster.example.com"
|
||||
versionTestVersion = "0.60.0"
|
||||
)
|
||||
|
||||
// hangupStream cancels its context on the first Send, emulating a proxy that
|
||||
// disconnects right after receiving the initial snapshot. The legacy stream
|
||||
// carries no proxy-to-management messages, so this is the only way for
|
||||
// GetMappingUpdate to return.
|
||||
type hangupStream struct {
|
||||
recordingStream
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *hangupStream) Send(m *proto.GetMappingUpdateResponse) error {
|
||||
s.cancel()
|
||||
return s.recordingStream.Send(m)
|
||||
}
|
||||
|
||||
func (s *hangupStream) Context() context.Context { return s.ctx }
|
||||
|
||||
// newVersionTestServer wires a server whose proxy manager only accepts a
|
||||
// Connect carrying versionTestVersion, so a dropped or mangled version fails
|
||||
// the test as an unexpected call.
|
||||
func newVersionTestServer(t *testing.T) *ProxyServiceServer {
|
||||
t.Helper()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
svcMgr := rpservice.NewMockManager(ctrl)
|
||||
svcMgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil)
|
||||
|
||||
proxyMgr := proxy.NewMockManager(ctrl)
|
||||
proxyMgr.EXPECT().
|
||||
Connect(gomock.Any(), versionTestProxyID, gomock.Any(), versionTestCluster, gomock.Any(), versionTestVersion, gomock.Any(), gomock.Any()).
|
||||
Return(&proxy.Proxy{ID: versionTestProxyID, Version: versionTestVersion}, nil)
|
||||
proxyMgr.EXPECT().Disconnect(gomock.Any(), versionTestProxyID, gomock.Any()).Return(nil)
|
||||
|
||||
s := newSnapshotTestServer(t, 10)
|
||||
s.serviceManager = svcMgr
|
||||
s.proxyManager = proxyMgr
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSyncMappings_ForwardsProxyVersion(t *testing.T) {
|
||||
s := newVersionTestServer(t)
|
||||
|
||||
// The init carries the version, the ack acknowledges the empty snapshot,
|
||||
// and the exhausted fake stream then ends the RPC.
|
||||
stream := &syncRecordingStream{
|
||||
recvMsgs: []*proto.SyncMappingsRequest{
|
||||
{Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{
|
||||
ProxyId: versionTestProxyID,
|
||||
Address: versionTestCluster,
|
||||
Version: versionTestVersion,
|
||||
}}},
|
||||
ackMsg(),
|
||||
},
|
||||
}
|
||||
|
||||
err := s.SyncMappings(stream)
|
||||
require.ErrorContains(t, err, "no more recv messages")
|
||||
}
|
||||
|
||||
func TestGetMappingUpdate_ForwardsProxyVersion(t *testing.T) {
|
||||
s := newVersionTestServer(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
stream := &hangupStream{ctx: ctx, cancel: cancel}
|
||||
|
||||
err := s.GetMappingUpdate(&proto.GetMappingUpdateRequest{
|
||||
ProxyId: versionTestProxyID,
|
||||
Address: versionTestCluster,
|
||||
Version: versionTestVersion,
|
||||
}, stream)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool {
|
||||
func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
tokenStore: tokenStore,
|
||||
pkceVerifierStore: pkceStore,
|
||||
tokenStore: tokenStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
s.SetProxyController(newTestProxyController())
|
||||
|
||||
@@ -186,11 +186,11 @@ func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) {
|
||||
func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
tokenStore: tokenStore,
|
||||
pkceVerifierStore: pkceStore,
|
||||
tokenStore: tokenStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
s.SetProxyController(newTestProxyController())
|
||||
|
||||
@@ -220,11 +220,11 @@ func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) {
|
||||
func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
tokenStore: tokenStore,
|
||||
pkceVerifierStore: pkceStore,
|
||||
tokenStore: tokenStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
s.SetProxyController(newTestProxyController())
|
||||
|
||||
@@ -272,13 +272,13 @@ func generateState(s *ProxyServiceServer, redirectURL string) string {
|
||||
|
||||
func TestOAuthState_NeverTheSame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
oidcConfig: ProxyOIDCConfig{
|
||||
HMACKey: []byte("test-hmac-key"),
|
||||
},
|
||||
pkceVerifierStore: pkceStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
|
||||
redirectURL := "https://app.example.com/callback"
|
||||
@@ -300,20 +300,20 @@ func TestOAuthState_NeverTheSame(t *testing.T) {
|
||||
|
||||
func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
oidcConfig: ProxyOIDCConfig{
|
||||
HMACKey: []byte("test-hmac-key"),
|
||||
},
|
||||
pkceVerifierStore: pkceStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
|
||||
// Old format had only 2 parts: base64(url)|hmac
|
||||
err := s.pkceVerifierStore.Store("base64url|hmac", "test", 10*time.Minute)
|
||||
err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = s.ValidateState("base64url|hmac")
|
||||
_, _, _, err = s.ValidateState("base64url|hmac")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid state format")
|
||||
}
|
||||
@@ -372,24 +372,48 @@ func TestEnforceAccountScope_AllowsNoTokenInContext(t *testing.T) {
|
||||
|
||||
func TestValidateState_RejectsInvalidHMAC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
s := &ProxyServiceServer{
|
||||
oidcConfig: ProxyOIDCConfig{
|
||||
HMACKey: []byte("test-hmac-key"),
|
||||
},
|
||||
pkceVerifierStore: pkceStore,
|
||||
singleUseStore: singleUseStore,
|
||||
}
|
||||
|
||||
// Store with tampered HMAC
|
||||
err := s.pkceVerifierStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute)
|
||||
err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac")
|
||||
_, _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid state signature")
|
||||
}
|
||||
|
||||
func TestSessionCodeCannotConsumeOIDCState(t *testing.T) {
|
||||
const verifier = "pkce-verifier"
|
||||
|
||||
store := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
server := &ProxyServiceServer{
|
||||
oidcConfig: ProxyOIDCConfig{
|
||||
HMACKey: []byte("test-hmac-key"),
|
||||
},
|
||||
singleUseStore: store,
|
||||
}
|
||||
state := generateState(server, "https://service.example.com/callback")
|
||||
require.NoError(t, store.Store(state, verifier, time.Minute))
|
||||
|
||||
response, err := server.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
SessionCode: state,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, response.GetValid())
|
||||
|
||||
gotVerifier, _, _, err := server.ValidateState(state)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, verifier, gotVerifier)
|
||||
}
|
||||
|
||||
func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) {
|
||||
tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t))
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/certposture"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
internalStatus "github.com/netbirdio/netbird/shared/management/status"
|
||||
@@ -70,6 +71,7 @@ type Server struct {
|
||||
peerLocks sync.Map
|
||||
authManager auth.Manager
|
||||
sessionStore *auth.SessionStore
|
||||
challenger *certposture.Challenger
|
||||
|
||||
logBlockedPeers bool
|
||||
blockPeersWithSameConfig bool
|
||||
@@ -131,7 +133,13 @@ func NewServer(
|
||||
}
|
||||
}
|
||||
|
||||
serverKey, err := secretsManager.GetWGKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get server WireGuard key: %w", err)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
challenger: newCertChallenger(config.DataStoreEncryptionKey, serverKey),
|
||||
jobManager: jobManager,
|
||||
accountManager: accountManager,
|
||||
settingsManager: settingsManager,
|
||||
@@ -246,6 +254,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
realIP := getRealIP(ctx)
|
||||
sRealIP := realIP.String()
|
||||
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
|
||||
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncReq.GetMeta().GetCertificateProofs())
|
||||
|
||||
metahashed := metaHash(peerMeta)
|
||||
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
|
||||
@@ -309,11 +318,12 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
return mapError(ctx, err)
|
||||
}
|
||||
|
||||
err = s.sendInitialSync(ctx, peerKey, peer, netMap, postureChecks, srv, dnsFwdPort)
|
||||
trackChallenge := func() { s.accountManager.TrackCertificateChallenges(ctx, accountID, peer.ID, syncStart) }
|
||||
err = s.sendInitialSync(ctx, peerKey, peer, netMap, postureChecks, srv, dnsFwdPort, trackChallenge)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err)
|
||||
s.syncSem.Add(-1)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -321,7 +331,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("error while notify peer connected for %s: %v", peerKey.String(), err)
|
||||
s.syncSem.Add(-1)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -337,7 +347,9 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
|
||||
s.syncSem.Add(-1)
|
||||
|
||||
return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart) }).
|
||||
return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, s.challenger,
|
||||
trackChallenge,
|
||||
srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart, updates) }).
|
||||
WithMetrics(s.appMetrics).HandleUpdates(ctx)
|
||||
}
|
||||
|
||||
@@ -383,7 +395,7 @@ func (s *Server) startResponseReceiver(ctx context.Context, srv proto.Management
|
||||
|
||||
func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates *job.Channel, srv proto.ManagementService_JobServer) error {
|
||||
// todo figure out better error handling strategy
|
||||
defer s.jobManager.CloseChannel(ctx, accountID, peer.ID)
|
||||
defer s.jobManager.CloseChannel(ctx, accountID, peer.ID, updates)
|
||||
|
||||
for {
|
||||
event, err := updates.Event(ctx)
|
||||
@@ -430,21 +442,28 @@ func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Even
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) {
|
||||
func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time, session chan *network_map.UpdateMessage) {
|
||||
uncanceledCTX := context.WithoutCancel(ctx)
|
||||
unlock := s.acquirePeerLockByUID(uncanceledCTX, peer.Key)
|
||||
defer unlock()
|
||||
|
||||
s.cancelPeerRoutinesWithoutLock(uncanceledCTX, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutinesWithoutLock(uncanceledCTX, accountID, peer, streamStartTime, session)
|
||||
}
|
||||
|
||||
func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) {
|
||||
// cancelPeerRoutinesWithoutLock tears down the stream of the session identified by streamStartTime
|
||||
// and its updates channel. A nil session means the stream failed before it registered a channel;
|
||||
// the controller then closes any channel still registered.
|
||||
func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time, session chan *network_map.UpdateMessage) {
|
||||
err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key, streamStartTime)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to disconnect peer %s properly: %v", peer.Key, err)
|
||||
}
|
||||
s.networkMapController.OnPeerDisconnected(ctx, accountID, peer.ID)
|
||||
if !s.networkMapController.OnPeerDisconnected(ctx, accountID, peer.ID, session) {
|
||||
log.WithContext(ctx).Debugf("skipped peer routines teardown for %s: a newer session owns the peer", peer.Key)
|
||||
return
|
||||
}
|
||||
s.secretsManager.CancelRefresh(peer.ID)
|
||||
s.accountManager.UntrackCertificateChallenges(accountID, peer.ID, streamStartTime)
|
||||
|
||||
log.WithContext(ctx).Debugf("peer %s has been disconnected", peer.Key)
|
||||
}
|
||||
@@ -534,9 +553,9 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee
|
||||
return nbpeer.PeerSystemMeta{}
|
||||
}
|
||||
|
||||
osVersion := meta.GetOSVersion()
|
||||
osVersion := cleanClientString(meta.GetOSVersion())
|
||||
if osVersion == "" {
|
||||
osVersion = meta.GetCore()
|
||||
osVersion = cleanClientString(meta.GetCore())
|
||||
}
|
||||
|
||||
networkAddresses := make([]nbpeer.NetworkAddress, 0, len(meta.GetNetworkAddresses()))
|
||||
@@ -548,36 +567,36 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee
|
||||
}
|
||||
networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{
|
||||
NetIP: netAddr,
|
||||
Mac: addr.GetMac(),
|
||||
Mac: cleanClientString(addr.GetMac()),
|
||||
})
|
||||
}
|
||||
|
||||
files := make([]nbpeer.File, 0, len(meta.GetFiles()))
|
||||
for _, file := range meta.GetFiles() {
|
||||
files = append(files, nbpeer.File{
|
||||
Path: file.GetPath(),
|
||||
Path: cleanClientString(file.GetPath()),
|
||||
Exist: file.GetExist(),
|
||||
ProcessIsRunning: file.GetProcessIsRunning(),
|
||||
})
|
||||
}
|
||||
|
||||
return nbpeer.PeerSystemMeta{
|
||||
Hostname: meta.GetHostname(),
|
||||
GoOS: meta.GetGoOS(),
|
||||
Kernel: meta.GetKernel(),
|
||||
Platform: meta.GetPlatform(),
|
||||
OS: meta.GetOS(),
|
||||
Hostname: cleanClientString(meta.GetHostname()),
|
||||
GoOS: cleanClientString(meta.GetGoOS()),
|
||||
Kernel: cleanClientString(meta.GetKernel()),
|
||||
Platform: cleanClientString(meta.GetPlatform()),
|
||||
OS: cleanClientString(meta.GetOS()),
|
||||
OSVersion: osVersion,
|
||||
WtVersion: meta.GetNetbirdVersion(),
|
||||
UIVersion: meta.GetUiVersion(),
|
||||
KernelVersion: meta.GetKernelVersion(),
|
||||
WtVersion: cleanClientString(meta.GetNetbirdVersion()),
|
||||
UIVersion: cleanClientString(meta.GetUiVersion()),
|
||||
KernelVersion: cleanClientString(meta.GetKernelVersion()),
|
||||
NetworkAddresses: networkAddresses,
|
||||
SystemSerialNumber: meta.GetSysSerialNumber(),
|
||||
SystemProductName: meta.GetSysProductName(),
|
||||
SystemManufacturer: meta.GetSysManufacturer(),
|
||||
SystemSerialNumber: cleanClientString(meta.GetSysSerialNumber()),
|
||||
SystemProductName: cleanClientString(meta.GetSysProductName()),
|
||||
SystemManufacturer: cleanClientString(meta.GetSysManufacturer()),
|
||||
Environment: nbpeer.Environment{
|
||||
Cloud: meta.GetEnvironment().GetCloud(),
|
||||
Platform: meta.GetEnvironment().GetPlatform(),
|
||||
Cloud: cleanClientString(meta.GetEnvironment().GetCloud()),
|
||||
Platform: cleanClientString(meta.GetEnvironment().GetPlatform()),
|
||||
},
|
||||
Flags: nbpeer.Flags{
|
||||
RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(),
|
||||
@@ -600,6 +619,11 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee
|
||||
}
|
||||
}
|
||||
|
||||
// cleanClientString removes NUL bytes and invalid UTF-8 from a client-reported string, which Postgres rejects in text columns.
|
||||
func cleanClientString(s string) string {
|
||||
return strings.ToValidUTF8(strings.ReplaceAll(s, "\x00", ""), "")
|
||||
}
|
||||
|
||||
func capabilitiesToInt32(caps []proto.PeerCapability) []int32 {
|
||||
result := make([]int32, len(caps))
|
||||
for i, c := range caps {
|
||||
@@ -644,6 +668,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
|
||||
}
|
||||
|
||||
peerMeta := extractPeerMeta(ctx, loginReq.GetMeta())
|
||||
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, loginReq.GetMeta().GetCertificateProofs())
|
||||
metahashed := metaHash(peerMeta)
|
||||
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
|
||||
if s.logBlockedPeers {
|
||||
@@ -692,7 +717,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
|
||||
|
||||
peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{
|
||||
WireGuardPubKey: peerKey.String(),
|
||||
SSHKey: string(sshKey),
|
||||
SSHKey: cleanClientString(string(sshKey)),
|
||||
Meta: peerMeta,
|
||||
UserID: userID,
|
||||
SetupKey: loginReq.GetSetupKey(),
|
||||
@@ -720,6 +745,8 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
|
||||
return nil, status.Errorf(codes.Internal, "failed logging in peer")
|
||||
}
|
||||
|
||||
// Renewal is tracked by the sync stream that follows, on whichever instance it lands.
|
||||
stampCertificateChallenges(loginResp.Checks, s.challenger, peerKey)
|
||||
encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID)
|
||||
@@ -899,7 +926,7 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty,
|
||||
}
|
||||
|
||||
// sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization
|
||||
func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
|
||||
func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64, onChallengeStamped func()) error {
|
||||
var err error
|
||||
var turnToken *Token
|
||||
|
||||
@@ -956,12 +983,12 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
|
||||
// stops doing duplicate work. Deferred until the client-side
|
||||
// decoder lands and there's a real deployment of capability=3 peers
|
||||
// worth optimizing for.
|
||||
freshPeer, components, proxyPatch, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer)
|
||||
freshPeer, components, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err)
|
||||
return status.Errorf(codes.Internal, "failed to build initial sync envelope")
|
||||
}
|
||||
plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort)
|
||||
plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort)
|
||||
} else {
|
||||
plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort)
|
||||
}
|
||||
@@ -971,6 +998,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
|
||||
return status.Errorf(codes.Internal, "failed getting server key")
|
||||
}
|
||||
|
||||
if stampCertificateChallenges(plainResp.Checks, s.challenger, peerKey) {
|
||||
onChallengeStamped()
|
||||
}
|
||||
encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error handling request")
|
||||
@@ -1089,7 +1119,8 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
|
||||
return nil, status.Errorf(codes.Internal, "failed to get server key")
|
||||
}
|
||||
|
||||
err = encryption.DecryptMessage(peerKey, key, req.Body, &proto.PKCEAuthorizationFlowRequest{})
|
||||
flowReq := &proto.PKCEAuthorizationFlowRequest{}
|
||||
err = encryption.DecryptMessage(peerKey, key, req.Body, flowReq)
|
||||
if err != nil {
|
||||
errMSG := fmt.Sprintf("error while decrypting peer's message with Wireguard public key %s.", req.WgPubKey)
|
||||
log.WithContext(ctx).Warn(errMSG)
|
||||
@@ -1133,6 +1164,7 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
|
||||
}
|
||||
|
||||
flowInfoResp := s.integratedPeerValidator.ValidateFlowResponse(ctx, peerKey.String(), initInfoFlow)
|
||||
applySessionExtendFlowPolicy(flowInfoResp, flowReq.GetSessionExtend())
|
||||
|
||||
encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp)
|
||||
if err != nil {
|
||||
@@ -1145,6 +1177,40 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applySessionExtendFlowPolicy forces a prompt=login flow for a session extend.
|
||||
//
|
||||
// An extend renews the session of one specific peer, so its token has to come
|
||||
// from the account that peer is registered under. A flow that does not prompt
|
||||
// leaves the choice to the IdP, which answers a silent authorization from any
|
||||
// session it already holds — not necessarily this peer's account when several
|
||||
// are signed in, and login_hint is a suggestion the IdP may ignore. The token
|
||||
// then fails the jwt.UserID == peer.UserID check in ExtendAuthSession, and the
|
||||
// user is given no opportunity to pick a different account.
|
||||
//
|
||||
// LoginFlagPromptLogin rather than max_age=0: both re-authenticate, but with
|
||||
// prompt=login the IdP honours login_hint and offers the peer's own account,
|
||||
// whereas max_age=0 leaves the user to find it among every account signed in.
|
||||
//
|
||||
// DisablePromptLogin is left alone. It is set for IdPs that break on
|
||||
// prompt=login — Authentik triggers a double authentication, and social logins
|
||||
// fail outright — so overriding it would trade a recoverable session extend for
|
||||
// a login that cannot complete at all. Those deployments keep the silent flow
|
||||
// and, with several accounts signed in, an extend answered from the wrong one
|
||||
// still fails the user match.
|
||||
//
|
||||
// Called after ValidateFlowResponse so that a per-peer override cannot reinstate
|
||||
// the silent flow for an extend.
|
||||
func applySessionExtendFlowPolicy(flow *proto.PKCEAuthorizationFlow, sessionExtend bool) {
|
||||
if !sessionExtend {
|
||||
return
|
||||
}
|
||||
cfg := flow.GetProviderConfig()
|
||||
if cfg == nil || cfg.GetDisablePromptLogin() {
|
||||
return
|
||||
}
|
||||
cfg.LoginFlag = uint32(common.LoginFlagPromptLogin)
|
||||
}
|
||||
|
||||
// SyncMeta endpoint is used to synchronize peer's system metadata and notifies the connected,
|
||||
// peer's under the same account of any updates.
|
||||
func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*proto.Empty, error) {
|
||||
@@ -1164,7 +1230,9 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr
|
||||
return nil, msg
|
||||
}
|
||||
|
||||
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP)
|
||||
peerMeta := extractPeerMeta(ctx, syncMetaReq.GetMeta())
|
||||
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncMetaReq.GetMeta().GetCertificateProofs())
|
||||
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), peerMeta, realIP)
|
||||
if err != nil {
|
||||
return nil, mapError(ctx, err)
|
||||
}
|
||||
@@ -1240,7 +1308,11 @@ func toProtocolCheck(postureCheck *nmdata.PostureChecks) *proto.Checks {
|
||||
}
|
||||
}
|
||||
|
||||
if len(protoCheck.Files) == 0 {
|
||||
if check := postureCheck.Checks.CertificateCheck; check != nil {
|
||||
protoCheck.CertificateChallenge = &proto.CertificateChallenge{CaCertificates: check.CACertificates}
|
||||
}
|
||||
|
||||
if len(protoCheck.Files) == 0 && protoCheck.CertificateChallenge == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
)
|
||||
|
||||
func TestCancelPeerRoutines_SessionOwnership(t *testing.T) {
|
||||
peer := &nbpeer.Peer{ID: "peer-1", Key: "peer-key"}
|
||||
streamStart := time.Unix(1700000000, 0)
|
||||
session := make(chan *network_map.UpdateMessage)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session chan *network_map.UpdateMessage
|
||||
ownsPeer bool
|
||||
cancelRefresh bool
|
||||
}{
|
||||
{name: "owning session tears everything down", session: session, ownsPeer: true, cancelRefresh: true},
|
||||
{name: "stale session keeps the newer session's refresh", session: session, ownsPeer: false, cancelRefresh: false},
|
||||
{name: "failed sync without a channel closes the older session", session: nil, ownsPeer: true, cancelRefresh: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
accountManager := account.NewMockManager(ctrl)
|
||||
controller := network_map.NewMockController(ctrl)
|
||||
secretsManager := NewMockSecretsManager(ctrl)
|
||||
s := &Server{accountManager: accountManager, networkMapController: controller, secretsManager: secretsManager}
|
||||
|
||||
accountManager.EXPECT().OnPeerDisconnected(gomock.Any(), "account-1", peer.Key, streamStart).Return(nil)
|
||||
controller.EXPECT().OnPeerDisconnected(gomock.Any(), "account-1", peer.ID, tt.session).Return(tt.ownsPeer)
|
||||
if tt.cancelRefresh {
|
||||
secretsManager.EXPECT().CancelRefresh(peer.ID)
|
||||
// The stream's challenge renewal ends with it; a stale session leaves the
|
||||
// newer one's alone.
|
||||
accountManager.EXPECT().UntrackCertificateChallenges("account-1", peer.ID, streamStart)
|
||||
}
|
||||
|
||||
s.cancelPeerRoutines(context.Background(), "account-1", peer, streamStart, tt.session)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
// SingleUseStore stores short-lived values that can be retrieved only once.
|
||||
type SingleUseStore struct {
|
||||
cache nbcache.Store
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewSingleUseStore creates a single-use value store over the shared cache.
|
||||
func NewSingleUseStore(ctx context.Context, cacheStore nbcache.Store) *SingleUseStore {
|
||||
return &SingleUseStore{
|
||||
cache: cacheStore,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// Store saves value under key with the given TTL, after which it is evicted.
|
||||
func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error {
|
||||
if err := s.cache.Set(s.ctx, key, value, store.WithExpiration(ttl)); err != nil {
|
||||
return fmt.Errorf("store single-use value: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate stores a value under a namespaced random key and returns the random key.
|
||||
func (s *SingleUseStore) Generate(namespace, value string, ttl time.Duration) (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate single-use key: %w", err)
|
||||
}
|
||||
|
||||
key := base64.RawURLEncoding.EncodeToString(buf)
|
||||
if err := s.Store(singleUseCacheKey(namespace, key), value, ttl); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func singleUseCacheKey(namespace, key string) string {
|
||||
return namespace + ":" + key
|
||||
}
|
||||
|
||||
// LoadAndDelete retrieves and removes the value for a key.
|
||||
func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) {
|
||||
value, found, err := s.cache.GetDel(s.ctx, key)
|
||||
if err != nil {
|
||||
log.Warnf("failed to consume single-use value: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
+42
-5
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
func TestSingleUseStoreLoadAndDelete(t *testing.T) {
|
||||
const (
|
||||
state = "state"
|
||||
verifier = "verifier"
|
||||
@@ -14,7 +14,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
)
|
||||
|
||||
t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) {
|
||||
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||
store := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
if err := store.Store(state, verifier, time.Minute); err != nil {
|
||||
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("replayed state is rejected", func(t *testing.T) {
|
||||
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||
store := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
if err := store.Store(state, verifier, time.Minute); err != nil {
|
||||
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("unknown state is rejected", func(t *testing.T) {
|
||||
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||
store := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
|
||||
if got, found := store.LoadAndDelete("never-stored"); found {
|
||||
t.Fatalf("unknown state should not resolve, got %q", got)
|
||||
@@ -72,7 +72,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("expired verifier is rejected", func(t *testing.T) {
|
||||
store := NewPKCEVerifierStore(context.Background(), testCacheStore(t))
|
||||
store := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
if err := store.Store(state, verifier, 50*time.Millisecond); err != nil {
|
||||
t.Fatalf("couldn't store PKCE verifier: %s", err)
|
||||
}
|
||||
@@ -83,3 +83,40 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSingleUseStore_GenerateAndConsumeOnce(t *testing.T) {
|
||||
const namespace = "test"
|
||||
s := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
|
||||
key, err := s.Generate(namespace, "the-value", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if key == "" || key == "the-value" {
|
||||
t.Fatalf("unexpected key %q", key)
|
||||
}
|
||||
|
||||
value, found := s.LoadAndDelete(singleUseCacheKey(namespace, key))
|
||||
if !found || value != "the-value" {
|
||||
t.Fatalf("expected to load the stored value, got %q found=%v", value, found)
|
||||
}
|
||||
|
||||
if _, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)); found {
|
||||
t.Fatal("value must be consumed on first LoadAndDelete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleUseStore_GenerateUniqueKeys(t *testing.T) {
|
||||
s := NewSingleUseStore(context.Background(), testCacheStore(t))
|
||||
a, err := s.Generate("test", "v", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("generate a: %v", err)
|
||||
}
|
||||
b, err := s.Generate("test", "v", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("generate b: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("generated keys must be distinct")
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
@@ -409,3 +409,75 @@ func TestConnectionSendResponse_RoutesToLegacyStream(t *testing.T) {
|
||||
require.Len(t, stream.messages, 1)
|
||||
assert.Equal(t, "svc-2", stream.messages[0].Mapping[0].Id)
|
||||
}
|
||||
|
||||
// blockingSyncStream is a ProxyService_SyncMappingsServer whose Recv blocks
|
||||
// until the test ends, like a proxy that stopped acking without closing the stream.
|
||||
type blockingSyncStream struct {
|
||||
grpc.ServerStream
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (s *blockingSyncStream) Recv() (*proto.SyncMappingsRequest, error) {
|
||||
<-s.done
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func (s *blockingSyncStream) Send(*proto.SyncMappingsResponse) error { return nil }
|
||||
|
||||
func newBlockingSyncStream(t *testing.T) *blockingSyncStream {
|
||||
t.Helper()
|
||||
s := &blockingSyncStream{done: make(chan struct{})}
|
||||
t.Cleanup(func() { close(s.done) })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWaitForAck_Ack(t *testing.T) {
|
||||
stream := &syncRecordingStream{recvMsgs: []*proto.SyncMappingsRequest{ackMsg()}}
|
||||
|
||||
require.NoError(t, waitForAck(context.Background(), stream, time.Second))
|
||||
}
|
||||
|
||||
func TestWaitForAck_UnexpectedMessage(t *testing.T) {
|
||||
stream := &syncRecordingStream{recvMsgs: []*proto.SyncMappingsRequest{
|
||||
{Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{}}},
|
||||
}}
|
||||
|
||||
assert.ErrorContains(t, waitForAck(context.Background(), stream, time.Second), "expected ack")
|
||||
}
|
||||
|
||||
func TestWaitForAck_TimesOutWhenProxyStopsAcking(t *testing.T) {
|
||||
stream := newBlockingSyncStream(t)
|
||||
|
||||
start := time.Now()
|
||||
err := waitForAck(context.Background(), stream, 50*time.Millisecond)
|
||||
|
||||
assert.ErrorContains(t, err, "no ack within")
|
||||
assert.Less(t, time.Since(start), time.Second)
|
||||
}
|
||||
|
||||
func TestWaitForAck_ReturnsWhenConnectionCanceled(t *testing.T) {
|
||||
stream := newBlockingSyncStream(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
assert.ErrorIs(t, waitForAck(ctx, stream, time.Minute), context.Canceled)
|
||||
}
|
||||
|
||||
func TestSendSnapshotSync_GivesUpWhenProxyStopsAcking(t *testing.T) {
|
||||
const cluster = "cluster.example.com"
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr := rpservice.NewMockManager(ctrl)
|
||||
mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(1, cluster), nil)
|
||||
|
||||
s := newSnapshotTestServer(t, 10)
|
||||
s.serviceManager = mgr
|
||||
s.snapshotAckTimeout = 50 * time.Millisecond
|
||||
|
||||
stream := newBlockingSyncStream(t)
|
||||
conn := &proxyConnection{proxyID: "proxy-a", address: cluster, syncStream: stream}
|
||||
|
||||
err := s.sendSnapshotSync(context.Background(), conn, stream)
|
||||
|
||||
assert.ErrorContains(t, err, "no ack within")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build integration
|
||||
|
||||
package grpc
|
||||
|
||||
import (
|
||||
@@ -7,6 +5,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -40,9 +39,9 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
|
||||
proxyManager := &testValidateSessionProxyManager{}
|
||||
|
||||
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
|
||||
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
||||
singleUseStore := NewSingleUseStore(ctx, testCacheStore(t))
|
||||
|
||||
proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil)
|
||||
proxyService := NewProxyServiceServer(nil, tokenStore, singleUseStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil)
|
||||
proxyService.SetServiceManager(serviceManager)
|
||||
|
||||
createTestProxies(t, ctx, testStore)
|
||||
@@ -179,7 +178,12 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
|
||||
|
||||
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
|
||||
t.Helper()
|
||||
token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
|
||||
return createSessionTokenWithEmail(t, privKeyB64, userID, "", domain)
|
||||
}
|
||||
|
||||
func createSessionTokenWithEmail(t *testing.T, privKeyB64, userID, email, domain string) string {
|
||||
t.Helper()
|
||||
token, err := sessionkey.SignToken(privKeyB64, userID, email, domain, auth.MethodOIDC, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return token
|
||||
}
|
||||
@@ -195,7 +199,7 @@ func TestValidateSession_UserAllowed(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -216,7 +220,7 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -240,7 +244,7 @@ func TestValidateSession_PendingApprovalUserDenied(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -265,7 +269,7 @@ func TestValidateSession_PendingApprovalUserInAllUsersGroupDenied(t *testing.T)
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "all-users-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -288,7 +292,7 @@ func TestValidateSession_BlockedUserDenied(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -312,7 +316,7 @@ func TestValidateSession_UserAllowedAfterApproval(t *testing.T) {
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
|
||||
req := &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck,
|
||||
}
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(ctx, req)
|
||||
@@ -345,7 +349,7 @@ func TestValidateSession_UserInDifferentAccount(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -364,7 +368,7 @@ func TestValidateSession_UserNotFound(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -383,7 +387,7 @@ func TestValidateSession_ProxyNotFound(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "unknown-proxy.example.com",
|
||||
SessionToken: token,
|
||||
SessionToken: token, //nolint:staticcheck,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -397,7 +401,7 @@ func TestValidateSession_InvalidToken(t *testing.T) {
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionToken: "invalid-token",
|
||||
SessionToken: "invalid-token", //nolint:staticcheck,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -410,7 +414,7 @@ func TestValidateSession_MissingDomain(t *testing.T) {
|
||||
defer setup.cleanup()
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
SessionToken: "some-token",
|
||||
SessionToken: "some-token", //nolint:staticcheck,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -491,15 +495,15 @@ func (m *testValidateSessionServiceManager) GetAllServices(_ context.Context, _,
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) DeleteService(_ context.Context, _, _, _ string) error {
|
||||
@@ -543,7 +547,7 @@ func (m *testValidateSessionServiceManager) GetServiceIDByTargetID(_ context.Con
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error {
|
||||
@@ -570,8 +574,8 @@ func (m *testValidateSessionServiceManager) DeleteAccountCluster(_ context.Conte
|
||||
|
||||
type testValidateSessionProxyManager struct{}
|
||||
|
||||
func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) {
|
||||
return nil, nil
|
||||
func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) Disconnect(_ context.Context, _, _ string) error {
|
||||
@@ -603,7 +607,7 @@ func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) GetAccountProxy(_ context.Context, _ string) (*proxy.Proxy, error) {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) CountAccountProxies(_ context.Context, _ string) (int64, error) {
|
||||
@@ -634,6 +638,14 @@ func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) ClusterAllProxiesPrivate(_ context.Context, _ string) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testValidateSessionProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type testValidateSessionUsersManager struct {
|
||||
store store.Store
|
||||
}
|
||||
@@ -662,3 +674,169 @@ func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context,
|
||||
}
|
||||
return user, groups, nil
|
||||
}
|
||||
|
||||
func TestValidateSession_RedeemsSessionCode(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com")
|
||||
code, ok := setup.proxyService.GenerateSessionCode(token)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, token, code, "code must not be the token itself")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionCode: code,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Valid, "redeemed code should authorize the user")
|
||||
assert.Equal(t, "allowedUserId", resp.UserId)
|
||||
assert.Equal(t, token, resp.GetSessionToken(), "response must carry the durable token for the cookie")
|
||||
|
||||
// Single-use: the same code must not redeem again.
|
||||
resp2, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionCode: code,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp2.Valid, "a consumed code must be rejected")
|
||||
assert.Empty(t, resp2.GetSessionToken())
|
||||
}
|
||||
|
||||
func TestValidateSession_InvalidSessionCode(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionCode: "does-not-exist",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid)
|
||||
assert.Empty(t, resp.GetSessionToken())
|
||||
}
|
||||
|
||||
// TestGenerateSessionToken_EmailFallsBackToIdP covers users whose stored email
|
||||
// column is empty: the token's email claim comes from the IdP so the proxy can
|
||||
// still stamp X-NetBird-User.
|
||||
func TestGenerateSessionToken_EmailFallsBackToIdP(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
idpMock := &mockTunnelIdpManager{email: "idp@example.com", hasData: true}
|
||||
setup.proxyService.idpManager = idpMock
|
||||
|
||||
token, err := setup.proxyService.GenerateSessionToken(ctx, "restricted-proxy.example.com", "allowedUserId", auth.MethodOIDC)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := base64.StdEncoding.DecodeString(svc.SessionPublicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, email, _, _, _, err := auth.ValidateSessionJWT(token, "restricted-proxy.example.com", pubKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "idp@example.com", email)
|
||||
require.Len(t, idpMock.gotMeta, 1)
|
||||
assert.Equal(t, "testAccountId", idpMock.gotMeta[0].WTAccountID, "IdP lookup must be scoped to the service's account")
|
||||
}
|
||||
|
||||
// TestGenerateSessionToken_IdPFailureStillIssuesToken pins the fail-soft
|
||||
// behaviour: an unreachable IdP costs the email claim, not the login.
|
||||
func TestGenerateSessionToken_IdPFailureStillIssuesToken(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
setup.proxyService.idpManager = &mockTunnelIdpManager{err: errors.New("idp down")}
|
||||
|
||||
token, err := setup.proxyService.GenerateSessionToken(ctx, "restricted-proxy.example.com", "allowedUserId", auth.MethodOIDC)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := base64.StdEncoding.DecodeString(svc.SessionPublicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
userID, email, _, _, _, err := auth.ValidateSessionJWT(token, "restricted-proxy.example.com", pubKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "allowedUserId", userID)
|
||||
assert.Empty(t, email)
|
||||
}
|
||||
|
||||
func TestValidateSession_EmailResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storedEmail string
|
||||
tokenEmail string
|
||||
idp *mockTunnelIdpManager
|
||||
expectEmail string
|
||||
expectCalls int
|
||||
}{
|
||||
{
|
||||
name: "stored email wins without IdP call",
|
||||
storedEmail: "stored@example.com",
|
||||
tokenEmail: "claim@example.com",
|
||||
idp: &mockTunnelIdpManager{email: "idp@example.com", hasData: true},
|
||||
expectEmail: "stored@example.com",
|
||||
},
|
||||
{
|
||||
name: "token email claim is reused without IdP call",
|
||||
tokenEmail: "claim@example.com",
|
||||
idp: &mockTunnelIdpManager{email: "idp@example.com", hasData: true},
|
||||
expectEmail: "claim@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty stored email and claim fall back to IdP",
|
||||
idp: &mockTunnelIdpManager{email: "idp@example.com", hasData: true},
|
||||
expectEmail: "idp@example.com",
|
||||
expectCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "IdP error leaves email empty but session valid",
|
||||
idp: &mockTunnelIdpManager{err: errors.New("idp down")},
|
||||
expectCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "IdP without data leaves email empty",
|
||||
idp: &mockTunnelIdpManager{},
|
||||
expectCalls: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
user, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
user.Email = tt.storedEmail
|
||||
require.NoError(t, setup.store.SaveUser(ctx, user))
|
||||
|
||||
setup.proxyService.idpManager = tt.idp
|
||||
|
||||
svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "testProxyId")
|
||||
require.NoError(t, err)
|
||||
token := createSessionTokenWithEmail(t, svc.SessionPrivateKey, "allowedUserId", tt.tokenEmail, "test-proxy.example.com")
|
||||
code, ok := setup.proxyService.GenerateSessionCode(token)
|
||||
require.True(t, ok)
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(ctx, &proto.ValidateSessionRequest{
|
||||
Domain: "test-proxy.example.com",
|
||||
SessionCode: code,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.GetValid())
|
||||
assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
|
||||
assert.Equal(t, tt.expectCalls, tt.idp.gotCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user