From f175e402c7d2b7a4caa1315310206c6434de2fb3 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:59:31 +0200 Subject: [PATCH 01/70] [client] stop offering to every peer when the relay transport drops (#7092) * [client] stop offering to every peer when the relay transport drops The relay transport is shared: one connection per relay server carries the streams of every peer using it. When it drops, each of those peers gets a Disconnected verdict from evalConnStatus even when ICE is still carrying its traffic, because peerUsesRelay comes from HasRelayAddress(), which only reports that management offered relay servers, not that we are connected to one. The guard answers Disconnected with the aggressive retry, so every peer starts sending offers over signal for a transport that no offer can restore: the relay client's own guard is what reconnects it. Feed relayManager.Ready() into the status inputs and return PartiallyConnected when ICE is up and the missing side is the shared transport. That is the existing "one path works, the other does not" branch, which retries three times and then hourly instead of walking the exponential ladder forever. Peers are not left waiting for the hourly tick: when the transport comes back, Manager.onServerConnected notifies srWatcher, the guard resets the ticker to 800ms and iceState.reset() clears the hourly mode. The verdict is unchanged when the transport is up but this peer is unreachable over relay - it may have moved to another server, and only an offer carries its new relay address - and in force-relay mode, where relay is the only transport. * Renaming according to actual meanings * Don't consider an in progress ICE as "partially connected" when the relay is not.. * Aligns tests * Address wrong comments --- client/internal/peer/conn.go | 28 ++++--- client/internal/peer/conn_status.go | 15 ++-- client/internal/peer/conn_status_eval_test.go | 83 ++++++++++++++++--- client/internal/peer/guard/guard.go | 8 +- client/internal/peer/worker_relay.go | 4 + 5 files changed, 104 insertions(+), 34 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index d73144773..17823e043 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -828,7 +828,8 @@ func (conn *Conn) evalStatus() ConnStatus { // // The result is a tri-state: // - ConnStatusConnected: all available transports are up -// - ConnStatusPartiallyConnected: relay is up but ICE is still pending/reconnecting +// - ConnStatusPartiallyConnected: one transport carries the traffic and the other does +// not: relay up with ICE down, or ICE up with the shared relay transport down // - ConnStatusDisconnected: no working transport func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { defer func() { @@ -845,13 +846,14 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { } return evalConnStatus(connStatusInputs{ - forceRelay: IsForceRelayed(), - peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), - relayConnected: conn.statusRelay.Get() == worker.StatusConnected, - remoteSupportsICE: conn.handshaker.RemoteICESupported(), - iceWorkerCreated: iceWorkerCreated, - iceStatusConnecting: conn.statusICE.Get() != worker.StatusDisconnected, - iceInProgress: iceInProgress, + forceRelay: IsForceRelayed(), + peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), + relayConnected: conn.statusRelay.Get() == worker.StatusConnected, + relayTransportConnected: conn.workerRelay.IsTransportConnected(), + remoteSupportsICE: conn.handshaker.RemoteICESupported(), + iceWorkerCreated: iceWorkerCreated, + iceStatusConnected: conn.statusICE.Get() == worker.StatusConnected, + iceInProgress: iceInProgress, }) } @@ -1060,19 +1062,21 @@ func evalConnStatus(in connStatusInputs) guard.ConnStatus { return boolToConnStatus(relayUsedAndUp) } - // ICE counts as "up" when the status is anything other than Disconnected, OR - // when a negotiation is currently in progress (so we don't spam offers while one is in flight). - iceUp := in.iceStatusConnecting || in.iceInProgress + // ICE counts as "running" when either connected or attempting to connect. + iceRunning := in.iceStatusConnected || in.iceInProgress // Relay side is acceptable if the peer doesn't rely on relay, or relay is connected. relayOK := !in.peerUsesRelay || in.relayConnected switch { - case iceUp && relayOK: + case iceRunning && relayOK: return guard.ConnStatusConnected case relayUsedAndUp: // Relay is up but ICE is down — partially connected. return guard.ConnStatusPartiallyConnected + case in.iceStatusConnected && !in.relayTransportConnected: + // ICE is up and the shared relay transport is down — offers cannot restore it. + return guard.ConnStatusPartiallyConnected default: return guard.ConnStatusDisconnected } diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index d6ad37b70..acf271534 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -17,13 +17,14 @@ const ( // tri-state connection classification. Extracted so the decision logic can be unit-tested // without constructing full Worker/Handshaker objects. type connStatusInputs struct { - forceRelay bool // NB_FORCE_RELAY or JS/WASM - peerUsesRelay bool // remote peer advertises relay support AND local has relay - relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) - remoteSupportsICE bool // remote peer sent ICE credentials - iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) - iceStatusConnecting bool // statusICE is anything other than Disconnected - iceInProgress bool // a negotiation is currently in flight + forceRelay bool // NB_FORCE_RELAY or JS/WASM + peerUsesRelay bool // remote peer advertises relay support AND local has relay + relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) + relayTransportConnected bool // the relay transport shared by all peers on that server is up + remoteSupportsICE bool // remote peer sent ICE credentials + iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) + iceStatusConnected bool // statusICE reports Connected + iceInProgress bool // a negotiation is currently in flight } // ConnStatus describe the status of a peer's connection diff --git a/client/internal/peer/conn_status_eval_test.go b/client/internal/peer/conn_status_eval_test.go index 66393cafe..a239196dc 100644 --- a/client/internal/peer/conn_status_eval_test.go +++ b/client/internal/peer/conn_status_eval_test.go @@ -30,6 +30,21 @@ func TestEvalConnStatus_ForceRelay(t *testing.T) { }, want: guard.ConnStatusDisconnected, }, + { + name: "force relay, relay up but the shared transport reports down", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: true, + relayConnected: true, + relayTransportConnected: false, + // The ICE inputs are set so that the force-relay return is the only branch + // that can produce Connected here: without it the peer would fall through to + // relayUsedAndUp and report PartiallyConnected. + remoteSupportsICE: true, + iceWorkerCreated: true, + }, + want: guard.ConnStatusConnected, + }, { name: "force relay, peer does NOT use relay - disconnected forever", in: connStatusInputs{ @@ -123,24 +138,28 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true }, want: guard.ConnStatusConnected, }, { - name: "ICE connected, peer does NOT use relay", + name: "ICE connected, peer does NOT use relay, shared transport down", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = false + in.iceStatusConnected = true }, + // A peer that does not rely on relay is unaffected by the shared transport: + // relayOK is true, so the first arm matches before the transport is considered. want: guard.ConnStatusConnected, }, { name: "ICE InProgress only, peer does NOT use relay", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = true }, want: guard.ConnStatusConnected, @@ -150,7 +169,8 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = false + in.relayTransportConnected = true + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusPartiallyConnected, @@ -160,21 +180,60 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, }, { - name: "ICE up, peer uses relay but relay down -> partial (relay required, ICE ignored)", + name: "ICE connected, relay down for this peer but the shared transport is up -> disconnected", mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true + }, + // The transport is fine, so the peer itself is unreachable over relay: it may have + // moved to another server, and only an offer carries its new relay address. + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE connected, the shared relay transport is down -> partial", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = true + }, + // ICE carries the traffic and the relay transport is restored by the relay client's + // own guard, not by offers, so this must not trigger the aggressive retry. + want: guard.ConnStatusPartiallyConnected, + }, + { + name: "ICE only negotiating while the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = true + }, + // A negotiation in flight is not a working transport, so this peer has no path at + // all and must keep the aggressive retry. Calling it partially connected spends the + // ICE retry budget and parks the guard on the hourly ticker, and nothing wakes it + // when the negotiation then fails: onICEStateDisconnected is only reached once ICE + // has reached Connected (worker_ice.go onConnectionStateChange). + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE down and the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = false }, - // relayOK = false (peer uses relay but it's down), iceUp = true - // first switch arm fails (relayOK false), relayUsedAndUp = false (relay down), - // falls into default: Disconnected. want: guard.ConnStatusDisconnected, }, { @@ -182,7 +241,7 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = true // not actually used since peer doesn't rely on it - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 73bab2a89..15028d91c 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -14,7 +14,8 @@ type ConnStatus int const ( // ConnStatusDisconnected means neither ICE nor Relay is connected. ConnStatusDisconnected ConnStatus = iota - // ConnStatusPartiallyConnected means Relay is connected but ICE is not. + // ConnStatusPartiallyConnected means one transport is usable and the other is not: + // relay connected with ICE down, or ICE connected with the shared relay transport down. ConnStatusPartiallyConnected // ConnStatusConnected means all required connections are established. ConnStatusConnected @@ -87,8 +88,9 @@ func (g *Guard) SetICEConnDisconnected() { // - Connected: no action, the peer is fully reachable. // - Disconnected (neither ICE nor Relay): retries aggressively with exponential backoff (800ms doubling // up to timeout), never gives up. This ensures rapid recovery when the peer has no connectivity at all. -// - PartiallyConnected (Relay up, ICE not): retries up to 3 times with exponential backoff, then switches -// to one attempt per hour. This limits signaling traffic when relay already provides connectivity. +// - PartiallyConnected (one transport usable, the other not): retries up to 3 times +// with exponential backoff, then switches to one attempt per hour. This limits +// signaling traffic while the peer still has a working path. // // External events (relay/ICE disconnect, signal/relay reconnect, candidate changes) reset the retry // counter and backoff ticker, giving ICE a fresh chance after network conditions change. diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index fc3489992..694207847 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -101,6 +101,10 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool { return w.relayManager.HasRelayAddress() } +func (w *WorkerRelay) IsTransportConnected() bool { + return w.relayManager.Ready() +} + func (w *WorkerRelay) CloseConn() { w.relayLock.Lock() conn := w.relayedConn From 2b5293687f3e6aa4a6a1f070a6e95353155e21c4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 16:31:43 +0200 Subject: [PATCH 02/70] [client] Skip late session warnings on desktop and schedule them in the app on Android (#7548) * Skip session warnings that fire after their window The warning timers run on the monotonic clock, which does not advance while an Android device is suspended. A timer armed for T-10 or T-2 can therefore fire long after the window it was armed for, delivering a "session expires soon" notification once that window is already gone. Gate both callbacks on the wall clock at fire time: the T-10 warning is skipped once the final-warning window has been reached, and the final warning is skipped once the deadline itself has passed. Both set their edge guard before returning so a skipped warning cannot fire again for the same deadline. * Harden the late-warning guards Clamp a non-positive final lead to zero in the T-10 guard so a disabled final warning cannot move the cutoff past the deadline, matching how armTimerLocked already treats it. Strip the monotonic reading from both sides of the comparison so the guard measures wall-clock time regardless of how the caller built the deadline. The production deadline comes from a protobuf timestamp and has no monotonic reading; this keeps the guard correct for callers that derive one from time.Now. * Log the deadline and lateness on skipped warnings Include the deadline and how far past the cutoff the timer fired, so a debug bundle shows how long the device was suspended. * Inject the clock into the late-warning guard and cover it with tests The guard read time.Now internally, so the skip paths were reachable only through a deadline already in the past and the boundary depended on real time. Extract the comparison into isLate and read the time through a nowFn field, so tests can place a resume anywhere around the deadline without sleeping. * Send the final warning when the T-10 timer fires inside its window A suspend between roughly eight and ten minutes long made the T-10 timer fire inside the final-warning window and the final timer fire after the deadline, so both were skipped and a user who resumed with time left got no warning at all. When the T-10 timer fires late but before the deadline, send the final warning in its place and mark it fired so the delayed final timer does not repeat it. * Respect dismissal when promoting a late warning to the final one fireFinal skips the final warning once the user dismissed the deadline, but the promoted path did not, so a dismissed deadline could still get a final warning. Check the dismissal first, and give each skip reason its own log line so an already-fired final warning no longer logs a negative lateness. * Add a deadline-only mode to the session watcher Android will schedule its own expiry warnings from the deadline, so the engine must not arm the T-10 and T-2 timers there. NewDeadlineOnly keeps the deadline validation, the recorder propagation and the logging, and skips only the timers, so the status snapshot the app reads stays correct and an out-of-range deadline is still rejected. * Use the deadline-only watcher on Android and drop the warning callbacks The warning timers run on the monotonic clock, which does not advance while the device sleeps, so a warning armed for T-10 could fire long after its window. The app now schedules the warnings itself with WorkManager, anchored to the wall clock, from the deadline it reads through SessionExpiresAtUnix on every OnStateChanged. Wire the deadline-only watcher into the android build and remove the event-driven path from the gomobile surface: OnSessionExpiring, the event subscription behind it and DismissSessionWarning, which the app never called. * Describe the late-warning guard without naming Android The guard stays for the desktop builds, where a timer can also stall across a sleep. Android no longer arms the timers at all. --- client/android/client.go | 3 +- client/android/session.go | 91 +------- client/internal/auth/sessionwatch/watcher.go | 70 ++++++- .../auth/sessionwatch/watcher_test.go | 198 ++++++++++++++++++ client/internal/engine_sessionwatch.go | 12 +- .../internal/engine_sessionwatch_android.go | 12 ++ 6 files changed, 295 insertions(+), 91 deletions(-) create mode 100644 client/internal/engine_sessionwatch_android.go diff --git a/client/android/client.go b/client/android/client.go index 6f5eaacf3..9705db8e0 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -104,8 +104,7 @@ type Client struct { stateChangeMu sync.Mutex stateChangeSubID string - eventSub *peer.EventSubscription - // Closed to stop the watch goroutines from delivering buffered items to a + // Closed to stop the watch goroutine from delivering buffered ticks to a // listener that has been removed or replaced. See stopStateChangeWatchLocked. stateChangeDone chan struct{} diff --git a/client/android/session.go b/client/android/session.go index d5da09c93..1ce97f074 100644 --- a/client/android/session.go +++ b/client/android/session.go @@ -6,13 +6,8 @@ import ( "context" "fmt" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" - "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" - "github.com/netbirdio/netbird/client/internal/peer" - cProto "github.com/netbirdio/netbird/client/proto" ) // StateChangeListener receives client state notifications. @@ -21,16 +16,11 @@ import ( // changed: connection state, the run-loop status label (e.g. NeedsLogin) or // the session deadline. It mirrors the daemon's SubscribeStatus stream // trigger — on each signal the consumer pulls the fresh values via -// Status() / SessionExpiresAtUnix(). -// -// OnSessionExpiring forwards the engine's session-expiry warnings, fired at -// sessionwatch.WarningLead before the deadline and again at FinalWarningLead -// (finalWarning true). The second one is suppressed when the user dismissed -// the first via DismissSessionWarning. The daemon turns the same events into -// its tray notification. +// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning +// timers on Android; the app schedules the warnings from the deadline it +// reads here. type StateChangeListener interface { OnStateChanged() - OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) } // Status returns the connect run-loop's status label — the same value the @@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { return } - // Both subscriptions are buffered (one pending tick, ten pending events), - // so unsubscribing is not enough to stop callbacks: the loops would drain - // what is already queued and deliver it to a listener the caller has - // already removed or replaced. Gate every callback on this registration's - // own signal, which is closed before unsubscribing. + // The subscription is buffered (one pending tick), so unsubscribing is + // not enough to stop callbacks: the loop would drain what is already + // queued and deliver it to a listener the caller has already removed or + // replaced. Gate every callback on this registration's own signal, which + // is closed before unsubscribing. done := make(chan struct{}) c.stateChangeDone = done @@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { listener.OnStateChanged() } }() - - c.eventSub = c.recorder.SubscribeToEvents() - go watchSessionWarnings(c.eventSub, listener, done) } // RemoveStateChangeListener unregisters the state notification listener. @@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() { c.stopStateChangeWatchLocked() } -// DismissSessionWarning records the user's "Dismiss" on the first expiry -// warning and suppresses the final one for the current deadline. A refreshed -// deadline re-arms both. No-op while the engine is not running. -func (c *Client) DismissSessionWarning() { - cc := c.getConnectClient() - if cc == nil { - return - } - engine := cc.Engine() - if engine == nil { - return - } - engine.DismissSessionWarning() -} - // ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and // asks the management server to extend the session deadline. The tunnel is // untouched: no resync, no reconnect. Async; the result arrives on the @@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() { } func (c *Client) stopStateChangeWatchLocked() { - // Signal first, unsubscribe second: closing the channels only stops new - // items, and the loops would still hand whatever is buffered to a listener + // Signal first, unsubscribe second: closing the channel only stops new + // items, and the loop would still hand whatever is buffered to a listener // that is no longer registered. if c.stateChangeDone != nil { close(c.stateChangeDone) @@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() { c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) c.stateChangeSubID = "" } - if c.eventSub != nil { - // Closes the channel, which ends watchSessionWarnings. - c.recorder.UnsubscribeFromEvents(c.eventSub) - c.eventSub = nil - } -} - -// watchSessionWarnings forwards the engine's session-expiry warnings to the -// listener. The event stream also carries unrelated traffic — network-map -// updates on every sync, DNS and route errors — so everything but an -// AUTHENTICATION event carrying the session-warning marker is dropped. Exits -// when the subscription is closed by UnsubscribeFromEvents, or earlier when -// done is closed — the stream buffers up to ten events, and a deregistered -// listener must not receive the ones already queued. -func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { - for ev := range sub.Events() { - select { - case <-done: - return - default: - } - if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { - continue - } - meta := ev.GetMetadata() - if meta[sessionwatch.MetaSessionWarning] != "true" { - // Other AUTHENTICATION events exist (e.g. a deadline rejected as - // out of range); they carry no warning marker. - continue - } - deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) - if err != nil { - log.Warnf("session warning event with unparsable deadline: %v", err) - continue - } - lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) - if err != nil { - // Informational only — the deadline above is what drives the UI. - lead = 0 - } - listener.OnSessionExpiring(deadline.Unix(), int64(lead), - meta[sessionwatch.MetaSessionFinal] == "true") - } } func (c *Client) beginExtend() (context.Context, error) { diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e685c28d0..496903044 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -90,8 +90,9 @@ type StatusRecorder interface { // fallback T-FinalWarningLead dialog (suppressed when the user dismissed // the first one for the same deadline). Safe for concurrent use. type Watcher struct { - lead time.Duration - finalLead time.Duration + lead time.Duration + finalLead time.Duration + deadlineOnly bool mu sync.Mutex current time.Time @@ -102,6 +103,7 @@ type Watcher struct { dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal closed bool recorder StatusRecorder + nowFn func() time.Time } // New returns a watcher with the package defaults WarningLead and @@ -122,9 +124,17 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { lead: lead, finalLead: final, recorder: recorder, + nowFn: time.Now, } } +// NewDeadlineOnly returns a watcher that validates and records deadlines but arms no warning timers. +func NewDeadlineOnly(recorder StatusRecorder) *Watcher { + w := New(recorder) + w.deadlineOnly = true + return w +} + // Update sets the latest deadline. Pass the zero time to clear (e.g. when // a Sync push from the server omits the field because login expiration // was disabled). @@ -181,7 +191,7 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - if deadline.After(now) { + if deadline.After(now) && !w.deadlineOnly { w.armTimerLocked(deadline) } recorder := w.recorder @@ -303,6 +313,11 @@ func (w *Watcher) fire(armedFor time.Time) { w.mu.Unlock() return } + now := w.nowFn() + if isLate(now, armedFor, max(w.finalLead, 0)) { + w.fireLateLocked(armedFor, now) + return + } w.firedAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -331,6 +346,14 @@ func (w *Watcher) fireFinal(armedFor time.Time) { log.Infof("auth session final-warning skipped (dismissed by user)") return } + now := w.nowFn() + if isLate(now, armedFor, 0) { + w.finalFiredAt = armedFor + w.mu.Unlock() + log.Infof("auth session final-warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } w.finalFiredAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -341,6 +364,39 @@ func (w *Watcher) fireFinal(armedFor time.Time) { publishWarning(recorder, armedFor, true) } +// fireLateLocked handles a T-WarningLead callback that fired inside the +// final-warning window: it sends the final warning in its place while the +// deadline has not passed and the user has not dismissed it, so a resume +// with time left still warns. The caller must hold w.mu; this helper +// releases it. +func (w *Watcher) fireLateLocked(armedFor, now time.Time) { + w.firedAt = armedFor + switch { + case w.dismissedAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (dismissed by user)") + return + case w.finalFiredAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (final warning already fired)") + return + case isLate(now, armedFor, 0): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired inside the final-warning window, sending final warning for deadline %s", + armedFor.Format(time.RFC3339)) + publishWarning(recorder, armedFor, true) +} + // armOneShotLocked schedules cb at fireAt. When fireAt is already in the // past it dispatches on the next scheduler tick so a state-change recorder // notification (invoked after w.mu is released) lands first. Caller must @@ -380,3 +436,11 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { meta, ) } + +// isLate reports whether the wall clock now has already reached armedFor +// minus cutoffLead. The timers run on the monotonic clock, which can stall +// while the host sleeps, so a timer can fire long after the window it was +// armed for. +func isLate(now, armedFor time.Time, cutoffLead time.Duration) bool { + return !now.Round(0).Before(armedFor.Add(-cutoffLead).Round(0)) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index 4b49a94b6..cb2800978 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -527,3 +527,201 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) { } t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) } + +func TestIsLate(t *testing.T) { + armedFor := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + lead := 2 * time.Minute + tests := []struct { + name string + now time.Time + cutoffLead time.Duration + want bool + }{ + {"before cutoff", armedFor.Add(-3 * time.Minute), lead, false}, + {"at cutoff", armedFor.Add(-lead), lead, true}, + {"after cutoff", armedFor.Add(-time.Minute), lead, true}, + {"zero lead before deadline", armedFor.Add(-time.Second), 0, false}, + {"zero lead at deadline", armedFor, 0, true}, + {"zero lead after deadline", armedFor.Add(time.Second), 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isLate(tt.now, armedFor, tt.cutoffLead); got != tt.want { + t.Fatalf("isLate(%s, %s, %s) = %v, want %v", tt.now, armedFor, tt.cutoffLead, got, tt.want) + } + }) + } +} + +func TestIsLateIgnoresMonotonicReading(t *testing.T) { + now := time.Now() + wallOnly := now.Round(0) + if isLate(now, wallOnly.Add(time.Second), 0) { + t.Fatalf("now with monotonic reading must compare as wall clock before a later wall-only deadline") + } + if !isLate(now, wallOnly, 0) { + t.Fatalf("now with monotonic reading must compare as wall clock at an equal wall-only deadline") + } +} + +func TestLateTimerFiring(t *testing.T) { + tests := []struct { + name string + final bool + beforeDl time.Duration + wantWarns int + wantFinals int + }{ + {"warning on resume inside window", false, 3 * time.Minute, 1, 0}, + {"warning promoted to final inside final window", false, time.Minute, 0, 1}, + {"warning skipped past deadline", false, -time.Minute, 0, 0}, + {"final on resume before deadline", true, time.Minute, 0, 1}, + {"final skipped past deadline", true, -time.Minute, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // The deadline is an hour out so the real timers never fire + // during the test; the late callback is invoked directly with an + // injected clock that simulates a resume near the deadline. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-tt.beforeDl) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + if tt.final { + w.fireFinal(d) + } else { + w.fire(d) + } + + events := r.snapshot() + if got := countWhere(events, event.isWarning); got != tt.wantWarns { + t.Fatalf("expected %d warning publishes, got %d: %+v", tt.wantWarns, got, events) + } + if got := countWhere(events, event.isFinalWarning); got != tt.wantFinals { + t.Fatalf("expected %d final-warning publishes, got %d: %+v", tt.wantFinals, got, events) + } + }) + } +} + +func TestPromotedFinalWarningIsNotRepeated(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + now := d.Add(-time.Minute) + w.nowFn = func() time.Time { return now } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fire(d) + // The final timer was suspended too, so it fires even later than the + // warning timer, here still just before the deadline. + now = d.Add(-30 * time.Second) + w.fireFinal(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestPromotionRespectsDismiss(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.Dismiss() + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish after dismiss, got %d: %+v", got, events) + } +} + +func TestPromotionSkippedWhenFinalAlreadyFired(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // Both timers fall in the past after a long suspend and are dispatched + // with a zero delay, so the final callback can run before the warning one. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fireFinal(d) + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + // With the default leads this deadline would otherwise fire both + // timers on the next tick. + d := time.Now().Add(50 * time.Millisecond).Round(0) + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("expected recorder deadline %v, got %v", d, got) + } + + time.Sleep(100 * time.Millisecond) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events) + } + if w.timer != nil || w.finalTimer != nil { + t.Fatal("expected no timers armed in deadline-only mode") + } +} + +func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update: %v", err) + } + + err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("expected ErrDeadlineInPast, got %v", err) + } + if got := r.deadline(); !got.IsZero() { + t.Fatalf("expected recorder cleared after rejection, got %v", got) + } +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go index a46d73f87..05b46a465 100644 --- a/client/internal/engine_sessionwatch.go +++ b/client/internal/engine_sessionwatch.go @@ -1,4 +1,4 @@ -//go:build !js +//go:build !js && !android package internal @@ -7,10 +7,12 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" ) -// newSessionWatcher returns the real SSO session expiry watcher for every -// non-wasm build. The js/wasm build gets a no-op stub from -// engine_sessionwatch_js.go so the sessionwatch package (and its timer -// machinery) never links into the wasm binary. +// newSessionWatcher returns the real SSO session expiry watcher. The js/wasm +// build gets a no-op stub from engine_sessionwatch_js.go so the sessionwatch +// package (and its timer machinery) never links into the wasm binary; the +// android build gets a deadline-only watcher from +// engine_sessionwatch_android.go because the app schedules the warnings +// itself. func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { return sessionwatch.New(recorder) } diff --git a/client/internal/engine_sessionwatch_android.go b/client/internal/engine_sessionwatch_android.go new file mode 100644 index 000000000..8317f9165 --- /dev/null +++ b/client/internal/engine_sessionwatch_android.go @@ -0,0 +1,12 @@ +//go:build android + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.NewDeadlineOnly(recorder) +} From eb5a98c059891a2ba78284daf5446cc8c4bfa5fd Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:48:48 +0200 Subject: [PATCH 03/70] [management] add tenant delete endpoint to openapi (#8054) --- shared/management/http/api/openapi.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4b7077cac..90b87462f 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -10625,6 +10625,28 @@ paths: $ref: "#/components/responses/requires_authentication" "500": $ref: "#/components/responses/internal_error" + delete: + summary: Delete MSP tenant + tags: + - MSP + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The unique identifier of a tenant account + responses: + "200": + description: Successfully deleted the tenant + "400": + $ref: "#/components/responses/bad_request" + "403": + $ref: "#/components/responses/requires_authentication" + "404": + description: The tenant was not found + "500": + $ref: "#/components/responses/internal_error" /api/integrations/msp/tenants/{id}/unlink: post: summary: Unlink a tenant From d6340ba0de1d1448623fe906e985fdf2a5b53bdd Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 01:04:36 +0200 Subject: [PATCH 04/70] [self-hosted] Add a UBI image variant for the combined server (#7953) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. --- .github/workflows/redhat-certify.yml | 2 + .goreleaser.yaml | 35 ++++++++++++ combined/Dockerfile.ubi | 31 +++++++++++ combined/collect-licenses.sh | 81 ++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 combined/Dockerfile.ubi create mode 100644 combined/collect-licenses.sh diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index e592dabc2..fa0a87c64 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -32,6 +32,7 @@ on: - all - client-rootless - reverse-proxy + - netbird-server version: description: "Released version, e.g. v0.80.0" type: string @@ -66,6 +67,7 @@ jobs: components=( "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" + "netbird-server ghcr.io/netbirdio/netbird-server -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 275c1cd7b..ba9e56a50 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -511,6 +511,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-server-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: combined/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=1 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: netbird-proxy disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/combined/Dockerfile.ubi b/combined/Dockerfile.ubi new file mode 100644 index 000000000..66ef55a34 --- /dev/null +++ b/combined/Dockerfile.ubi @@ -0,0 +1,31 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-server" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Server" \ + description="NetBird Server runs the Management, Signal, Relay and STUN services of a self-hosted NetBird deployment in a single process." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created keys and databases retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird /etc/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +# Runtimes such as OpenShift and Podman reserve ports below 1024 for root, so +# the mounted config must set server.listenAddress to an unprivileged port. +EXPOSE 8443 3478/udp +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-server"] +CMD ["--config", "/etc/netbird/config.yaml"] diff --git a/combined/collect-licenses.sh b/combined/collect-licenses.sh new file mode 100644 index 000000000..ae618e46b --- /dev/null +++ b/combined/collect-licenses.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +# Assemble beside the target and rename on success, so a failed run leaves +# nothing behind that would block the next attempt. +staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM +mkdir "$staging/third_party" + +cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$staging/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$staging/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" + +mv "$staging" "$output" From ab79aebd88a359da506b4443203b15808b1e6ccb Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 10:25:57 +0200 Subject: [PATCH 05/70] [misc] Share one license collection script across the UBI images (#8055) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. * [misc] Share one license collection script across the UBI images The client, proxy and combined images each carried a near-identical copy of collect-licenses.sh, and the signal and relay variants would add two more. The copies differed only in the Go package, build tags, component license and the proxy's web licenses, which are now options of one script in release_files/. The client gains the staged write the others already had. --- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 6 +- client/collect-licenses.sh | 77 ----------------- proxy/collect-licenses.sh | 82 ------------------- .../collect-licenses.sh | 46 +++++++++-- 5 files changed, 41 insertions(+), 172 deletions(-) delete mode 100644 client/collect-licenses.sh delete mode 100644 proxy/collect-licenses.sh rename {combined => release_files}/collect-licenses.sh (61%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee4d398d..a79357505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,7 +199,7 @@ jobs: with: node-version: '22' - name: Install proxy web dependencies for license collection - # proxy/collect-licenses.sh reads the UI's license terms from node_modules. + # release_files/collect-licenses.sh -w reads the proxy UI's license terms from node_modules. working-directory: proxy/web run: npm ci --ignore-scripts - name: Set up QEMU diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ba9e56a50..59d8274e9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -385,7 +385,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -t load_wgnt_from_rsrc "{{ .ContextDir }}/licenses" ./client amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 @@ -530,7 +530,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l combined/LICENSE "{{ .ContextDir }}/licenses" ./combined amd64 arm64' env: - GOOS=linux - CGO_ENABLED=1 @@ -587,7 +587,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l proxy/LICENSE -w "{{ .ContextDir }}/licenses" ./proxy/cmd/proxy amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh deleted file mode 100644 index 7dfabada9..000000000 --- a/client/collect-licenses.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = "." ] || - [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -mkdir "$output" -mkdir "$output/third_party" - -cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$output/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$output/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" diff --git a/proxy/collect-licenses.sh b/proxy/collect-licenses.sh deleted file mode 100644 index ccf5f4dd6..000000000 --- a/proxy/collect-licenses.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.sorted.XXXXXX") - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -# Assemble beside the target and rename on success, so a failed run leaves -# nothing behind that would block the next attempt. -staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM -mkdir "$staging/third_party" - -cp "$repo_root/proxy/LICENSE" "$staging/AGPL-3.0.txt" -cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" -node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./proxy/cmd/proxy >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$staging/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$staging/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" - -mv "$staging" "$output" diff --git a/combined/collect-licenses.sh b/release_files/collect-licenses.sh similarity index 61% rename from combined/collect-licenses.sh rename to release_files/collect-licenses.sh index ae618e46b..8481c9c3e 100644 --- a/combined/collect-licenses.sh +++ b/release_files/collect-licenses.sh @@ -1,10 +1,32 @@ #!/bin/sh +# +# Collect the license terms shipped in /licenses of the UBI images: NetBird's +# own licenses, the Go standard library terms, and the root license files of +# every module the Go package links on the given architectures. +# +# -l FILE component license, copied as AGPL-3.0.txt (path from repo root) +# -t TAGS build tags used for the dependency walk +# -w add the proxy web UI's third-party licenses (needs proxy/web/node_modules) set -eu -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 +usage() { + printf '%s\n' "usage: $0 [-l LICENSE_FILE] [-t TAGS] [-w] OUTPUT_DIRECTORY PACKAGE GOARCH..." >&2 exit 2 -fi +} + +component_license="" +tags="" +web=false +while getopts l:t:w opt; do + case "$opt" in + l) component_license=$OPTARG ;; + t) tags=$OPTARG ;; + w) web=true ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) +[ "$#" -ge 3 ] || usage repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) output_name=$(basename "$1") @@ -14,27 +36,33 @@ if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || fi output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) output="$output_parent/$output_name" -shift +package=$2 +shift 2 if [ -e "$output" ] || [ -L "$output" ]; then printf 'output directory already exists: %s\n' "$output" >&2 exit 1 fi -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.sorted.XXXXXX") # Assemble beside the target and rename on success, so a failed run leaves # nothing behind that would block the next attempt. staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM mkdir "$staging/third_party" -cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +if [ -n "$component_license" ]; then + cp "$repo_root/$component_license" "$staging/AGPL-3.0.txt" +fi cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" +if [ "$web" = true ]; then + node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" +fi cd "$repo_root" for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -tags "$tags" -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' "$package" >>"$modules" done LC_ALL=C sort -u "$modules" >"$sorted_modules" From d622e03d4019e5ca5324530f6e150417de810c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20H=C3=A4rtter?= <108095150+janekhaertter@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:32:23 +0200 Subject: [PATCH 06/70] [misc] Use ASCII hyphens in the LICENSE header (#8065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first line of LICENSE spelled BSD-3-Clause with non-breaking hyphens (U+2011). The Windows NSIS installer shows LICENSE on its license page and reads it in the system ANSI code page, so the UTF-8 bytes rendered as "BSD‑3‑Clause". Plain hyphens also match the SPDX identifier and keep the file ASCII-only. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d922f155a..cea6f8f0b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. +This BSD-3-Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. BSD 3-Clause License From a8dff998ef3cb38f435c1e4c9648dcd129ce48c1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:39:22 +0200 Subject: [PATCH 07/70] [client] Gate settings updates on value, not on field presence (#7398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Gate settings updates on value, not on field presence The update-settings kill switch (--disable-update-settings / NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids changing settings, but it decided what a "change" was by looking at whether a field was present in the request. The CLI fills the whole config surface of SetConfigRequest and LoginRequest from its flags and environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a client configured by environment restates its own configuration on every start and tripped the gate every time. SetConfig only warned about that, but Login carries the same fields and was gated the same way, and Login runs inside the CLI's backoff loop: the daemon answered every attempt with codes.Unavailable, `netbird up` never completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any config env var (NB_MANAGEMENT_URL, for one) could not come up at all. Both gates now compare values. Config.WouldChange is the dry-run half of UpdateConfig: it runs the very same diff logic (Config.apply) against a copy of the stored config, so the gate cannot drift from what an actual update would do, nor go stale when a field is added. A request that restates what the profile already holds changes nothing and is allowed; a request that diverges is refused exactly as before, and a dry run that cannot be evaluated fails closed. A profile with no config on disk yet is judged against the config the daemon would create for it. For Login, the compared input comes from loginOverridesInput, which persistLoginOverrides also uses to perform the write, so the gate judges precisely the two fields a login can persist (management URL, pre-shared key) and no field it ignores. Two adjacent defects surfaced while making the comparison exact: - Config.apply compared URLs as raw strings, so the same endpoint spelled without its default port ("https://api.netbird.io" vs "https://api.netbird.io:443") counted as a new value and rewrote the config. It now compares the parsed forms. - UpdateConfig did not collapse the redacted pre-shared key, unlike UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the mask replaced the stored key with asterisks. The CLI warning for a refused SetConfig said the method was not available in the daemon, which sent people looking for a version mismatch that was not there; it now reports the refusal. * [client] Do not write the profile config while only reading it to decide The update-settings gate needs the stored config to decide whether a request changes anything, so the previous commit moved that read ahead of the refusal. The read is not side-effect free: profilemanager.GetConfig writes the config back whenever apply() has to fill in a default the file was missing. A request that the gate then refuses had therefore already rewritten the profile file. PeekConfig is GetConfig without that write-back. The returned config is still normalized in memory, which is what the decision needs; the file is left exactly as it was found. Every caller of storedConfigAtPath feeds a gate that can refuse, so they all peek. Note for reviewers: the daemon still normalizes the file on startup and on every real update, so nothing depends on a read performing that write. * [client] Compare service URLs as endpoints, not as strings Three places in one request path each had their own notion of "same management URL": the config layer compared the parsed URLs as strings, the privileged-change gate compared scheme + host + effective port, and the MDM conflict check compared strings after filling in the default port. Only the middle one was right. A string comparison answers the wrong question. "https://api.netbird.io", "https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint written three ways, so a client restating its own management URL with a trailing slash — a normal way to write it — was still read as a client asking to be repointed, and the update-settings gate refused it. The MDM check had the same flaw against the enforced value. profilemanager.SameServiceURL is now the single comparison: same scheme, same host case-insensitively as DNS names are, same effective port. The config layer, the privileged-change gate and the MDM conflict check all defer to it, so there is one answer to "did this URL change?" instead of three. * [client] Stop the config dry run from generating throwaway keys The dry run's baseline for a profile with no config file yet went through createNewConfig, and apply() generates a WireGuard and an SSH key whenever it finds those fields empty. The baseline is compared against and discarded, so every evaluation minted a keypair it threw away — and logged "generated new Wireguard key". The CLI retries Login in a backoff loop, so a first `netbird up` on a fresh profile filled the daemon log with what reads like peer-key rotation. The baseline now starts from the shared skeleton with placeholder keys, so apply() has nothing to generate. No ConfigInput field maps to either key, so the comparison is unaffected. * [client] Cover the login the update-settings gate used to refuse The gate's decision procedure was tested directly, but no test drove the Login RPC that the refusal actually broke: the CLI retries Login in a backoff loop, so a refused no-op login is what kept a client configured by environment from ever coming up. The handler-level coverage stopped at the refusal case, which passes on the pre-fix code too. This test fails on the pre-fix daemon with "update settings are disabled" and passes now. Past the gate the handler does real work the test does not stand up, so it asserts only that the refusal did not happen. * [client] Re-take the update-settings decision under the config lock Login checks twice on purpose: the first check refuses the ordinary case early, and authorizeAndPrepareLogin re-takes the authoritative one under guardedConfigMu because the first is unsynchronized against a concurrent privileged request. The update-settings decision is now equally value-dependent — it compares the request against the stored config — but it was taken only in the first, unlocked check. So a login that was a no-op when it was checked could be written after a concurrent writer had repointed the profile, which is exactly the window the lock exists to close. The decision is now re-taken alongside the privilege one, which also makes it the last read before persistLoginOverrides writes. The test drives that interleaving through the existing afterLoginPreCheck seam and fails without the re-check. * [client] Drop an unreachable guard and fix two stale comments - loginOverridesInput's nil-message guard cannot be reached: Login dereferences the message well before it, in storedLoginConfig. - The docstring above afterLoginPreCheck described persistLoginOverrides, which lives further down the file and now carries its own. - UpdateConfig's comment named DirectUpdateConfig; the function is DirectUpdateOrCreateConfig. * [client] Make config reads pure and provision the identity explicitly Reading a config wrote it back. profilemanager.readConfig persisted whatever apply() had filled in, and ReadConfig created and wrote the file outright when it was absent, so every reader was quietly a writer: a gate deciding whether to refuse a request, a UI listing profiles, a mobile getter reading one preference. The previous commit worked around that with a PeekConfig variant, which left two read functions with opposite side effects and the antipattern still there for everyone else. Only one thing in a read genuinely had to be persisted: apply() generated the WireGuard and SSH keys when it found them empty, and a generated key cannot be recomputed — losing it means the peer comes back with a different identity and registers again. Everything else apply() fills in is a deterministic default that the next read recomputes anyway. So identity provisioning is now its own step, Config.EnsureIdentity, and the callers that provision write the result out themselves, in the open: - Server.getConfig, the daemon's provisioning point; - the CLI's foreground login, which is about to dial management; - update() / directUpdate(), the config write paths — a stored profile can legitimately carry no identity, since a mobile logout clears the keys in place, and the next write is what has to mint a new one. ReadConfig and GetConfig no longer write anything, PeekConfig is gone, and the dry-run baseline no longer needs placeholder keys to keep apply() from minting real ones. One deliberate leftover: readConfig still calls util.EnforcePermission, which chmods a config file whose permissions are too broad. It changes no content and is idempotent, and dropping it would leave a legacy file world-readable until its first write. * [client] Name the two config readers for what they do ReadConfig and GetConfig differed in one thing — what happens when the file is absent — and neither name said which was which: - ReadConfig -> ReadOrGenerateConfig (reads it, or generates one in memory) - GetConfig -> GetExistingConfig (reads it, or fails) Three comments went with them: - GetConfig's said "return with Config and if it was created. Errors out if it does not exist", which described a bool it does not return and a creation it never performs. - ReadConfig's explained that it does not write, which is what a reader is supposed to do anyway. - Server.getConfig's said it "errors out if it does not exist", which it does not — it resolves a default config, and now provisions the identity too. * [client] Do not panic on a config with no sync message version apply() wrote the incoming sync message version through the stored pointer, without checking it was there: a config that carries no version yet made it dereference nil. Reachable from the update-settings dry run, which runs inside a request handler — where failing closed is the worst acceptable outcome, and a panic is not one. The field is now reassigned like every other optional one, which also means apply() no longer mutates anything the caller still holds through a pointer, so the dry run's copy has one less field to detach. Reported by cubic-dev-ai on PR #7398. * [client] Compare the client certificate paths before reporting a change apply() assigned the incoming mTLS certificate and key paths and set updated unconditionally, without comparing them to what the config already held. It is the same presence-instead-of-value mistake this branch set out to fix, one layer down: a caller restating its own certificate paths was reported as changing them, which trips the value-aware update-settings gate. Reported by cubic-dev-ai on PR #7398. * [client] Address the remaining bot findings on PR #7398 - Login logged the active-profile-state error and returned the same cause; the repo's guidelines call for one or the other, and the wrapped error is the one that carries context. (CodeRabbit) - `netbird up` reported a codes.Unavailable SetConfig failure as "the daemon refused the settings update", but that code also covers a daemon that became unreachable. It now reports what the daemon said without asserting why. (cubic-dev-ai) - TestLogin_ChangingTheManagementURLIsRefused asserted the error and nothing else, while "refused before it can touch daemon state" is the contract. It now checks the stored management URL, the in-progress login and the active profile, matching its SetConfig counterpart. (cubic-dev-ai) * [client] Keep the peer identity out of a read that finds no file ReadOrGenerateConfig resolves a default config when the profile has no file yet, and createNewConfig was minting the WireGuard and SSH keys while doing so. That defeated the provisioning pair it was meant to serve: the CLI's foreground login calls EnsureIdentity to find out whether it has to persist the keys, got generated == false because the read had already generated them, and so never wrote them out. The login then dialed management with an identity that only existed in memory, and the next login registered a second peer. createNewConfig no longer provisions. createProvisionedConfig is the variant that does, and the callers whose contract is "usable as it comes back" use it: CreateInMemoryConfig, whose callers connect with the result, and the two create-and-write branches. A read gets a config with no identity, so the caller's own EnsureIdentity reports the work and triggers the write. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop the gate test from dialing the real management server TestLogin_RestatingTheStoredConfigPassesTheGate asserts that the gate lets a no-op login through, and the handler then went on to do the login for real: isLoginRequired builds an auth client when isLoginRequiredFn is unset, so the test dialed the profile's management URL — api.netbird.io:443. It took 1.05s locally and would hang on a runner with no egress, for a fact about the gate that needs no network at all. Stubbed like the login_outcome tests do. The test now runs in 0.00s. Reported by cubic-dev-ai on PR #7398. * [client] Keep the admin panel path part of its identity The endpoint comparison introduced for the management URL was applied to the admin URL too, and that one is opened in a browser rather than dialed over gRPC: a panel served under /netbird is not the panel served at the root. So a config whose admin URL differed only by path reported no change, and the new path was never persisted — a custom panel URL could not be updated at all. SameServiceURLIncludingPath adds what a URL carries past its endpoint (path, query, fragment, userinfo) while still treating equivalent spellings as equal: a missing path and "/" are the same root, and so is a trailing slash. The management URL keeps the endpoint-only comparison, since only the endpoint is ever dialed. Ports are also normalized numerically now, so ":0443" and ":443" are one port. Reported by cubic-dev-ai on PR #7398 (two findings). * [client] Treat a profile with no identity as already deregistered Two findings on the same consequence of pure reads: a profile can legitimately carry no keys, because logging out clears them in place. - sendLogoutRequestWithConfig went straight to wgtypes.ParseKey and failed with "incorrect key size: 0" on the second logout of the same profile. There is nothing to deregister for a peer that was never registered, so it returns cleanly. Before pure reads this case was hidden: the read minted a key and the daemon dialed management with one it had never seen. - The mobile logout read the config with the generating reader right after checking the file exists. The two are not atomic, so a profile removed in between was resolved from the defaults and recreated by the write that follows. It uses the existing-file reader now. Reported by cubic-dev-ai and CodeRabbit on PR #7398. * [client] Fail `netbird up` when the daemon refuses the settings update With the update-settings kill switch on, `netbird up --enable-rosenpass` connected and said almost nothing: SetConfig refused the change, the CLI downgraded that to a warning, and Login carries no rosenpass field to apply, so the flag was silently dropped. The setting stayed disabled, which is the point of the switch, but the caller was never told their request had been ignored. The refusal now travels as codes.FailedPrecondition instead of codes.Unavailable, and the CLI fails on it. Unavailable means "the daemon cannot serve this call", which is why the CLI downgraded it and why client/ui/services reads it as an unreachable daemon — both wrong for a daemon that answered and refused. FailedPrecondition also matches what the MDM gate already returns for a managed field, so both refusals are now one class of error, and it is added to the login backoff's early-exit codes so a refused login stops instead of retrying for 30s. This does not put the container back in the deadlock: with the value-aware gate, a client restating its own configuration is not refused at all, so nothing reaches this path unless a real change was asked for. * [client] Name the reader storedConfigAtPath actually calls The purity note still said profilemanager.GetConfig, which the rename two commits later turned into GetExistingConfig. Reported by cubic-dev-ai on PR #7398. * [client] Restore the gofmt alignment of the error constants The comment added above errUpdateSettingsDisabled in the previous commit split the const block's alignment group, so gofmt wants the two constants above it re-aligned. CI runs gofmt, so this would have failed the lint job. * [client] Let an unprivileged caller log out a profile with no identity The empty-key check sat behind requirePrivilegeForDeregistration, so an unprivileged logout of an identity-less profile was refused with PermissionDenied instead of completing as the no-op it is. And it was refused for most profiles, not a corner case: the gate arms whenever the SSH server is enabled, and sshServerEnabled reads an absent ServerSSHAllowed as enabled, so every legacy profile qualifies. The check now runs first. What the gate protects against is handing this machine's registered key to another management server; with no key there is nothing to hand over and nothing to protect. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop `netbird login` from retrying a refusal for 30 seconds `netbird up` and `netbird login` both run Login through the backoff cycle, and each carried its own copy of the list of codes that end it. Only up.go learned about codes.FailedPrecondition, so a refused `netbird login` kept retrying and then reported "login backoff cycle failed" instead of what the daemon said. terminalLoginError is now that list, once, next to WithBackOff — the duplicated copies are what let the two commands disagree in the first place. Reported by cubic-dev-ai on PR #7398. * [client] Answer terminalLoginError's nil case on its own terms A successful Login reaches terminalLoginError with a nil error, and nothing covered that. It happens to work on grpc v1.80.0 — gstatus.FromError(nil) answers (nil, true), and Status.Code tolerates a nil receiver by returning codes.OK, which is not in the terminal set — but that is a chain of internal details to be relying on for the common path, and none of it was asserted. Now the nil error is handled where it is obvious, and the table covers it. Reported by CodeRabbit on PR #7398, which called it a panic; measured on v1.80.0 it is not one. The gap was the untested reliance, not a crash. * [client] Treat an unset optional field as its default when diffing a config Seven Config fields mean "the effective default" when they hold no value: the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every consumer already reads a nil as that default, but apply() diffed them by presence — `config.X == nil || *input.X != *config.X` — so an input restating the default counted as a change. That made the update-settings gate refuse `netbird up` outright. The CLI sends every flag whose value came from an environment variable (SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag Changed), and the config a plain login writes leaves all seven unset, so a container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default the file held as null on every start and was answered with FailedPrecondition. apply() now resolves the seven up front, the way it already did for ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on its next write. With the values named, the comparisons below diff values instead of presence, so their nil branches are gone. The network monitor keeps its platform default — on for windows and darwin — and naming it as false elsewhere is what createEngineConfig already read a nil to be. getJWTCacheTTL reaches the same 0 through its own default, and Android's GetEnableSSH* getters already answered nil with false. * [client] Normalize the config before diffing it in WouldChange apply() reports two different things through one bool: an input that changed a value, and a field it had to fill in because the config carried none. The update-settings gate reads that bool as "the caller asked for a change", so any config still missing a default answered a request that asks for nothing with a refusal. Readers already hand out normalized configs — readConfig applies an empty input for exactly this reason — which is why the gate got away with it. But a handler that refuses a request must not depend on where its caller obtained the config, and it must not start reading "this profile predates a field" as "the caller asked for a change" the day someone adds one with a default. WouldChange now runs the filling-in as a pass of its own and discards its verdict, so the pass that answers the caller measures only what the input did. * [client] Stop the last config write that skipped normalization Every path that creates or updates a profile config goes through apply(), which resolves an optional field to its default — except RenameProfile, which read the file with a bare json.Unmarshal, set the name, and wrote it straight back. That copied whatever the file held, so a config written by a client that stored these fields as null kept them null. It could not introduce a null, only carry one forward, but renaming a profile is a poor place to leave a half-resolved config behind. It now reads through GetExistingConfig, which normalizes what it hands out. The tests state the invariant the fix completes, over the *bool fields of Config listed by reflection so a field added later is covered without touching them: none may come out of apply() unset, and no write may store one as null. An optional bool that can be nil, true or false forces every reader to invent the meaning of nil, and makes a diff of the config compare presence rather than value — which is exactly what refused `netbird up` for a client restating its own defaults. SyncMessageVersion stays a genuine three-state field and is not covered: it is an *int whose absence means the client pins no version, and it travels to management that way. * [client] Refuse a serialized config that carries no peer identity ConfigFromJSON still promised a "fully initialized" config after this PR moved key generation out of apply() into EnsureIdentity, but identity stopped being one of the defaults it applies. Its two callers both connect with what they get back: the iOS SDK's Client.SetConfigFromJSON keeps it as the preloaded config Run() uses on tvOS, and Auth.SetConfigFromJSON as the config it authenticates with. No caller feeds it a document without keys today — every stored document comes from Auth.GetConfigJSON, whose config is provisioned by DirectUpdateOrCreateConfig or CreateInMemoryConfig, and the tvOS app only ever edits fields of a document it already has. This is a safety net for the next caller, not a live bug. Provisioning the identity here would be the wrong net. Neither caller can hand a generated key back to the store the document came from — Client exports no config at all — so the peer would connect under an identity nothing persists and register anew on every launch, which is the failure the EnsureIdentity split exists to prevent. A document with no identity means nobody has logged in yet, and saying so is the only useful answer. Both keys are required because both are dead ends when missing: an empty WireGuard key fails the management login on its size, and an empty SSH key fails ssh.GeneratePublicKey in ConnectClient before the engine starts. * [client] Say that the null-on-disk fixture is synthesized, not written The test comment described the null state in the present tense — "the config a plain login writes leaves every one of them unset" — which was true before this branch and is not any more: apply() now resolves those fields, so a login writes them set. unsetOnDisk puts the null state back deliberately, to stand in for a profile an older client wrote. Comments only. * [client] Gather the optional-field defaults into one function Resolving an unset optional field was spread over five places: the two values newConfigSkeleton pre-sets, the block this branch added for the SSH toggles, the network monitor's own if, the `else if` tails of ServerSSHAllowed and RemoteJobsAllowed, and a trailing if for DisableNotifications several hundred lines further down. Reading apply() left no single answer to "what does this field default to, and who decides". They now live in Config.resolveUnsetDefaults, which apply() calls before it compares anything — the ordering being the point, since it is what lets every comparison below diff values instead of presence. The comparisons for ServerSSHAllowed, RemoteJobsAllowed and DisableNotifications lose their `config.X == nil ||` clauses accordingly, as the other six already had. newConfigSkeleton keeps its two, and that is the one asymmetry worth naming: ServerSSHAllowed defaults to false for a new profile and to true for a legacy one, and it only works because the skeleton runs first. The doc comment says so, where before it was implied by the order of two distant blocks. Pure refactor. Verified as one: for the four fields whose branches moved, plus two that did not and the JWT TTL, all 63 combinations of stored value (nil/false/true) against input value (absent/false/true) produce byte- identical resolved values and `updated` verdicts before and after. * [client] Resolve the merge conflicts left in the tree 262ce8c3b landed with the conflict markers still in it, so client/server and the iOS SDK did not compile. Four regions, resolved as follows. client/server/mdm.go — main moved the MDM conflict-check machinery into the mdm package (mdm.ResolveConflicts, mdm.ConflictBool, mdm.ConflictURL, ...). This branch had edited the local copies, which are now dead: dropped, along with the profilemanager import that only the local conflictURL needed. client/server/server.go, Login gate — this branch's value-aware gate stays (the point of the PR: refuse a real divergence, let a restatement through), so main's presence-based `loginRequestHasConfigOverrides` block goes; that helper no longer exists here anyway. Main's other change in the same lines is real and kept: the MDM policy now comes from the daemon-owned s.mdmLoader.Load() instead of the package-level loadMDMPolicy, which main removed. The stale call right below the conflict was the reason the file would not have compiled even with the markers gone. client/server/server.go, getConfig — both sides add something and both are needed. The identity is provisioned and persisted first, then the MDM overlay is applied, so what reaches disk stays the profile's own config: the overlay is runtime-only and re-derived on every load. client/ios/NetBirdSDK/client.go — main reworked SetConfigFromJSON to store the JSON and re-parse it on each load, which is the shape kept; the parse is now only a validity check, and this branch's reason for it (a document with no peer identity is refused, not just an unparseable one) moves into that comment. client/server/update_settings_gate_test.go — follows the sentinel constant to its new home, mdm.PreSharedKeyRedactedSentinel. * [client] Reuse util's service-URL comparison instead of a second copy The endpoint-comparison rules this branch introduced now live in util (PR #7472 moved them there so the MDM conflict check could stop comparing URLs as strings). Keeping a copy here is what produced that bug in the first place: two implementations of "is this the same endpoint?" drift, and the one that drifts starts refusing a URL that addresses the very server it already points at. So SameServiceURL delegates the port normalization to util.ServiceURLPort and drops the local one, and SameServiceURLIncludingPath — endpoint plus path, for the admin panel URL, which is opened rather than dialed — is util.SameServiceURL plus the query, fragment and userinfo it adds on top, so the local path normalization goes too. What stays here is the distinction util does not make: SameServiceURL is endpoint-only, because a management URL is dialed and only its host and port are, while util.SameServiceURL includes the path. Pure refactor. Verified as one: all 198 pairs of a 14-spelling matrix (default and zero-padded ports, host case, trailing slash, path, query, fragment, userinfo, both schemes, nil operands) answer identically for both functions before and after. * [client] Give a newly added profile its identity (review item 1) AddProfile writes the config it builds straight to disk, but built it with createNewConfig, which stopped generating the peer's keys when identity generation moved out of apply() into EnsureIdentity. The profile file landed with an empty PrivateKey and SSHKey. Nothing lost the keys permanently — the daemon's own getConfig provisions and persists them on first use — but every reader that does not write got a config that cannot connect in the meantime, which is exactly the set this branch grew: the update-settings gate deciding whether to refuse a request, and the mobile SDKs loading a stored profile. createProvisionedConfig exists for callers that persist or connect, and this is one; before the split, createNewConfig produced the keys here too. * [client] Let a logged-out profile deserialize again (review item 2) ConfigFromJSON refused a document with no WireGuard or SSH key. A config legitimately has none between a logout and the next login: mobile LogoutProfile clears both in place and writes the profile back, so the peer re-registers on the next login instead of returning as itself. So the refusal broke the mobile flows it was meant to protect. On iOS and tvOS the stored JSON of a logged-out profile stopped loading through Client.SetConfigFromJSON and Auth.SetConfigFromJSON, and copyConfig — which round-trips a Config through JSON to take an in-memory copy before applying the MDM overlay — failed on the same document. Where the old code silently minted a key, this returned an error, which is worse for logout and profile switching alike: neither is asking to connect. The deserializer now stays out of the identity question in both directions: it does not generate one (a read cannot hand back keys nothing will write down) and does not refuse one that is absent. Whoever goes on to connect is where an absent identity has to be answered — and it already is, by the login path that provisions and persists. ErrConfigWithoutIdentity goes with it; nothing else used it. * [client] Fold the scheme case here too, like util does (review item 6) profilemanager.SameServiceURL compared the scheme with ==, util.SameServiceURL with EqualFold. No observable difference — net/url lowercases the scheme when it parses, and both functions take parsed URLs — but two functions of the same name with two different rules is a trap for whoever reads one and assumes the other. * [client] Classify the daemon's refusals in the GUI (review item 3) FailedPrecondition reached the classifier unmatched, so a refusal showed as "Operation failed". It is the code both of the daemon's deliberate refusals carry: the update-settings kill switch, and a field an MDM policy manages. Both are now named — settings_locked and settings_managed_by_mdm, matched on the message the daemon composes — and FailedPrecondition itself falls back to change_refused, so a refusal the daemon grows later still reads as a refusal rather than a failure. Only the English strings are added. Bundle.Translate falls back to the default language for a missing key, so other locales show English until the usual translation pass, rather than the bare "error." the classifier would otherwise surface. Note: the package needs GTK4/WebKit to build, which this machine has not, so the test is type-checked (go vet, GOOS=windows) but was not executed locally; CI's Linux job runs it. * [client] Cover the mobile profile round trip: create, logout, reload Both mobile regressions this branch's review turned up lived on the same path, and neither was visible from the desktop client: a profile created without an identity, and a logged-out profile that would no longer deserialize. The desktop never meets the second one — it is mobile logout that clears the peer's keys in place, so the next login registers a new peer instead of bringing the old one back. The test walks a profile through the round its user puts it through — created, logged out, loaded again, switched away from and back — and loads it at each step the way the SDKs do: read the stored config, serialize it, load it back. That is Client.SetConfigFromJSON storing the document for tvOS, Auth.SetConfigFromJSON authenticating with it, and copyConfig taking an in-memory copy before the MDM overlay. Verified to fail on each regression separately: restoring the bare constructor in AddProfile fails it with "a new profile was written with no identity", and restoring the identity check in ConfigFromJSON fails it at "load the profile back". client/mobile already had the coverage for the first one in TestLogoutProfile_DisableProfiles — which arrived from main with the MDM work, and which I had not been running. * [client] Name only the refusals, not every FailedPrecondition The classifier gained a blanket FailedPrecondition -> change_refused fallback so a refusal would stop reading as "Operation failed". It reaches too far: the daemon returns that code for two dozen states that are not settings refusals — "not logged in", "client is not running", "another capture is already running", "session can no longer be extended, log in again to reconnect" — and errorClassifier is shared with the session and connection services, not just the settings save. So the user was told the service had refused their change while what they actually had to do was log in again. The two refusals the daemon composes stay named by their message; everything else goes back to the generic message, which says nothing rather than something wrong. Reported by cubic on the PR. * [client] Say what each assertion was checking in the mobile test AGENTS.md asks for a context message on comparison and boolean assertions, and four of the ones added with this test had none, so a failure would have read as a bare Empty/Equal with no hint of which step of the round trip broke. Reported by cubic on the PR. * [client] Translate the two new error strings into every locale The GUI classifier gained error.settings_locked and error.settings_managed_by_mdm, and only the English strings were added: the bundle falls back to the default language for a missing key, so nothing would have shown a bare "error." to a user. CI disagrees, and it is right to: check-translations.mjs requires every locale to carry the full English key set, so English-only fails the gate rather than degrading quietly. The ten locales now carry both strings. These are my translations, not a localization pass — worth a second pass by whoever owns the language, in particular for the phrasing of "an administrator has locked them". The uk file also loses two lines of stray 8-space indentation, normalized by rewriting the file; no key or value changed with it. * [client] Persist the profile before overlaying MDM on it (review item) `netbird login` read the config, applied the MDM policy on top, and only then provisioned the identity and wrote the result out. On a profile with no identity yet — a first login — that write persisted the enforced values into the user's own config file: an MDM-managed management URL or pre-shared key became indistinguishable from one the user set, and stayed behind once the policy was withdrawn. Provisioning and its write now come first, and the overlay is applied to the in-memory config afterwards, where it belongs: it is re-derived on every load and never meant to reach disk from here. Server.getConfig already orders the two this way; the two paths now agree. Reported by cubic on the PR. * [client] Assert against the stored config, not a resolved default (review item) The login-gate test read the profile back with ReadOrGenerateConfig, which resolves a default config in memory when the file is missing — and that default's management URL is the very value the assertion checks. An erased or mislocated profile would have passed the test instead of failing it. The file is written by the test itself, so GetExistingConfig is the right reader: it errors when the file is gone. Reported by cubic on the PR. * [client] Keep the mTLS pair off the gate's dry run (review item) WouldChange runs the real apply() against a throwaway copy, and apply() loads the client mTLS certificate and key from disk whenever the config names them. So every gated SetConfig and Login read the pair — twice per request, once for the normalization pass and once for the verdict — including requests that were about to be refused or that changed nothing, and logged an error per request when the files were missing. The gate used to be presence-based and never called apply(), so this was new work on a request path. The loaded pair feeds the connection and never the comparison: nothing in apply() reads it back, and it does not move the `updated` verdict. A config built only to be compared against now says so, and apply() skips the load for it. Reported by cubic on the PR. * Makes it explicit that RenameProfile does write on disk * [client] Provision the peer identity under the config lock (review item) Login took the authoritative update-settings and privilege decisions under guardedConfigMu, then released it and called getConfig, which mints the peer's identity and writes the config out. Between that read and that write, a SetConfig holding the same lock could land a change and answer its caller — and then be overwritten by the config the login had already read. The window is narrow: getConfig only writes when the profile has no identity or no file, so in practice a first login racing a settings change on the same profile. It is also narrower than before this branch, where the write happened inside the reader on every read that filled in a default. Provisioning now runs where the decision it belongs to runs: at the end of authorizeAndPrepareLogin, with the lock already held, next to persistLoginOverrides, which writes there too. No lock is taken that was not held before, so the documented guardedConfigMu-then-mutex order is untouched. getConfig keeps its behaviour by calling the same extracted helper; on the login path it now finds the identity already there and writes nothing. The other callers are unchanged, and still provision outside any lock — a concurrent SetConfig is not part of their flow. Reported by cubic on the PR. * [client] Declare the probe marker to the debug-bundle field check TestAddConfig_AllFieldsCovered walks Config by reflection and fails until every field is either rendered in the debug bundle or listed as excluded with a reason. The probe marker added for the gate's dry run was neither, so the client unit suite went red on every platform. It is excluded: it marks a throwaway copy built to be compared against and discarded, so it is never set on a config anyone runs with, and rendering it would only ever print false. * [client] Provision the peer identity on the iOS login path Key generation used to happen inside apply(), so a config loaded from JSON with no keys got them in memory on the way in, the login worked, and the app stored the result. This branch moved generation into EnsureIdentity, and nothing in the iOS SDK called it. The consequence lands on the flow the mobile logout sets up: logout clears both keys in place so the next login registers a new peer. The app then hands that keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with an empty WireGuard key, which fails on key size before the SSO flow starts — the user cannot sign back in. Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns for the caller to persist, and writes it to disk itself when the profile has a file — non-atomically, like NewAuth's own write, since the tvOS App Group sandbox blocks temp-file-and-rename. Not covered by a test: the package builds only under GOOS=ios, which the test jobs do not run. Verified by building and vetting for GOOS=ios/arm64. Reported by pappz in review. * [client] Name the resolving reader for what it does, not what it makes ReadOrGenerateConfig reads the profile config and falls back to the defaults in memory when there is no file. "Generate" reads as "produces and stores", which is the opposite of the property the rename it came from was meant to advertise: the read is pure, writes nothing and mints no identity. ReadConfigOrDefault says the same without the side effect, and pairs with GetExistingConfig, which fails where this one falls back. Its doc comment now states the absence of a write rather than only the fallback. Pure rename; the two remaining mentions of the pre-branch name ReadConfig in the tests go with it. Reported by pappz in review. * [client] Read an emptied NAT list as the absent one it matches apply() compared NATExternalIPs with reflect.DeepEqual, which calls a nil slice and an empty slice different. Both mean the same thing — no NAT mappings — and the two meet on a perfectly ordinary start: a profile stores the absent list as JSON null and reads it back nil, while `netbird up` sends CleanNATExternalIPs, an empty list, whenever NB_EXTERNAL_IP_MAP is set to nothing, which a deployment template does by default. So the gate saw a change where nothing changed and refused the request with FailedPrecondition. That is the same deadlock this branch exists to remove, reached through another field: a container with the kill switch on could not come up, and `netbird up` reported "the daemon refused the settings update". The DNS label list next to it already used slices.Equal, which treats nil and empty as the same list. The NAT list now does too, and the last use of reflect in the package goes with it. Reported by pappz in review. --- client/android/preferences.go | 34 +- client/cmd/login.go | 27 +- client/cmd/root.go | 39 ++ client/cmd/up.go | 35 +- client/cmd/up_setconfig_refusal_test.go | 85 +++ client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 483 ++++++++++++---- .../profilemanager/config_json_test.go | 44 ++ .../config_optional_fields_test.go | 131 +++++ .../profilemanager/config_probe_test.go | 96 ++++ client/internal/profilemanager/config_test.go | 2 +- .../config_would_change_test.go | 529 ++++++++++++++++++ client/internal/profilemanager/service.go | 33 +- .../internal/profilemanager/service_test.go | 24 + client/ios/NetBirdSDK/client.go | 4 + client/ios/NetBirdSDK/login.go | 29 + client/ios/NetBirdSDK/preferences.go | 14 +- client/mobile/profile_lifecycle_test.go | 97 ++++ client/mobile/profile_manager.go | 5 +- client/server/login_gate_test.go | 2 +- client/server/login_overrides_test.go | 8 +- client/server/logout_gate_test.go | 2 +- client/server/mdm.go | 86 --- client/server/provision_identity_test.go | 59 ++ client/server/server.go | 204 ++++--- client/server/setconfig_mdm_test.go | 2 +- client/server/setconfig_test.go | 2 +- client/server/ssh_gate.go | 18 +- client/server/update_settings_gate.go | 55 ++ client/server/update_settings_gate_test.go | 390 +++++++++++++ client/ui/i18n/locales/de/common.json | 6 + client/ui/i18n/locales/en/common.json | 8 + client/ui/i18n/locales/es/common.json | 6 + client/ui/i18n/locales/fr/common.json | 6 + client/ui/i18n/locales/hu/common.json | 6 + client/ui/i18n/locales/it/common.json | 6 + client/ui/i18n/locales/ja/common.json | 6 + client/ui/i18n/locales/pt/common.json | 6 + client/ui/i18n/locales/ru/common.json | 6 + client/ui/i18n/locales/uk/common.json | 6 + client/ui/i18n/locales/zh-CN/common.json | 6 + client/ui/services/errors.go | 11 + client/ui/services/errors_test.go | 23 + 43 files changed, 2315 insertions(+), 327 deletions(-) create mode 100644 client/cmd/up_setconfig_refusal_test.go create mode 100644 client/internal/profilemanager/config_json_test.go create mode 100644 client/internal/profilemanager/config_optional_fields_test.go create mode 100644 client/internal/profilemanager/config_probe_test.go create mode 100644 client/internal/profilemanager/config_would_change_test.go create mode 100644 client/mobile/profile_lifecycle_test.go create mode 100644 client/server/provision_identity_test.go create mode 100644 client/server/update_settings_gate.go create mode 100644 client/server/update_settings_gate_test.go diff --git a/client/android/preferences.go b/client/android/preferences.go index 5ce31026c..3623de23f 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -64,7 +64,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -86,7 +86,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -112,7 +112,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -133,7 +133,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) { return *p.configInput.DisableClientRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -170,7 +170,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) { return *p.configInput.DisableServerRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -188,7 +188,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) { return *p.configInput.DisableDNS, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -206,7 +206,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) { return *p.configInput.DisableFirewall, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -227,7 +227,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) { return *p.configInput.ServerSSHAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -249,7 +249,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) { return *p.configInput.EnableSSHRoot, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -271,7 +271,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) { return *p.configInput.EnableSSHSFTP, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -293,7 +293,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) { return *p.configInput.EnableSSHLocalPortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -315,7 +315,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) { return *p.configInput.EnableSSHRemotePortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -340,7 +340,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) { return *p.configInput.BlockInbound, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -358,7 +358,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -377,7 +377,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/cmd/login.go b/client/cmd/login.go index 11867be09..1dc2d0d09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -9,8 +9,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/term" - "google.golang.org/grpc/codes" - gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -145,10 +143,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str err = WithBackOff(func() error { var backOffErr error loginResp, backOffErr = client.Login(ctx, &loginRequest) - if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument || - s.Code() == codes.PermissionDenied || - s.Code() == codes.NotFound || - s.Code() == codes.Unimplemented) { + if terminalLoginError(backOffErr) { loginErr = backOffErr return nil } @@ -327,10 +322,28 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, } - config, err := profilemanager.ReadConfig(configFilePath) + config, err := profilemanager.ReadConfigOrDefault(configFilePath) if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // Reading a config does not provision one: this login is about to dial + // management with the profile's identity, so mint the keys if the profile + // has none yet and put them on disk — a key that stayed in memory would + // come back different on the next run and register a second peer. + // + // Before the MDM overlay below, on purpose: the file must keep the + // profile's own values. The overlay is runtime-only and re-derived on + // every load, so persisting it would turn an enforced management URL or + // pre-shared key into one the user appears to own once the policy is + // withdrawn. + if generated, err := config.EnsureIdentity(); err != nil { + return fmt.Errorf("ensure profile identity: %v", err) + } else if generated { + if err := profilemanager.WriteOutConfig(configFilePath, config); err != nil { + return fmt.Errorf("write out config file %s: %v", configFilePath, err) + } + } + // CLI standalone login: profilemanager no longer auto-applies MDM, // so layer in the OS-native policy here. Desktop builds construct // a Loader with no fetcher — the build-tagged loadPlatform reads diff --git a/client/cmd/root.go b/client/cmd/root.go index be6479440..2ca14c39c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,6 +20,8 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" @@ -285,6 +287,43 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e return grpc.DialContext(ctx, target, opts...) } +// terminalLoginError reports whether a Login failure is final, so the backoff +// cycle stops and the caller is told what the daemon said instead of "login +// backoff cycle failed" thirty seconds later. Retrying cannot change any of +// these answers: the request is malformed, the caller is not allowed, the +// target does not exist, a precondition on the daemon refuses it (the +// update-settings kill switch, an MDM-managed field), or the method is not +// implemented. +// +// Both `netbird up` and `netbird login` run Login through the backoff, and +// they each carried their own copy of this list — which is how one of them +// ended up retrying a refusal the other treated as final. +func terminalLoginError(err error) bool { + // A successful Login reaches here with a nil error, and that is not a + // terminal failure. Handled explicitly rather than left to + // gstatus.FromError, which answers (nil, true) for a nil error and leans on + // Status.Code tolerating a nil receiver to come back as codes.OK. + if err == nil { + return false + } + + s, ok := gstatus.FromError(err) + if !ok { + return false + } + + switch s.Code() { + case codes.InvalidArgument, + codes.PermissionDenied, + codes.NotFound, + codes.FailedPrecondition, + codes.Unimplemented: + return true + default: + return false + } +} + // WithBackOff execute function in backoff cycle. func WithBackOff(bf func() error) error { return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) { diff --git a/client/cmd/up.go b/client/cmd/up.go index f5fac9749..120a25595 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -357,9 +357,17 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager // set the new config req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { - if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) - } else { + switch reason, refused := refusedSettingsUpdate(err); { + case refused: + // Failing here is the point: carrying on would connect while + // silently dropping the settings the caller asked for, since + // nothing further down the line applies them. + return fmt.Errorf("the daemon refused the settings update: %s", reason) + case gstatus.Code(err) == codes.Unavailable: + // The daemon cannot serve the method at all, which is what this + // code means; an older daemon without it lands here. + log.Warnf("the daemon did not apply the settings update: %s", gstatus.Convert(err).Message()) + default: return daemonCallError("call service setConfig method", err) } } @@ -400,10 +408,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ err = WithBackOff(func() error { var backOffErr error loginResp, backOffErr = client.Login(ctx, loginRequest) - if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument || - s.Code() == codes.PermissionDenied || - s.Code() == codes.NotFound || - s.Code() == codes.Unimplemented) { + if terminalLoginError(backOffErr) { loginErr = backOffErr return nil } @@ -472,6 +477,22 @@ func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { } } +// refusedSettingsUpdate reports whether err is the daemon refusing the settings +// a request carried — the update-settings kill switch, or a field an MDM policy +// manages — and returns the reason it gave. +// +// The distinction that matters is against codes.Unavailable, which means the +// daemon cannot serve the call: that one is worth a warning, because an older +// daemon without the method lands there and the rest of `netbird up` still +// works. A refusal is not, because the settings would be silently dropped. +func refusedSettingsUpdate(err error) (string, bool) { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.FailedPrecondition { + return "", false + } + return st.Message(), true +} + func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { var req proto.SetConfigRequest req.ProfileName = profileName diff --git a/client/cmd/up_setconfig_refusal_test.go b/client/cmd/up_setconfig_refusal_test.go new file mode 100644 index 000000000..fdf580102 --- /dev/null +++ b/client/cmd/up_setconfig_refusal_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +// A refused settings update has to fail `netbird up`, or a caller that asked +// for a setting the daemon will not apply connects as if it had been applied. +// The daemon being unable to serve the call is the case that stays a warning. +func TestRefusedSettingsUpdate(t *testing.T) { + tests := []struct { + name string + err error + wantRefused bool + }{ + { + name: "the kill switch refused the change", + err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled, you cannot use this feature without update settings enabled"), + wantRefused: true, + }, + { + name: "an MDM policy manages the field", + err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), + wantRefused: true, + }, + { + name: "the daemon cannot serve the call", + err: gstatus.Errorf(codes.Unavailable, "connection refused"), + wantRefused: false, + }, + { + name: "any other RPC failure", + err: gstatus.Errorf(codes.Internal, "boom"), + wantRefused: false, + }, + { + name: "not a status error at all", + err: errors.New("boom"), + wantRefused: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, refused := refusedSettingsUpdate(tt.err) + require.Equal(t, tt.wantRefused, refused) + if tt.wantRefused { + require.Equal(t, gstatus.Convert(tt.err).Message(), reason, "the daemon's reason must reach the caller") + } + }) + } +} + +// Both `netbird up` and `netbird login` drive Login through the backoff cycle, +// and a final answer has to stop it: retrying a refusal only replaces the +// daemon's reason with "login backoff cycle failed" thirty seconds later. +func TestTerminalLoginError(t *testing.T) { + tests := []struct { + name string + err error + wantTerminal bool + }{ + {name: "settings refused by the kill switch", err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled"), wantTerminal: true}, + {name: "field managed by MDM", err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), wantTerminal: true}, + {name: "caller not allowed", err: gstatus.Errorf(codes.PermissionDenied, "nope"), wantTerminal: true}, + {name: "malformed request", err: gstatus.Errorf(codes.InvalidArgument, "nope"), wantTerminal: true}, + {name: "profile not found", err: gstatus.Errorf(codes.NotFound, "nope"), wantTerminal: true}, + {name: "method missing on an older daemon", err: gstatus.Errorf(codes.Unimplemented, "nope"), wantTerminal: true}, + {name: "daemon unreachable, worth retrying", err: gstatus.Errorf(codes.Unavailable, "connection refused"), wantTerminal: false}, + {name: "transient internal failure", err: gstatus.Errorf(codes.Internal, "boom"), wantTerminal: false}, + {name: "not a status error", err: errors.New("boom"), wantTerminal: false}, + {name: "no error at all, the login succeeded", err: nil, wantTerminal: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantTerminal, terminalLoginError(tt.err)) + }) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 6a810bccc..0f74490f2 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -846,6 +846,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with", "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 412f81b5c..ac1b90a62 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -10,7 +10,6 @@ import ( "os" "os/user" "path/filepath" - "reflect" "runtime" "slices" "strings" @@ -198,6 +197,11 @@ type Config struct { MTU uint16 + // probing marks a config that exists only to be compared against and then + // thrown away, so apply() can skip the work that feeds no verdict. + // Unexported, so it never reaches the JSON. + probing bool + // policy is the MDM policy that produced the currently-set values // for any MDM-enforced fields. Set by ApplyMDMPolicy on every // invocation. Never persisted to disk. Callers query enforcement @@ -300,9 +304,11 @@ func fileExists(path string) (bool, error) { return false, err } -// createNewConfig creates a new config generating a new Wireguard key and saving to file -func createNewConfig(input ConfigInput) (*Config, error) { - config := &Config{ +// newConfigSkeleton returns the field values a brand-new profile config starts +// from, before apply() fills in the rest. Shared with the dry-run baseline so +// the two cannot disagree about what "a new config" means. +func newConfigSkeleton() *Config { + return &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), // Remote jobs are an explicit opt-in and default off, including for @@ -310,6 +316,91 @@ func createNewConfig(input ConfigInput) (*Config, error) { RemoteJobsAllowed: util.False(), WgPort: iface.DefaultWgPort, } +} + +// resolveUnsetDefaults is the single place where an optional field that carries +// no value gets one, and the only place that states what each of those defaults +// is. apply() runs it before it compares anything, and that ordering is the +// point: with the values named, every comparison below it diffs values instead +// of presence. +// +// Presence-based comparison is what broke `netbird up` for a client configured +// through the environment. These fields mean "the effective default" when they +// hold nothing — every consumer already reads a nil as the value resolved here, +// the SSH toggles in engine_ssh.go and the network monitor in +// createEngineConfig — so naming them changes nothing about what runs. But +// while they stayed nil, an input restating the default read as a change, and +// since the CLI sends every flag whose value came from an environment variable +// on each `netbird up`, a client with NB_ENABLE_SSH_ROOT=false restated it +// every time and the update-settings gate refused it. +// +// Filling a field in is not a settings change, so a caller measuring change +// must not read the returned bool as one: see WouldChange, which runs a pass +// for this and discards its verdict. +// +// ServerSSHAllowed is the one field whose default depends on the config's age. +// A brand-new profile gets false from newConfigSkeleton, which runs before +// this, so what is resolved here is only the legacy case: a config written by a +// version that had no such field keeps SSH on, for backwards compatibility. +func (config *Config) resolveUnsetDefaults() (updated bool) { + // Fields that default to false on every platform. + for _, field := range []**bool{ + &config.EnableSSHRoot, + &config.EnableSSHSFTP, + &config.EnableSSHLocalPortForwarding, + &config.EnableSSHRemotePortForwarding, + &config.DisableSSHAuth, + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + &config.RemoteJobsAllowed, + } { + if *field == nil { + *field = util.False() + updated = true + } + } + + if config.DisableNotifications == nil { + log.Infof("setting notifications to disabled by default") + config.DisableNotifications = util.True() + updated = true + } + + if config.SSHJWTCacheTTL == nil { + // A zero TTL disables the JWT cache, which is what no value meant. + config.SSHJWTCacheTTL = new(int) + updated = true + } + + if config.NetworkMonitor == nil { + // network monitoring is on by default on windows and darwin clients + enabled := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + config.NetworkMonitor = &enabled + updated = true + } + + if config.ServerSSHAllowed == nil { + if runtime.GOOS == "android" { + // default to disabled SSH on Android for security + log.Infof("setting SSH server to false by default on Android") + config.ServerSSHAllowed = util.False() + } else { + // enables SSH for configs from old versions to preserve backwards compatibility + log.Infof("falling back to enabled SSH server for pre-existing configuration") + config.ServerSSHAllowed = util.True() + } + updated = true + } + + return updated +} + +// createNewConfig resolves a new config in memory, with no identity: whoever +// needs the peer's keys calls EnsureIdentity and persists the result, so a read +// that lands on a missing file cannot hand back a config carrying keys that +// nothing will ever write down. +func createNewConfig(input ConfigInput) (*Config, error) { + config := newConfigSkeleton() if _, err := config.apply(input); err != nil { return nil, err @@ -318,6 +409,52 @@ func createNewConfig(input ConfigInput) (*Config, error) { return config, nil } +// createProvisionedConfig is createNewConfig plus the peer's identity, for the +// callers that go on to persist the config or to connect with it. +func createProvisionedConfig(input ConfigInput) (*Config, error) { + config, err := createNewConfig(input) + if err != nil { + return nil, err + } + + if _, err := config.EnsureIdentity(); err != nil { + return nil, err + } + + return config, nil +} + +// EnsureIdentity generates the keys that identify this peer if the config does +// not carry them yet, reporting whether it had to generate any. +// +// It is deliberately not part of apply(). Everything apply() fills in is a +// default it can recompute on the next read, but a generated key is not: it +// has to be persisted, or the peer comes back with a different WireGuard +// identity and re-registers. Having apply() generate keys is what forced every +// read of a config to write it back — so identity provisioning is its own step +// now, and the callers that perform it write the result out explicitly. +func (config *Config) EnsureIdentity() (bool, error) { + generated := false + + if config.PrivateKey == "" { + log.Infof("generated new Wireguard key") + config.PrivateKey = generateKey() + generated = true + } + + if config.SSHKey == "" { + log.Infof("generated new SSH key") + pem, err := ssh.GeneratePrivateKey(ssh.ED25519) + if err != nil { + return generated, err + } + config.SSHKey = string(pem) + generated = true + } + + return generated, nil +} + func (config *Config) apply(input ConfigInput) (updated bool, err error) { if config.Name != "" { sanitized, err := sanitizeDisplayName(config.Name) @@ -329,6 +466,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } } + + // Every optional field gets its value here, before anything below compares + // one. See resolveUnsetDefaults for why that ordering is the point. + if config.resolveUnsetDefaults() { + updated = true + } + if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) @@ -336,20 +480,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.ManagementURL != "" && input.ManagementURL != config.ManagementURL.String() { - log.Infof("new Management URL provided, updated to %#v (old value %#v)", - input.ManagementURL, config.ManagementURL.String()) + // The comparison is on the endpoint the URL addresses, not on its + // spelling: the same endpoint can be written several ways (an implicit + // :443, a trailing slash, a different host case), and treating an + // equivalent URL as new would rewrite the config and report a settings + // change where the configuration does not actually change. + if input.ManagementURL != "" { URL, err := parseURL("Management URL", input.ManagementURL) if err != nil { return false, err } - config.ManagementURL = URL - updated = true - } else if config.ManagementURL == nil { - log.Infof("using default Management URL %s", DefaultManagementURL) - config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) - if err != nil { - return false, err + if !SameServiceURL(URL, config.ManagementURL) { + log.Infof("new Management URL provided, updated to %#v (old value %#v)", + URL.String(), config.ManagementURL.String()) + config.ManagementURL = URL + updated = true } } @@ -360,31 +505,20 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.AdminURL != "" && input.AdminURL != config.AdminURL.String() { - log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", - input.AdminURL, config.AdminURL.String()) + // The admin panel is opened, not dialed, so unlike the Management URL its + // path is part of what identifies it: a panel served under /netbird is not + // the one served at the root. + if input.AdminURL != "" { newURL, err := parseURL("Admin Panel URL", input.AdminURL) if err != nil { return updated, err } - config.AdminURL = newURL - updated = true - } - - if config.PrivateKey == "" { - log.Infof("generated new Wireguard key") - config.PrivateKey = generateKey() - updated = true - } - - if config.SSHKey == "" { - log.Infof("generated new SSH key") - pem, err := ssh.GeneratePrivateKey(ssh.ED25519) - if err != nil { - return false, err + if !SameServiceURLIncludingPath(newURL, config.AdminURL) { + log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", + newURL.String(), config.AdminURL.String()) + config.AdminURL = newURL + updated = true } - config.SSHKey = string(pem) - updated = true } if input.WireguardPort != nil && *input.WireguardPort != config.WgPort { @@ -405,7 +539,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NATExternalIPs != nil && !reflect.DeepEqual(config.NATExternalIPs, input.NATExternalIPs) { + // slices.Equal, not reflect.DeepEqual, and for the same reason the DNS + // labels below use it: DeepEqual calls a nil slice and an empty one + // different, while both mean "no NAT mappings". A profile stores the + // absent list as JSON null and reads it back nil, and `netbird up` sends + // CleanNATExternalIPs — an empty list — whenever NB_EXTERNAL_IP_MAP is set + // to nothing, so the two met on every start and the gate read a no-op as a + // settings change. + if input.NATExternalIPs != nil && !slices.Equal(config.NATExternalIPs, input.NATExternalIPs) { log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])", strings.Join(input.NATExternalIPs, " "), strings.Join(config.NATExternalIPs, " ")) @@ -443,21 +584,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { + if input.NetworkMonitor != nil && *input.NetworkMonitor != *config.NetworkMonitor { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true } - if config.NetworkMonitor == nil { - // enable network monitoring by default on windows and darwin clients - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - enabled := true - config.NetworkMonitor = &enabled - updated = true - } - } - if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress { log.Infof("updating custom DNS address %#v (old value %#v)", string(input.CustomDNSAddress), config.CustomDNSAddress) @@ -490,7 +622,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { + if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { @@ -498,20 +630,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.ServerSSHAllowed = input.ServerSSHAllowed updated = true - } else if config.ServerSSHAllowed == nil { - if runtime.GOOS == "android" { - // default to disabled SSH on Android for security - log.Infof("setting SSH server to false by default on Android") - config.ServerSSHAllowed = util.False() - } else { - // enables SSH for configs from old versions to preserve backwards compatibility - log.Infof("falling back to enabled SSH server for pre-existing configuration") - config.ServerSSHAllowed = util.True() - } - updated = true } - if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if input.RemoteJobsAllowed != nil && *input.RemoteJobsAllowed != *config.RemoteJobsAllowed { if *input.RemoteJobsAllowed { log.Infof("enabling remote jobs") } else { @@ -519,14 +640,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.RemoteJobsAllowed = input.RemoteJobsAllowed updated = true - } else if config.RemoteJobsAllowed == nil { - // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config - // with no value defaults to disabled rather than being turned on. - config.RemoteJobsAllowed = util.False() - updated = true } - if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { + if input.EnableSSHRoot != nil && *input.EnableSSHRoot != *config.EnableSSHRoot { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -536,7 +652,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { + if input.EnableSSHSFTP != nil && *input.EnableSSHSFTP != *config.EnableSSHSFTP { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -546,7 +662,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { + if input.EnableSSHLocalPortForwarding != nil && *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -556,7 +672,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { + if input.EnableSSHRemotePortForwarding != nil && *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -566,7 +682,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { + if input.DisableSSHAuth != nil && *input.DisableSSHAuth != *config.DisableSSHAuth { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -576,7 +692,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { + if input.SSHJWTCacheTTL != nil && *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -659,13 +775,16 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + // Assigning the pointer, not writing through it: a config that carries no + // version yet would otherwise be a nil dereference, and a panic inside a + // request handler is not a way to fail. + if input.SyncMessageVersion != nil && (config.SyncMessageVersion == nil || *input.SyncMessageVersion != *config.SyncMessageVersion) { log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) - *config.SyncMessageVersion = *input.SyncMessageVersion + config.SyncMessageVersion = input.SyncMessageVersion updated = true } - if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { + if input.DisableNotifications != nil && *input.DisableNotifications != *config.DisableNotifications { if *input.DisableNotifications { log.Infof("disabling notifications") } else { @@ -675,24 +794,24 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if config.DisableNotifications == nil { - disabled := true - config.DisableNotifications = &disabled - log.Infof("setting notifications to disabled by default") - updated = true - } - - if input.ClientCertKeyPath != "" { + // Compared, not just assigned: restating the path a config already holds + // changes nothing, and reporting it as an update makes a caller that + // re-sends its own configuration look like one asking to change it. + if input.ClientCertKeyPath != "" && input.ClientCertKeyPath != config.ClientCertKeyPath { config.ClientCertKeyPath = input.ClientCertKeyPath updated = true } - if input.ClientCertPath != "" { + if input.ClientCertPath != "" && input.ClientCertPath != config.ClientCertPath { config.ClientCertPath = input.ClientCertPath updated = true } - if config.ClientCertPath != "" && config.ClientCertKeyPath != "" { + // Not on a probe: the loaded pair feeds the connection, never the + // comparison, and this would otherwise run on every gated SetConfig and + // Login — twice per request — including those that are refused or change + // nothing, logging an error per request when the files are missing. + if !config.probing && config.ClientCertPath != "" && config.ClientCertKeyPath != "" { cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath) if err != nil { log.Error("Failed to load mTLS cert/key pair: ", err) @@ -886,6 +1005,49 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { return parseURL(serviceName, serviceURL) } +// SameServiceURL reports whether two service URLs address the same endpoint: +// same scheme, same host compared case-insensitively as DNS names are, and +// same effective port, where an absent port means the scheme's default. +// +// This is the one comparison every caller deciding "did this URL change?" must +// use. A string comparison answers a different question: "https://host", +// "https://host/" and "https://HOST:443" are one endpoint written three ways, +// and reading them as three values makes a client that restates its own +// management URL look like a client asking to be repointed. A nil operand +// matches only another nil one. +// +// The path plays no part: a management URL is dialed, and only its host and +// port are. util.SameServiceURL is this comparison plus the path, which is +// what SameServiceURLIncludingPath needs and delegates to. +func SameServiceURL(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + util.ServiceURLPort(a) == util.ServiceURLPort(b) +} + +// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries +// past its endpoint: path, query, fragment and userinfo. +// +// Use it for a URL that gets opened rather than dialed. The admin panel can +// live under a path, so two URLs with the same endpoint and different paths are +// two different panels — where for a URL the client dials over gRPC only the +// endpoint is ever used. Equivalent spellings still compare equal: a missing +// path and "/" are the same root, and so is a trailing slash on any path. +func SameServiceURLIncludingPath(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return util.SameServiceURL(a, b) && + a.RawQuery == b.RawQuery && + a.Fragment == b.Fragment && + a.User.String() == b.User.String() +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { @@ -930,6 +1092,84 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { return false } +// WouldChange reports whether applying input would modify any field the +// config persists, leaving the receiver untouched. It is the dry-run half of +// UpdateConfig and reuses the very same diff logic (Config.apply), so a +// caller asking "is this a settings change?" cannot drift from what an +// actual update would do, nor go stale when a new field is added. +// +// A redacted pre-shared key is collapsed to "unset" exactly as +// UpdateOrCreateConfig does, so a UI that round-trips the mask is not read as +// a request for a new key. +// +// A nil receiver means the profile holds no config yet, so the baseline is the +// config the daemon would create for it: input values matching those defaults +// change nothing, anything else does. +func (config *Config) WouldChange(input ConfigInput) (bool, error) { + probe := config.clone() + if probe == nil { + baseline, err := newDryRunBaseline(input.ConfigPath) + if err != nil { + return true, fmt.Errorf("build default config baseline: %w", err) + } + probe = baseline + } + probe.probing = true + + // Normalize before measuring. apply() reports two different things through + // one bool: an input that changed a value, and a field it had to fill in + // because the config carried none. Only the first is a settings change, so + // the filling-in gets a pass of its own whose verdict is discarded, and the + // pass that answers the caller runs against a config with nothing left to + // fill in. + // + // Readers already hand out normalized configs — readConfig applies an empty + // input for this very reason — so this is normally a no-op. But a gate that + // refuses a request must not depend on where its caller got the config + // from, and it must not start reading "this profile predates a field" as + // "the caller asked for a change" the day someone adds one. + if _, err := probe.apply(ConfigInput{ConfigPath: input.ConfigPath}); err != nil { + return true, fmt.Errorf("normalize the config to diff against: %w", err) + } + + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + + return probe.apply(input) +} + +// newDryRunBaseline builds the config a brand-new profile would start from, for +// a dry run to compare an input against. It is createNewConfig without the +// identity: this config exists only to be compared against and thrown away, and +// no ConfigInput field maps to either key. +func newDryRunBaseline(configPath string) (*Config, error) { + baseline := newConfigSkeleton() + + if _, err := baseline.apply(ConfigInput{ConfigPath: configPath}); err != nil { + return nil, err + } + + return baseline, nil +} + +// clone returns a copy of the config that apply can be run against without the +// original observing the writes, or nil for a nil receiver. Only what apply +// mutates in place needs detaching, which is the slices it replaces or appends +// to: every pointer field it touches is reassigned rather than written through, +// and ClientCertKeyPair is only overwritten. +func (config *Config) clone() *Config { + if config == nil { + return nil + } + + probe := *config + probe.IFaceBlackList = slices.Clone(config.IFaceBlackList) + probe.NATExternalIPs = slices.Clone(config.NATExternalIPs) + probe.DNSLabels = slices.Clone(config.DNSLabels) + return &probe +} + // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { configExists, err := fileExists(input.ConfigPath) @@ -940,6 +1180,14 @@ func UpdateConfig(input ConfigInput) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath) } + // A UI that round-trips the mask GetConfig hands it back is asking to keep + // the stored key, not to set the mask as the new one. UpdateOrCreateConfig + // and DirectUpdateOrCreateConfig already collapse it; this one did not, so + // the same round-trip through SetConfig replaced the key with asterisks. + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + return update(input) } @@ -951,7 +1199,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -976,12 +1224,20 @@ func update(input ConfigInput) (*Config, error) { return nil, err } + // A write path is a provisioning point: a stored profile can legitimately + // carry no identity (a mobile logout clears the keys in place), and the + // next config write is what has to mint a new one. Reads leave that alone. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -990,8 +1246,8 @@ func update(input ConfigInput) (*Config, error) { return config, nil } -// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist -func GetConfig(configPath string) (*Config, error) { +// GetExistingConfig reads and returns the config if it exists on disk. Fails otherwise. +func GetExistingConfig(configPath string) (*Config, error) { return readConfig(configPath, false) } @@ -1074,17 +1330,27 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return newConfig, nil } -// CreateInMemoryConfig generate a new config but do not write out it to the store +// CreateInMemoryConfig generate a new config but do not write out it to the store. +// It carries an identity: callers connect with what they get back. func CreateInMemoryConfig(input ConfigInput) (*Config, error) { - return createNewConfig(input) + return createProvisionedConfig(input) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values -func ReadConfig(configPath string) (*Config, error) { +// ReadConfigOrDefault reads the profile config at configPath, or resolves the +// default config in memory when the file does not exist. It never writes, and +// never mints an identity — EnsureIdentity is where that happens, so the +// caller that provisions is also the one that persists. +func ReadConfigOrDefault(configPath string) (*Config, error) { return readConfig(configPath, true) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values +// readConfig reads the profile config at configPath. createIfMissing resolves a +// default config in memory when the file is absent, rather than erroring. +// +// Reads are pure. This used to write the config back whenever apply() had to +// fill in a default the file was missing, which quietly made every reader a +// writer: a gate deciding whether to refuse a request, a UI listing profiles, +// a mobile getter reading a single preference. func readConfig(configPath string, createIfMissing bool) (*Config, error) { configExists, err := fileExists(configPath) if err != nil { @@ -1102,12 +1368,8 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, err } // initialize through apply() without changes - if changed, err := config.apply(ConfigInput{}); err != nil { + if _, err := config.apply(ConfigInput{}); err != nil { return nil, err - } else if changed { - if err = WriteOutConfig(configPath, config); err != nil { - return nil, err - } } return config, nil @@ -1115,13 +1377,7 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", configPath) } - cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath}) - if err != nil { - return nil, err - } - - err = WriteOutConfig(configPath, cfg) - return cfg, err + return createNewConfig(ConfigInput{ConfigPath: configPath}) } // WriteOutConfig write put the prepared config to the given path @@ -1144,7 +1400,7 @@ func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -1171,12 +1427,18 @@ func directUpdate(input ConfigInput) (*Config, error) { return nil, err } + // Same provisioning point as update(); see the note there. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -1198,7 +1460,16 @@ func ConfigToJSON(config *Config) (string, error) { // ConfigFromJSON deserializes a JSON string to a Config struct. // This is useful for restoring config from alternative storage mechanisms. -// After unmarshaling, defaults are applied to ensure the config is fully initialized. +// After unmarshaling, defaults are applied to ensure the config is fully +// initialized. +// +// The peer identity is deliberately none of its business, in either direction. +// It does not generate one: a read cannot hand back keys that nothing will +// write down (see ReadConfigOrDefault). Nor does it refuse a document that +// carries none, because a config legitimately has no identity between a logout +// and the next login — mobile logout clears both keys in place — and this is +// also the deserializer the iOS SDK copies a config through. Whoever goes on +// to connect is where an absent identity has to be answered. func ConfigFromJSON(jsonStr string) (*Config, error) { config := &Config{} err := json.Unmarshal([]byte(jsonStr), config) diff --git a/client/internal/profilemanager/config_json_test.go b/client/internal/profilemanager/config_json_test.go new file mode 100644 index 000000000..9a6d820c4 --- /dev/null +++ b/client/internal/profilemanager/config_json_test.go @@ -0,0 +1,44 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The serialized form is how the tvOS SDK stores a profile and how the iOS SDK +// copies one in memory, so it must round-trip whatever a profile legitimately +// holds — including no identity at all, which is the state mobile logout leaves +// behind when it clears both keys in place. Refusing that document here broke +// logout, profile switching and the login that follows them. +func TestConfigFromJSONRoundTripsALoggedOutProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "exported.json") + stored, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.NotEmpty(t, stored.PrivateKey, "a provisioned config is the fixture this test starts from") + require.NotEmpty(t, stored.SSHKey) + + exported, err := ConfigToJSON(stored) + require.NoError(t, err) + + restored, err := ConfigFromJSON(exported) + require.NoError(t, err, "a config exported after a login must load") + require.Equal(t, stored.PrivateKey, restored.PrivateKey, "the restored peer is not the stored one") + require.Equal(t, stored.SSHKey, restored.SSHKey) + + // What mobile logout leaves on disk. + loggedOut := stored.clone() + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + + document, err := ConfigToJSON(loggedOut) + require.NoError(t, err) + + reloaded, err := ConfigFromJSON(document) + require.NoError(t, err, "a logged-out profile must still load") + require.Empty(t, reloaded.PrivateKey, "loading must not mint a key nothing will write down") + require.Empty(t, reloaded.SSHKey) + require.Equal(t, stored.ManagementURL.String(), reloaded.ManagementURL.String(), + "the rest of the profile survives the logout") +} diff --git a/client/internal/profilemanager/config_optional_fields_test.go b/client/internal/profilemanager/config_optional_fields_test.go new file mode 100644 index 000000000..9b74e2217 --- /dev/null +++ b/client/internal/profilemanager/config_optional_fields_test.go @@ -0,0 +1,131 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// optionalBoolFields lists the *bool fields of Config by name, derived from the +// type so a field added later is covered without touching these tests. +func optionalBoolFields() []string { + pointerToBool := reflect.TypeOf((*bool)(nil)) + + var fields []string + configType := reflect.TypeOf(Config{}) + for i := range configType.NumField() { + field := configType.Field(i) + if field.Type == pointerToBool && field.Tag.Get("json") != "-" { + fields = append(fields, field.Name) + } + } + return fields +} + +func requireNoUnsetOptionalBool(t *testing.T, config *Config, context string) { + t.Helper() + + value := reflect.ValueOf(*config) + for _, name := range optionalBoolFields() { + require.False(t, value.FieldByName(name).IsNil(), + "%s left %s unset, so its readers have to invent a default and a diff of it compares presence instead of value", context, name) + } +} + +// An optional bool must not be tristate. While one can be nil, true or false, +// every reader has to invent the meaning of nil, and — the reason this test +// exists — a diff of the config ends up comparing presence rather than value: +// that is what made the update-settings gate refuse `netbird up` for a client +// restating its own defaults. apply() is where a config becomes complete, so +// the invariant belongs to it: no *bool may come out of apply() unset. +func TestApplyLeavesNoOptionalBoolUnset(t *testing.T) { + require.NotEmpty(t, optionalBoolFields(), "the invariant is only meaningful while Config has optional bools") + + t.Run("a config built from scratch", func(t *testing.T) { + config := newConfigSkeleton() + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "apply on a new config") + }) + + t.Run("a config file that predates every optional field", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.json") + require.NoError(t, os.WriteFile(path, []byte(`{"WgIface":"wt0"}`), 0o600)) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a legacy config") + }) + + t.Run("a config file that stores them as null", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "null.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a config storing nulls") + }) +} + +// The same invariant on disk: what a write leaves in the file is what the next +// client to read it starts from, so no write may store a null. +func TestNoWriteStoresAnUnsetOptionalBool(t *testing.T) { + requireNoNullOnDisk := func(t *testing.T, path string, context string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, name := range optionalBoolFields() { + value, present := stored[name] + require.True(t, present, "%s did not store %s at all", context, name) + require.NotEqual(t, "null", string(value), "%s stored %s as null", context, name) + } + } + + t.Run("UpdateOrCreateConfig", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "created.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateOrCreateConfig") + }) + + t.Run("UpdateConfig over a config storing nulls", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "stored.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + _, err = UpdateConfig(ConfigInput{ConfigPath: path, ManagementURL: "https://mgmt.example.com"}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateConfig") + }) + + // Renaming used to copy the file back through a bare Unmarshal, which + // preserved the nulls a pre-fix client had written. + t.Run("RenameProfile", func(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + unsetOnDisk(t, created.Path, optionalBoolFields()...) + + require.NoError(t, sm.RenameProfile(created.ID, username, "office")) + + requireNoNullOnDisk(t, created.Path, "RenameProfile") + }) + }) +} diff --git a/client/internal/profilemanager/config_probe_test.go b/client/internal/profilemanager/config_probe_test.go new file mode 100644 index 000000000..35a179a84 --- /dev/null +++ b/client/internal/profilemanager/config_probe_test.go @@ -0,0 +1,96 @@ +package profilemanager + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// writeCertPair writes a throwaway certificate and key, so apply() has +// something real to load rather than a missing file it would only log about. +func writeCertPair(t *testing.T) (certPath, keyPath string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "probe-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + dir := t.TempDir() + certPath = filepath.Join(dir, "client.crt") + keyPath = filepath.Join(dir, "client.key") + require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)) + require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600)) + return certPath, keyPath +} + +// The dry run behind the update-settings gate must not read the mTLS pair off +// disk. The loaded pair feeds the connection, never the comparison, and the +// gate runs it on every SetConfig and Login — twice per request — including the +// ones it refuses. +func TestProbeDoesNotLoadTheCertificatePair(t *testing.T) { + certPath, keyPath := writeCertPair(t) + + t.Run("a real apply loads it", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.NotNil(t, config.ClientCertKeyPair, "the connection would have no client certificate") + }) + + t.Run("a probe does not", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + config.probing = true + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.Nil(t, config.ClientCertKeyPair, "the dry run read the certificate off disk") + }) + + // And the verdict is the same either way, which is the only thing the gate + // asks of the probe. + t.Run("the verdict is unaffected", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: DefaultManagementURL, + ClientCertPath: certPath, + ClientCertKeyPath: keyPath, + }) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := stored.WouldChange(ConfigInput{ClientCertPath: certPath, ClientCertKeyPath: keyPath}) + require.NoError(t, err) + require.False(t, changed, "restating the stored certificate paths is not a change") + + changed, err = stored.WouldChange(ConfigInput{ClientCertPath: filepath.Join(t.TempDir(), "other.crt")}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") + }) +} diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 248920b5e..a461aa71f 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -196,7 +196,7 @@ func TestWireguardPortZeroExplicit(t *testing.T) { assert.Equal(t, 0, config.WgPort, "WgPort should be 0 when explicitly set by user") // Verify it persists - readConfig, err := GetConfig(configPath) + readConfig, err := GetExistingConfig(configPath) require.NoError(t, err) assert.Equal(t, 0, readConfig.WgPort, "WgPort should remain 0 after reading from file") } diff --git a/client/internal/profilemanager/config_would_change_test.go b/client/internal/profilemanager/config_would_change_test.go new file mode 100644 index 000000000..6b140030f --- /dev/null +++ b/client/internal/profilemanager/config_would_change_test.go @@ -0,0 +1,529 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func seededConfig(t *testing.T) *Config { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + PreSharedKey: strPointer("stored-key"), + }) + require.NoError(t, err) + return cfg +} + +func strPointer(s string) *string { return &s } + +func intPtr(i int) *int { return &i } + +func TestWouldChange(t *testing.T) { + tests := []struct { + name string + input ConfigInput + want bool + }{ + {name: "empty input", input: ConfigInput{}, want: false}, + {name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false}, + {name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false}, + {name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true}, + {name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false}, + {name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false}, + {name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true}, + {name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true}, + {name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := seededConfig(t) + + changed, err := cfg.WouldChange(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, changed) + }) + } +} + +// The dry run must not be observable on the config it is run against: it +// decides whether a write is allowed, it does not perform one. +func TestWouldChangeLeavesTheConfigAlone(t *testing.T) { + cfg := seededConfig(t) + blacklist := len(cfg.IFaceBlackList) + + changed, err := cfg.WouldChange(ConfigInput{ + ManagementURL: "https://other.example:443", + PreSharedKey: strPointer("other-key"), + ExtraIFaceBlackList: []string{"nb-probe0"}, + DNSLabels: domain.FromPunycodeList([]string{"probe"}), + NATExternalIPs: []string{"1.2.3.4"}, + }) + require.NoError(t, err) + require.True(t, changed) + + require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String()) + require.Equal(t, "stored-key", cfg.PreSharedKey) + require.Len(t, cfg.IFaceBlackList, blacklist) + require.Empty(t, cfg.DNSLabels) + require.Empty(t, cfg.NATExternalIPs) +} + +// A nil config means the profile holds nothing yet, so the baseline is what +// the daemon would create for it. +func TestWouldChangeWithoutAStoredConfig(t *testing.T) { + var cfg *Config + + changed, err := cfg.WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "a request carrying nothing cannot change anything") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.False(t, changed, "the default management URL is what would be written anyway") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"}) + require.NoError(t, err) + require.True(t, changed) +} + +func TestWouldChangeReportsAnInvalidInput(t *testing.T) { + cfg := seededConfig(t) + + _, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"}) + require.Error(t, err) +} + +// Reads must not write. A config file missing a field apply() fills in (MTU, +// here) is what used to trigger the write-back. +func TestReadsDoNotWriteTheConfigBack(t *testing.T) { + denormalized := []byte(`{"WgIface":"wt0"}`) + + for name, read := range map[string]func(string) (*Config, error){ + "GetExistingConfig": GetExistingConfig, + "ReadConfigOrDefault": ReadConfigOrDefault, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "profile.json") + require.NoError(t, os.WriteFile(path, denormalized, 0o600)) + + cfg, err := read(path) + require.NoError(t, err) + require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory") + require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either") + + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name) + }) + } +} + +// ReadConfigOrDefault resolves a default config for a profile that has no file +// yet, and that must not create the file either. +func TestReadConfigDoesNotCreateTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.json") + + cfg, err := ReadConfigOrDefault(path) + require.NoError(t, err) + require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + _, err = os.Stat(path) + require.True(t, os.IsNotExist(err), "ReadConfigOrDefault created the config file") +} + +// The identity is the one thing a read cannot recompute, so it is provisioned +// on request and its caller persists it. +func TestEnsureIdentity(t *testing.T) { + cfg := newConfigSkeleton() + + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated) + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) + + key := cfg.PrivateKey + generated, err = cfg.EnsureIdentity() + require.NoError(t, err) + require.False(t, generated, "a config that already has an identity keeps it") + require.Equal(t, key, cfg.PrivateKey) +} + +// One endpoint written several ways is one endpoint. A gate that compared +// spellings refused a client restating its own management URL with a trailing +// slash, which is a normal way to write it. +func TestSameServiceURL(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true}, + {a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true}, + {a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true}, + {a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true}, + {a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false}, + {a: "https://mgmt.example.com", b: "https://other.example.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.a+" vs "+tt.b, func(t *testing.T) { + a, err := ParseServiceURL("a", tt.a) + require.NoError(t, err) + b, err := ParseServiceURL("b", tt.b) + require.NoError(t, err) + + require.Equal(t, tt.want, SameServiceURL(a, b)) + require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric") + }) + } +} + +// The same spellings, through the dry run the update-settings gate uses. +func TestWouldChangeIgnoresURLSpelling(t *testing.T) { + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://mgmt.example.com", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + for _, spelling := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com/", + "https://mgmt.example.com:443", + "https://mgmt.example.com:443/", + "https://MGMT.example.com", + } { + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored endpoint written differently", spelling) + } + + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"}) + require.NoError(t, err) + require.True(t, changed, "a different port is a different endpoint") +} + +// The dry-run baseline exists to be compared against and discarded, so it must +// not mint keys — the CLI's login backoff loop would otherwise log a fresh +// "generated new Wireguard key" on every attempt. +func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) { + baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config") + require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config") + + // Everything the comparison actually looks at is still the default config. + require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String()) + require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU) + require.Equal(t, iface.DefaultWgPort, baseline.WgPort) +} + +// A stored profile can carry no identity — a mobile logout clears the keys in +// place — so the next config write has to mint one, which is what keeps the +// following login from dialing management with an empty key. +func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "logged-out.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + // Stand in for the logout, which zeroes the keys and writes the config out. + loggedOut, err := GetExistingConfig(path) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, WriteOutConfig(path, loggedOut)) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity") + require.NotEmpty(t, cfg.SSHKey) + + persisted, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted") +} + +// A config that carries no sync message version must not make the dry run +// panic: the gate runs inside a request handler, where failing closed is the +// worst acceptable outcome. +func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) { + cfg := seededConfig(t) + require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset") + + version := 2 + changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version}) + require.NoError(t, err) + require.True(t, changed) + require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config") +} + +// Restating the certificate paths a config already holds is not a change, for +// the same reason restating any other value is not. +func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(ConfigInput{ + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + require.False(t, changed, "the stored certificate paths were restated") + + changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") +} + +// A read that lands on a missing file must not hand back keys: nothing would +// write them down, so the caller would connect with an identity that changes on +// the next run and registers a second peer. +func TestReadConfigOrDefaultCarriesNoIdentity(t *testing.T) { + cfg, err := ReadConfigOrDefault(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key") + require.Empty(t, cfg.SSHKey, "a read minted an SSH key") + + // So the caller's own EnsureIdentity is the one that reports the work, and + // therefore the one that triggers the write. + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated, "the provisioning caller could not tell it had to persist the identity") +} + +// CreateInMemoryConfig is the opposite contract: its callers connect with what +// they get back, so it does carry an identity. +func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) { + cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"}) + require.NoError(t, err) + + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) +} + +// The admin panel is opened, not dialed, so its path identifies it. Comparing +// it as a bare endpoint left a custom panel URL unable to change. +func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + AdminURL: "https://app.example.com/netbird", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String()) + + // Equivalent spellings of the same panel are still not a change. + for _, same := range []string{ + "https://app.example.com/netbird", + "https://app.example.com:443/netbird", + "https://app.example.com/netbird/", + "https://APP.example.com/netbird", + } { + changed, err := cfg.WouldChange(ConfigInput{AdminURL: same}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored panel written differently", same) + } + + // A different path is a different panel, and it must be persisted. + changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.True(t, changed, "a different panel path is a change") + + updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted") +} + +// unsetOnDisk rewrites the stored config so the named fields carry a JSON null, +// which is how a profile written before apply() resolved them looks on disk. +// It synthesizes that state: no write produces it any more. +func unsetOnDisk(t *testing.T, path string, fields ...string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, field := range fields { + _, present := stored[field] + require.True(t, present, "%s is not a field of the stored config", field) + stored[field] = json.RawMessage("null") + } + + rewritten, err := json.Marshal(stored) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, rewritten, 0600)) +} + +// Seven fields mean "the effective default" when they hold no value, and every +// profile written before apply() resolved them holds them as null. Restating +// that default is asking for no change — and the CLI restates it on every +// `netbird up`, because a flag set through an environment variable is a flag +// pflag reports as Changed. Judging those restatements as changes made the +// update-settings gate refuse `netbird up` outright for a client configured +// through the environment, which is the shape of a Kubernetes deployment. +// +// A login now writes those fields set, so the fixture puts the null state back +// on disk with unsetOnDisk instead of getting it from a login. +func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) { + networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + + tests := []struct { + field string + theDefault ConfigInput + theOtherWay ConfigInput + }{ + {"EnableSSHRoot", + ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}}, + {"EnableSSHSFTP", + ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}}, + {"EnableSSHLocalPortForwarding", + ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}}, + {"EnableSSHRemotePortForwarding", + ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}}, + {"DisableSSHAuth", + ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}}, + {"SSHJWTCacheTTL", + ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}}, + {"NetworkMonitor", + ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "unset.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + unsetOnDisk(t, path, tt.field) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(tt.theDefault) + require.NoError(t, err) + require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field) + + // The gate still has to refuse a request that does ask for something. + changed, err = cfg.WouldChange(tt.theOtherWay) + require.NoError(t, err) + require.True(t, changed, "asking for a non-default %s is a change", tt.field) + }) + } +} + +// The verdict must not depend on where the caller got the config from. Readers +// normalize what they hand out, but apply() signals "I filled in a default" +// through the same bool as "the input changed something", so a config that +// never passed through a read would otherwise report a change for an input +// that asks for nothing. +func TestWouldChangeNormalizesBeforeMeasuring(t *testing.T) { + rawConfig := func(t *testing.T) *Config { + t.Helper() + + cfg := &Config{WgIface: iface.WgInterfaceDefault} + require.Nil(t, cfg.ServerSSHAllowed, "the fixture is only useful while the config is not normalized") + require.Nil(t, cfg.EnableSSHRoot) + require.Empty(t, cfg.IFaceBlackList) + return cfg + } + + changed, err := rawConfig(t).WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "an input carrying nothing cannot change anything") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(false)}) + require.NoError(t, err) + require.False(t, changed, "the default of a field the config never held is not a change") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(true)}) + require.NoError(t, err) + require.True(t, changed, "a non-default value is still a change") +} + +// A zero-padded port addresses the same port. The normalization itself belongs +// to util.ServiceURLPort and is tested there; this asserts that the comparison +// this package hands its callers inherits it. +func TestServiceURLPortIsNormalizedNumerically(t *testing.T) { + padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443") + require.NoError(t, err) + plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443") + require.NoError(t, err) + + require.True(t, SameServiceURL(padded, plain)) +} + +// A list the profile does not have and a list the request empties are the same +// thing: no NAT mappings, no DNS labels. The profile stores an absent list as +// JSON null and reads it back as a nil slice, while `netbird up` sends the +// emptied list — CleanNATExternalIPs / CleanDNSLabels — whenever the matching +// environment variable is set to nothing, which a deployment template does by +// default. Judging nil and empty as different made the gate refuse that start, +// which is the very deadlock this branch exists to remove, on another field. +func TestWouldChangeIgnoresAnEmptiedListThatWasAlreadyAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "lists.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + require.Nil(t, stored.NATExternalIPs, "the fixture is only useful while the stored list is absent") + require.Nil(t, stored.DNSLabels) + + changed, err := stored.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.False(t, changed, "emptying a NAT list the profile never had is not a change") + + changed, err = stored.WouldChange(ConfigInput{DNSLabels: domain.List{}}) + require.NoError(t, err) + require.False(t, changed, "emptying a DNS label list the profile never had is not a change") + + // A list that does hold something still moves when the request empties it. + withEntries, err := UpdateConfig(ConfigInput{ConfigPath: path, NATExternalIPs: []string{"1.2.3.4"}}) + require.NoError(t, err) + require.Equal(t, []string{"1.2.3.4"}, withEntries.NATExternalIPs) + + changed, err = withEntries.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.True(t, changed, "clearing a NAT list that had an entry is a change") +} diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ec287f01a..e58f421fd 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -313,7 +313,11 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err } profPath := filepath.Join(configDir, id.String()+".json") - cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) + // Provisioned, not bare: this config goes straight to disk, and a profile + // file with no identity is one whose first reader has to mint the keys and + // remember to write them back. Before identity generation moved out of + // apply() into EnsureIdentity, createNewConfig produced them here too. + cfg, err := createProvisionedConfig(ConfigInput{ConfigPath: profPath}) if err != nil { return nil, fmt.Errorf("failed to create new config: %w", err) } @@ -330,6 +334,19 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err }, nil } +// RenameProfile changes a profile's display name. It rewrites the whole +// profile file, not just the name: the config is read through the normalizing +// reader, so apply()'s resolved values — the optional booleans, the interface +// blacklist, the DNS route interval — are persisted along with the new name. +// +// That is deliberate. A write that skipped apply() is what left profiles on +// disk carrying null where a value was meant, and made a diff of the config +// compare presence instead of value. Two consequences worth knowing: the +// platform-dependent defaults resolved here are the renaming host's +// (ServerSSHAllowed and the network monitor differ per OS), and a profile +// whose stored name does not survive sanitizeDisplayName now fails to rename +// rather than being rewritten — though apply() rejects such a profile on every +// other read too, so it was already unusable. func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { displayName, err := sanitizeDisplayName(newName) if err != nil { @@ -356,17 +373,17 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e return ErrProfileNotFound } - data, err := os.ReadFile(target.Path) + // Through the reader, not a bare Unmarshal: this was the one write that + // skipped apply(), so it copied back whatever the file held — including an + // optional field left unset, which every other write resolves to its + // default. Renaming a profile is a poor place to leave that behind. + cfg, err := GetExistingConfig(target.Path) if err != nil { - return err - } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return err + return fmt.Errorf("read profile config: %w", err) } cfg.Name = displayName - if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + if err := WriteOutConfig(target.Path, cfg); err != nil { return fmt.Errorf("failed to write profile name: %w", err) } return nil diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go index 5e051b15d..d26ce746a 100644 --- a/client/internal/profilemanager/service_test.go +++ b/client/internal/profilemanager/service_test.go @@ -228,3 +228,27 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) { assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") }) } + +// A profile file is written here and read back by whoever connects with it, so +// it has to carry the peer's identity. While AddProfile used the bare +// constructor, it wrote a config with no keys: the first reader had to mint +// them, and the paths that read without writing — a gate deciding whether to +// refuse a request, the mobile SDKs loading a stored profile — got a config +// that cannot connect. +func TestAddProfileWritesAnIdentity(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + stored, err := GetExistingConfig(created.Path) + require.NoError(t, err) + + require.NotEmpty(t, stored.PrivateKey, "the profile was written without a WireGuard key") + require.NotEmpty(t, stored.SSHKey, "the profile was written without an SSH key") + + // And the identity is the one on disk, not one minted per read. + reread, err := GetExistingConfig(created.Path) + require.NoError(t, err) + require.Equal(t, stored.PrivateKey, reread.PrivateKey) + }) +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 96c747ae4..a6315a4ab 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -130,6 +130,10 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV // SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { + // Parsed only to reject an unreadable document early; the JSON itself is + // what is stored, and every load re-parses it. A document carrying no peer + // identity is readable and accepted: that is a logged-out profile, and the + // login that follows provisions the keys. if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 0dfff620e..6a9a6d3d0 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -379,6 +379,35 @@ func (a *Auth) SetConfigFromJSON(jsonStr string) error { } func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + // A logged-out profile carries no keys: the mobile logout clears them in + // place so the next login registers a new peer instead of resurrecting the + // old one. This is that login, and auth.NewAuth parses the WireGuard key + // before the SSO flow even starts, so an absent identity fails the login on + // key size rather than asking the user to sign in. + // + // Minted on the base config, which is the one GetConfigJSON hands back for + // the caller to store — the overlaid copy below is runtime-only. + generated, err := base.EnsureIdentity() + if err != nil { + return fmt.Errorf("ensure profile identity: %w", err) + } + if generated { + if a.cfgPath != "" { + // Non-atomic, like NewAuth's own write: the tvOS App Group sandbox + // blocks the temp-file-and-rename an atomic write needs. + if err := profilemanager.DirectWriteOutConfig(a.cfgPath, base); err != nil { + return fmt.Errorf("write out profile config: %w", err) + } + } else { + // No file to write to — this is the tvOS path, where the profile + // lives in the caller's own store. It persists the new identity by + // calling GetConfigJSON once the login completes; until then the + // keys exist only here, and a login that never completes leaves + // nothing behind. + log.Infof("provisioned a peer identity for a config with no file on disk") + } + } + overlaid, err := copyConfig(base) if err != nil { return err diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 5297920a3..642f9e160 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -49,7 +49,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -67,7 +67,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -89,7 +89,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -115,7 +115,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -136,7 +136,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -168,7 +168,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/mobile/profile_lifecycle_test.go b/client/mobile/profile_lifecycle_test.go new file mode 100644 index 000000000..9612f550d --- /dev/null +++ b/client/mobile/profile_lifecycle_test.go @@ -0,0 +1,97 @@ +package mobile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// loadAsTheMobileSDKsDo replays what the iOS SDK does with a stored profile: +// read the config, serialize it, and load it back. Client.SetConfigFromJSON +// stores that document for tvOS, Auth.SetConfigFromJSON authenticates with it, +// and copyConfig round-trips a Config through the same pair to take an +// in-memory copy before applying the MDM overlay. +func loadAsTheMobileSDKsDo(t *testing.T, configPath string) *profilemanager.Config { + t.Helper() + + stored, err := profilemanager.GetExistingConfig(configPath) + require.NoError(t, err, "read the stored profile") + + document, err := profilemanager.ConfigToJSON(stored) + require.NoError(t, err, "serialize the stored profile") + + reloaded, err := profilemanager.ConfigFromJSON(document) + require.NoError(t, err, "load the profile back") + return reloaded +} + +// A profile survives the whole round its user puts it through: created, logged +// out, loaded again, and switched away from and back. +// +// Logout is the step that makes this worth asserting. It clears the peer's +// keys in place so the next login registers a new peer rather than bringing +// the old one back, which leaves a profile that legitimately carries no +// identity — and both mobile SDKs go on loading that profile through the +// serialized form. A load that refused it, or a creation that never wrote an +// identity in the first place, breaks logout and profile switching on iOS and +// Android without any of it being visible from the desktop client. +func TestProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + + created, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + // Created: the profile carries the identity it will connect with. + require.NotEmpty(t, privateKeyOf(t, pm, created.ID), "a new profile was written with no identity") + + configPath, err := pm.GetConfigPath(created.ID) + require.NoError(t, err) + + before := loadAsTheMobileSDKsDo(t, configPath) + require.NotEmpty(t, before.PrivateKey) + managementURL := before.ManagementURL.String() + + // Logged out: the identity is gone, on purpose. + require.NoError(t, pm.LogoutProfile(created.ID)) + require.Empty(t, privateKeyOf(t, pm, created.ID), "logout left the peer's key behind") + + // Loaded again: the profile is still readable, and loading it neither + // fails nor mints a key that nothing would write down. + after := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, after.PrivateKey, "loading a logged-out profile minted a key nothing will persist") + assert.Empty(t, after.SSHKey, "loading a logged-out profile minted an SSH key") + assert.Equal(t, managementURL, after.ManagementURL.String(), "the rest of the profile did not survive the logout") + + // Switched away from and back: still the same profile, still loadable. + require.NoError(t, pm.SwitchProfile(created.ID)) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NoError(t, pm.SwitchProfile(created.ID)) + + active, err := pm.GetActiveProfile() + require.NoError(t, err) + assert.Equal(t, created.ID, active.ID, "the profile switched to is not the active one") + + assert.Equal(t, managementURL, loadAsTheMobileSDKsDo(t, configPath).ManagementURL.String(), + "the profile did not survive the round of switches") +} + +// The profile the SDKs fall back to gets the same treatment, since it is the +// one a mobile client without an explicit profile runs on. +func TestDefaultProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + configPath, err := pm.GetConfigPath(profilemanager.DefaultProfileName) + require.NoError(t, err) + require.NotEmpty(t, loadAsTheMobileSDKsDo(t, configPath).PrivateKey) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + + reloaded := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, reloaded.PrivateKey, "loading the logged-out default profile minted a key") + assert.NotNil(t, reloaded.ManagementURL, "the profile lost its management URL") +} diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 348b7253b..ad79d80c0 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -192,7 +192,10 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return fmt.Errorf("profile %q does not exist", id) } - config, err := profilemanager.ReadConfig(configPath) + // The existing-file reader, not the generating one: the check above is not + // atomic with this read, so a profile removed in between would otherwise be + // resolved from the defaults here and recreated by the write below. + config, err := profilemanager.GetExistingConfig(configPath) if err != nil { return fmt.Errorf("read profile config: %w", err) } diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go index de62a8180..17ae3ecad 100644 --- a/client/server/login_gate_test.go +++ b/client/server/login_gate_test.go @@ -93,7 +93,7 @@ func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing. require.NoError(t, err) require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") - stored, err := profilemanager.ReadConfig(targetPath) + stored, err := profilemanager.GetExistingConfig(targetPath) require.NoError(t, err) require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") } diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index 5a2298764..f858c6059 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" ) func TestPersistLoginOverrides(t *testing.T) { @@ -80,10 +81,13 @@ func TestPersistLoginOverrides(t *testing.T) { require.NoError(t, err, "seed config") activeProf := &profilemanager.ActiveProfileState{ID: "default"} - err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) + err = persistLoginOverrides(activeProf, &proto.LoginRequest{ + ManagementUrl: tt.newMgmtURL, + OptionalPreSharedKey: tt.newPSK, + }) require.NoError(t, err, "persistLoginOverrides") - cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(profilemanager.DefaultConfigPath) require.NoError(t, err, "read back config") require.Equal(t, tt.wantMgmtURL, cfg.ManagementURL.String(), "management URL") diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go index 2d84d1b6a..d88801959 100644 --- a/client/server/logout_gate_test.go +++ b/client/server/logout_gate_test.go @@ -129,7 +129,7 @@ func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) { // refused with PermissionDenied. The namesake profile does not, so the // correct path gets as far as dialing its own unreachable management URL. enableSSHOnProfile(t, cfgPath) - running, err := profilemanager.GetConfig(cfgPath) + running, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) s.config = running s.connectClient = newDummyConnectClient(context.Background()) diff --git a/client/server/mdm.go b/client/server/mdm.go index 7a47b2a57..b22c3b0a3 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -180,92 +180,6 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ }) } -// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest -// carries ANY field that would actually mutate the persisted config. -// The CLI builds a SetConfigRequest unconditionally on every -// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain -// `netbird up` produces a request with every field at its zero value; -// the gate must skip such no-op invocations or it would always fire -// even when the user did not pass any --flag. Returns false on a nil -// msg; true when any management/admin URL, PSK, DNS/NAT list+clean -// flag, interface/port/MTU, or any optional bool/duration field is set. -func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - len(msg.ExtraIFaceBlacklist) > 0 || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.DnsRouteInterval != nil || - msg.RosenpassEnabled != nil || - msg.RosenpassPermissive != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.Mtu != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.NetworkMonitor != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - msg.BlockInbound != nil || - msg.DisableIpv6 != nil || - msg.EnableSSHRoot != nil || - msg.EnableSSHSFTP != nil || - msg.EnableSSHLocalPortForwarding != nil || - msg.EnableSSHRemotePortForwarding != nil || - msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - -// loginRequestHasConfigOverrides reports whether the LoginRequest -// carries ANY field that would mutate persisted daemon configuration -// (as opposed to pure-auth fields like setupKey, hostname, hint, -// profileName, username). Used by the Login handler to decide whether -// the `--disable-update-settings` / MDM gates must run: a re-auth that -// changes nothing about the configuration is always allowed. -func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - msg.RosenpassEnabled != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.RosenpassPermissive != nil || - len(msg.ExtraIFaceBlacklist) > 0 || - msg.NetworkMonitor != nil || - msg.DnsRouteInterval != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the // LoginRequest surface. Same value-aware semantics: a field set to the // MDM-enforced value is a no-op echo, not a conflict; only a divergent diff --git a/client/server/provision_identity_test.go b/client/server/provision_identity_test.go new file mode 100644 index 000000000..c944cdb1a --- /dev/null +++ b/client/server/provision_identity_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// The daemon provisions the peer's identity and persists it, because a key that +// stayed in memory would come back different on the next start and register a +// second peer. Provisioning is idempotent: a profile that already has an +// identity keeps the one on disk. +func TestProvisionProfileIdentity(t *testing.T) { + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + }) + + dir := t.TempDir() + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + + activeProf := &profilemanager.ActiveProfileState{ID: "default"} + + t.Run("a profile with no file is provisioned and written", func(t *testing.T) { + _, err := os.Stat(profilemanager.DefaultConfigPath) + require.True(t, os.IsNotExist(err), "the fixture starts without a config file") + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.False(t, existed, "the file was reported as pre-existing") + require.NotEmpty(t, config.PrivateKey) + + stored, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err, "provisioning did not write the config out") + require.Equal(t, config.PrivateKey, stored.PrivateKey, "the persisted identity is not the one returned") + require.NotEmpty(t, stored.SSHKey) + }) + + t.Run("a second call keeps the identity on disk", func(t *testing.T) { + before, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.True(t, existed) + require.Equal(t, before.PrivateKey, config.PrivateKey, "provisioning minted a second identity") + + after, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + require.Equal(t, before.PrivateKey, after.PrivateKey, "provisioning rewrote the stored identity") + }) +} diff --git a/client/server/server.go b/client/server/server.go index 108aa8a41..f7f81b688 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -58,8 +58,13 @@ const ( // JWT token cache TTL for the client daemon (disabled by default) defaultJWTCacheTTL = 0 - errRestoreResidualState = "failed to restore residual state: %v" - errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + errRestoreResidualState = "failed to restore residual state: %v" + errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + // errUpdateSettingsDisabled is returned with codes.FailedPrecondition, not + // codes.Unavailable: the daemon answered, and it refused. Unavailable means + // "the daemon cannot serve this", which is why the CLI downgrades it to a + // warning and the GUI reads it as an unreachable daemon — both wrong for a + // refusal the caller has to act on. errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled" errNetworksDisabled = "network selection is disabled by the administrator" ) @@ -492,16 +497,27 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques s.mutex.Lock() defer s.mutex.Unlock() - // Skip the update-settings gate when the request carries no actual - // overrides: the CLI builds a SetConfigRequest unconditionally on - // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain - // `netbird up` would otherwise always trip the gate and surface a - // misleading "setConfig method is not available" warning, even when - // the user did not pass any config flag. - if setConfigRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) + if err != nil { + return nil, err + } + + config, err := s.setConfigInputFromRequest(msg) + if err != nil { + return nil, err + } + + // Update-settings gate: refuse the request only when it would actually + // change a persisted setting. The CLI builds a SetConfigRequest + // unconditionally on every `netbird up` (setupSetConfigReq in + // cmd/up.go) and fills it from its flags and environment, so a service + // or container that restates the configuration it already runs with + // must pass the gate. Deciding this on field presence alone refused + // those callers, and — through the identical gate in Login — refused + // their login too, which left a client configured by environment + // (NB_MANAGEMENT_URL and friends) unable to come up at all. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, config) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } // MDM gate: refuse the whole request if any of its fields is enforced @@ -513,19 +529,10 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) - if err != nil { - return nil, err - } if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { return nil, err } - config, err := s.setConfigInputFromRequest(msg) - if err != nil { - return nil, err - } - updatedConf, err := profilemanager.UpdateConfig(config) if err != nil { log.Errorf("failed to update profile config: %v", err) @@ -641,37 +648,45 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile // Login uses setup key to prepare configuration for the daemon. func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("failed to get active profile state: %w", err) + } + + // The stored config of the profile this request targets backs all three + // gates below. It is read before anything changes daemon state, so a + // refused login neither switches the profile nor cancels a login already + // in progress, and it is the profile the switch further down would + // activate. + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, err + } + // Config-override gates. LoginRequest carries the same surface as // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles, // ...), so the same protections must apply. Without these the CLI // command `netbird up --management-url=X` (which falls through to // Login when SetConfig is rejected — see cmd/up.go) would silently // bypass `--disable-update-settings` and any MDM policy. - if loginRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } - policy := s.mdmLoader.Load() - if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { - return nil, err - } + // + // The update-settings gate is value-aware, as in SetConfig: it looks at + // what a login would actually persist (loginOverridesInput) and refuses + // only a real divergence from the stored config. A login that restates + // the values already on disk changes nothing, so it must go through — + // that is what keeps a re-login, or a container restart carrying + // NB_MANAGEMENT_URL, working with the kill switch on. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } - activeProf, err := s.profileManager.GetActiveProfileState() - if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + policy := s.mdmLoader.Load() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err } // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry - // the same fields. It runs before anything here changes daemon state, so a - // refused login neither switches the profile nor cancels a login already in - // progress, and it reads the profile the request targets, which is the one the - // switch below would activate. - stored, err := s.storedLoginConfig(activeProf, msg) - if err != nil { - return nil, err - } + // the same fields. if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { return nil, err } @@ -1174,6 +1189,10 @@ func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState // storedConfigAtPath reads a profile config file, yielding nil when it does not // exist yet. +// +// Reading it has no side effect: profilemanager.GetExistingConfig does not +// write, so a request that the gates go on to refuse leaves the profile file as +// it found it. func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -1182,7 +1201,7 @@ func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) return nil, fmt.Errorf("stat profile config: %w", err) } - cfg, err := profilemanager.GetConfig(path) + cfg, err := profilemanager.GetExistingConfig(path) if err != nil { return nil, fmt.Errorf("read profile config: %w", err) } @@ -1485,8 +1504,16 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// getConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist -func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { +// provisionProfileIdentity resolves the active profile's config and puts the +// keys that identify the peer on disk, reporting whether the config file +// already existed. +// +// This is the daemon's provisioning point: the config resolved here is the one +// the peer runs with, so it needs its identity, and that has to reach disk — a +// key that stays in memory would come back different on the next start and +// re-register the peer. Reads themselves are pure, so the write is here, in +// the open, instead of hiding inside the reader. +func provisionProfileIdentity(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { cfgPath, err := activeProf.FilePath() if err != nil { return nil, false, fmt.Errorf("failed to get active profile file path: %w", err) @@ -1497,15 +1524,38 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof log.Infof("active profile config existed: %t, err %v", configExisted, err) - config, err := profilemanager.ReadConfig(cfgPath) + config, err := profilemanager.ReadConfigOrDefault(cfgPath) if err != nil { return nil, false, fmt.Errorf("failed to get config: %w", err) } - // Apply the daemon-owned MDM policy on top of the just-resolved - // Config. profilemanager's apply() initialises the policy to - // empty — the Loader lives outside Config, so this overlay step - // is driven externally here. + generated, err := config.EnsureIdentity() + if err != nil { + return nil, false, fmt.Errorf("ensure profile identity: %w", err) + } + + if generated || !configExisted { + if err := profilemanager.WriteOutConfig(cfgPath, config); err != nil { + return nil, false, fmt.Errorf("write out profile config: %w", err) + } + } + + return config, configExisted, nil +} + +// getConfig resolves the active profile's config, provisions its identity and +// reports whether the config file already existed. +func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { + config, configExisted, err := provisionProfileIdentity(activeProf) + if err != nil { + return nil, false, err + } + + // Apply the daemon-owned MDM policy on top of the just-resolved Config. + // profilemanager's apply() initialises the policy to empty — the Loader + // lives outside Config, so this overlay step is driven externally here. + // After the write above, on purpose: the overlay is runtime-only and + // re-derived on every load, so the file keeps the profile's own values. config.ApplyMDMPolicy(s.mdmLoader.Load()) return config, configExisted, nil @@ -1560,7 +1610,7 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager. cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(cfgPath) + config, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { return fmt.Errorf("profile '%s' not found", profile.ID) } @@ -1579,6 +1629,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil // Privilege gate: deregistering frees this machine's key to be registered // against another management server, which is only restricted while the SSH // server makes that a privilege handover. + // Ahead of the privilege gate on purpose. A profile with no identity was + // never registered — a logout clears the keys in place, so logging the same + // profile out twice lands here — so there is nothing to deregister and + // nothing for the gate to protect: what it guards against is handing this + // machine's registered key to another management server. Behind the gate, + // an unprivileged caller would be refused instead, and for a profile whose + // ServerSSHAllowed is unset that is every caller, since an absent value + // counts as SSH enabled. + if config.PrivateKey == "" { + log.Infof("profile carries no identity, nothing to deregister") + return nil + } + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { return err } @@ -2196,7 +2259,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p cfgPath = profilemanager.DefaultConfigPath } - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) @@ -2659,8 +2722,6 @@ func sendTerminalNotification() error { return wallCmd.Wait() } -// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the -// active profile config so that subsequent reads pick them up. Empty/nil values are ignored. // afterLoginPreCheck is a seam for tests to run a concurrent config change // between Login's first privilege check and the authoritative one. var afterLoginPreCheck func() @@ -2691,6 +2752,15 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, err } + // The update-settings decision is re-taken here for the same reason as the + // privilege one: Login's earlier check ran outside this lock, so the stored + // config it compared against could have moved since. This one is the + // authoritative check, and it is the last read before persistLoginOverrides + // writes. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) + } + s.mutex.Lock() if s.actCancel != nil { s.actCancel() @@ -2717,18 +2787,28 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, fmt.Errorf("active profile state: %w", err) } - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + if err := persistLoginOverrides(activeProf, msg); err != nil { return nil, nil, fmt.Errorf("persist login overrides: %w", err) } + // Provisioning under the same lock as the decision above, and next to the + // write it guards. getConfig would otherwise mint the identity and persist + // it once this returns: between its read and its write, a SetConfig that + // had already answered its caller would be overwritten by the config this + // login read before it landed. + if _, _, err := provisionProfileIdentity(activeProf); err != nil { + return nil, nil, err + } + return ctx, activeProf, nil } -func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { - if preSharedKey != nil && *preSharedKey == "" { - preSharedKey = nil - } - if managementURL == "" && preSharedKey == nil { +// persistLoginOverrides writes the config fields a login request is allowed to +// carry into the active profile. It shares its input builder with the +// update-settings gate, so the gate judges exactly the fields this writes. +func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) error { + input := loginOverridesInput(msg) + if input.ManagementURL == "" && input.PreSharedKey == nil { return nil } @@ -2737,11 +2817,7 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage return fmt.Errorf("active profile file path: %w", err) } - input := profilemanager.ConfigInput{ - ConfigPath: cfgPath, - ManagementURL: managementURL, - PreSharedKey: preSharedKey, - } + input.ConfigPath = cfgPath if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil { return fmt.Errorf("update config: %w", err) } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index a392af6d3..d174dc47b 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -290,7 +290,7 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // Confirm RosenpassEnabled was NOT applied even though it was not // in the conflict list: the request was rejected as a whole. - reloaded, err := profilemanager.GetConfig(cfgPath) + reloaded, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected") } diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7442b718e..d7f7b2bd5 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -125,7 +125,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { cfgPath, err := profState.FilePath() require.NoError(t, err) - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String()) diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 01d24687e..40d66b7a5 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -331,21 +331,5 @@ func sameManagementURL(stored *url.URL, requested string) bool { return false } - return stored.Scheme == parsed.Scheme && - stored.Hostname() == parsed.Hostname() && - effectivePort(stored) == effectivePort(parsed) -} - -func effectivePort(u *url.URL) string { - if port := u.Port(); port != "" { - return port - } - switch u.Scheme { - case "https": - return "443" - case "http": - return "80" - default: - return "" - } + return profilemanager.SameServiceURL(stored, parsed) } diff --git a/client/server/update_settings_gate.go b/client/server/update_settings_gate.go new file mode 100644 index 000000000..b4d32754f --- /dev/null +++ b/client/server/update_settings_gate.go @@ -0,0 +1,55 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// configChangeRequested reports whether applying input would move the target +// profile away from the configuration it already persists. It is the decision +// procedure of the update-settings kill switch (--disable-update-settings / +// NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key): that switch +// forbids *changing* settings, so a request that restates the stored values is +// not a change and must not be refused. +// +// This has to be judged on values, not on field presence. `netbird up` rebuilds +// the whole config surface of SetConfigRequest and LoginRequest from its flags +// and environment on every invocation, so a service or container configured by +// environment restates its own configuration on every start. A presence-based +// gate refused those requests, and because Login carries the same fields it +// refused the login too — leaving such a client unable to come up at all. +// +// A dry run that cannot be evaluated fails closed: the request counts as a +// change, so a malformed field can never open the gate. The error itself is +// reported to the caller by the real update path. +func configChangeRequested(stored *profilemanager.Config, input profilemanager.ConfigInput) bool { + changed, err := stored.WouldChange(input) + if err != nil { + log.Warnf("cannot evaluate the requested config change, treating it as a change: %v", err) + return true + } + return changed +} + +// loginOverridesInput builds the ConfigInput a login request persists. The +// management URL and the pre-shared key are the only config fields the daemon +// applies from a LoginRequest; everything else on that message is either pure +// auth or ignored. An empty pre-shared key is dropped rather than written, so +// a login cannot clear the stored key by omission. +// +// Both the write (persistLoginOverrides) and the update-settings gate go +// through this builder, so the gate can neither refuse a field the write +// ignores nor miss one it applies. +func loginOverridesInput(msg *proto.LoginRequest) profilemanager.ConfigInput { + preSharedKey := msg.OptionalPreSharedKey + if preSharedKey != nil && *preSharedKey == "" { + preSharedKey = nil + } + + return profilemanager.ConfigInput{ + ManagementURL: msg.ManagementUrl, + PreSharedKey: preSharedKey, + } +} diff --git a/client/server/update_settings_gate_test.go b/client/server/update_settings_gate_test.go new file mode 100644 index 000000000..0d2cd8810 --- /dev/null +++ b/client/server/update_settings_gate_test.go @@ -0,0 +1,390 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// The seeded profile of setupServerWithProfile is created with this management +// URL, so a request carrying it restates what the profile already holds. +const storedManagementURL = "https://api.netbird.io:443" + +// A client configured by environment re-sends its whole configuration on every +// `netbird up`: the CLI fills the request from its flags and env regardless of +// what changed. With the update-settings kill switch on, such a request must +// pass — nothing about the configuration moves. +func TestSetConfig_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + }) + require.NoError(t, err, "restating the stored management URL is not a settings change") +} + +// The same endpoint written without its default port is the same endpoint. A +// gate that compared raw strings refused NB_MANAGEMENT_URL=https://host, which +// is how the URL is normally spelled. +func TestSetConfig_EquivalentManagementURLPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://api.netbird.io", + }) + require.NoError(t, err, "an implicit :443 is the same management URL") +} + +// The kill switch still has to do its job: a request that moves a setting is +// refused, and the profile keeps the value it had. +func TestSetConfig_ChangingASettingIsRefused(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused request changed the config anyway") +} + +// A field whose requested value differs from the stored one is a change even +// when the rest of the request restates the configuration. +func TestSetConfig_SingleDivergingFieldIsRefused(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + rosenpass := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + RosenpassEnabled: &rosenpass, + }) + require.Error(t, err, "enabling Rosenpass is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) +} + +// With the switch off, the same diverging request goes through: the gate must +// not leak into a daemon that never enabled it. +func TestSetConfig_ChangeAllowedWhenTheSwitchIsOff(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", cfg.ManagementURL.String()) +} + +// Login carries the same config surface as SetConfig, so it is gated the same +// way: a login that would move a protected setting is refused before it can +// touch daemon state. +func TestLogin_ChangingTheManagementURLIsRefused(t *testing.T) { + s, _, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL through Login is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + // "Refused before it can touch daemon state" is the contract, so check the + // state as well as the error. + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused login moved the management URL") + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(profName), active.ID, "the refused login switched the active profile") +} + +// seedProfileConfig writes a profile config carrying the given management URL +// and pre-shared key into a temp dir, and returns its path. +func seedProfileConfig(t *testing.T, managementURL, preSharedKey string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: path, + ManagementURL: managementURL, + PreSharedKey: &preSharedKey, + }) + require.NoError(t, err, "seed profile config") + return path +} + +// The decision procedure itself, over the fields a login actually persists. +// A login that restates the stored values must not be refused: that is what +// keeps a re-login, or a container restart carrying NB_MANAGEMENT_URL, working +// with the kill switch on. +func TestLoginGateDecision(t *testing.T) { + stored, err := profilemanager.GetExistingConfig(seedProfileConfig(t, storedManagementURL, "stored-key")) + require.NoError(t, err) + + redacted := mdm.PreSharedKeyRedactedSentinel + empty := "" + sameKey := "stored-key" + otherKey := "other-key" + + tests := []struct { + name string + msg *proto.LoginRequest + wantChanged bool + }{ + { + name: "pure auth carries no config", + msg: &proto.LoginRequest{SetupKey: "ABC"}, + wantChanged: false, + }, + { + name: "stored management URL restated", + msg: &proto.LoginRequest{ManagementUrl: storedManagementURL}, + wantChanged: false, + }, + { + name: "stored management URL without its default port", + msg: &proto.LoginRequest{ManagementUrl: "https://api.netbird.io"}, + wantChanged: false, + }, + { + name: "different management URL", + msg: &proto.LoginRequest{ManagementUrl: "https://mgmt.elsewhere.example:443"}, + wantChanged: true, + }, + { + name: "stored pre-shared key restated", + msg: &proto.LoginRequest{OptionalPreSharedKey: &sameKey}, + wantChanged: false, + }, + { + name: "redacted pre-shared key echoed back", + msg: &proto.LoginRequest{OptionalPreSharedKey: &redacted}, + wantChanged: false, + }, + { + name: "empty pre-shared key is not a request to clear it", + msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, + wantChanged: false, + }, + { + name: "different pre-shared key", + msg: &proto.LoginRequest{OptionalPreSharedKey: &otherKey}, + wantChanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantChanged, configChangeRequested(stored, loginOverridesInput(tt.msg))) + }) + } +} + +// A profile with no config on disk yet is judged against the config the daemon +// would create for it, so a first login that asks for the defaults is not a +// change while one that asks for a different management URL is. +func TestGateDecisionWithoutStoredConfig(t *testing.T) { + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{}), + "a request carrying nothing cannot change anything") + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: profilemanager.DefaultManagementURL}), + "asking for the default management URL is what the daemon would write anyway") + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "https://mgmt.elsewhere.example:443"}), + "asking for a non-default management URL is a change") +} + +// A dry run that cannot be evaluated must fail closed, or a malformed field +// would open the gate. +func TestGateDecisionFailsClosedOnAnInvalidRequest(t *testing.T) { + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "not-a-url"}), + "an unevaluable request must count as a change") +} + +// The gate reads the stored config to decide, and reading it must not write it: +// a refused request has to leave the profile file byte-for-byte as it was. +// A config file missing a field the config layer fills in (MTU, here) is what +// makes the normalization write fire. +func TestSetConfig_RefusedRequestLeavesTheConfigFileUntouched(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + require.NoError(t, os.WriteFile(cfgPath, []byte(`{"WgIface":"wt0"}`), 0o600)) + before, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + _, err = s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + after, err := os.ReadFile(cfgPath) + require.NoError(t, err) + require.Equal(t, string(before), string(after), "the refused request rewrote the profile config") +} + +// The container case that the string comparison still broke: the management URL +// supplied through the environment is the stored one, written with a trailing +// slash. +func TestSetConfig_ManagementURLSpellingsPassTheGate(t *testing.T) { + for _, spelling := range []string{ + "https://api.netbird.io", + "https://api.netbird.io/", + "https://api.netbird.io:443/", + "https://API.netbird.io:443", + } { + t.Run(spelling, func(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: spelling, + }) + require.NoError(t, err, "%q is the stored management URL written differently", spelling) + }) + } +} + +// The RPC the whole fix hangs on. Login is retried by the CLI in a backoff +// loop, so a login that restates the stored configuration — which is what a +// container configured by environment sends on every start — must get past the +// gate, or the client never comes up at all. +// +// Past the gate the handler goes on to do real work this test does not stand +// up, so the assertion is only that the refusal did not happen. +func TestLogin_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + // Stand in for the management round trip the handler makes once the gate + // lets it through, so this test exercises the gate and not the network: + // without it the profile's management URL is dialed for real. + s.isLoginRequiredFn = func(context.Context) (bool, error) { return false, nil } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: storedManagementURL, + }) + if err != nil { + require.NotEqual(t, codes.FailedPrecondition, gstatus.Code(err), + "the gate refused a login that changes nothing: %v", err) + require.NotContains(t, err.Error(), "update settings are disabled", + "the gate refused a login that changes nothing: %v", err) + } +} + +// The value-aware decision has the same synchronization problem as the +// privileged-change one: Login's first check runs outside guardedConfigMu, so +// the stored config it compared against can move before the write. A login that +// was a no-op when it was checked must not be written once it has become a +// change. +func TestLogin_ChangeThatAppearsMidRequestIsRefused(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "moved-under-us" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: storedManagementURL, + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a concurrent writer that repoints the profile between the two + // checks, which is the interleaving the lock has to make safe. The login + // restates the URL the profile held when it was checked, so the first check + // sees a no-op and lets it through. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: storedManagementURL, + }) + require.Error(t, err, "the login became a settings change before it was written") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + stored, err := profilemanager.GetExistingConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", stored.ManagementURL.String(), + "the refused login wrote the management URL it was asked for") +} + +// Logging out a profile that was already logged out must not fail: the logout +// clears the keys in place, so the second attempt finds a profile with no +// identity, which was never registered and has nothing to deregister. +func TestLogout_ProfileWithoutAnIdentityIsANoOp(t *testing.T) { + s, _, _, _, cfgPath := setupServerWithProfile(t) + + loggedOut, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, profilemanager.WriteOutConfig(cfgPath, loggedOut)) + + stored, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.NoError(t, s.sendLogoutRequestWithConfig(privilegedTestCtx(), stored), + "logging out an identity-less profile must not fail") + + // And for an unprivileged caller too: the deregistration privilege gate + // guards the handover of a registered key, so with no key there is nothing + // to guard. An unset SSH setting is what arms that gate — sshServerEnabled + // reads an absent value as enabled — so this stands in for every legacy + // profile, where behind the gate the caller would be refused. + stored.ServerSSHAllowed = nil + require.NoError(t, s.sendLogoutRequestWithConfig(userCtx(), stored), + "an unprivileged caller could not log out a profile with nothing to deregister") +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index c39584992..dcce2f908 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." }, + "error.settings_locked": { + "message": "Die Einstellungen können auf diesem Gerät nicht geändert werden: Ein Administrator hat sie gesperrt." + }, + "error.settings_managed_by_mdm": { + "message": "Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden." + }, "error.unknown": { "message": "Vorgang fehlgeschlagen." }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e9ee26de4..94d741b3e 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1815,6 +1815,14 @@ "message": "The NetBird daemon is not responding. Please check that the service is running.", "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." }, + "error.settings_locked": { + "message": "Settings cannot be changed on this device: an administrator has locked them.", + "description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes." + }, + "error.settings_managed_by_mdm": { + "message": "This setting is managed by your organization and cannot be changed.", + "description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system." + }, "error.unknown": { "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 245b5aa5f..979879680 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." }, + "error.settings_locked": { + "message": "La configuración no se puede cambiar en este dispositivo: un administrador la ha bloqueado." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está gestionada por su organización y no se puede cambiar." + }, "error.unknown": { "message": "La operación falló." }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 6da66a643..f9961d864 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." }, + "error.settings_locked": { + "message": "Les paramètres ne peuvent pas être modifiés sur cet appareil : un administrateur les a verrouillés." + }, + "error.settings_managed_by_mdm": { + "message": "Ce paramètre est géré par votre organisation et ne peut pas être modifié." + }, "error.unknown": { "message": "L’opération a échoué." }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 1b4d2fb9d..94dcb578c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." }, + "error.settings_locked": { + "message": "A beállítások ezen az eszközön nem módosíthatók: egy rendszergazda zárolta őket." + }, + "error.settings_managed_by_mdm": { + "message": "Ezt a beállítást a szervezete kezeli, ezért nem módosítható." + }, "error.unknown": { "message": "A művelet meghiúsult." }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 4cee0f842..8c1312535 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." }, + "error.settings_locked": { + "message": "Le impostazioni non possono essere modificate su questo dispositivo: un amministratore le ha bloccate." + }, + "error.settings_managed_by_mdm": { + "message": "Questa impostazione è gestita dalla sua organizzazione e non può essere modificata." + }, "error.unknown": { "message": "Operazione non riuscita." }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 4fc81d283..a3138de6c 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" }, + "error.settings_locked": { + "message": "この端末では設定を変更できません。管理者によってロックされています。" + }, + "error.settings_managed_by_mdm": { + "message": "この設定は組織によって管理されているため、変更できません。" + }, "error.unknown": { "message": "操作に失敗しました。" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index cb4a542d0..a75ae3dfc 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." }, + "error.settings_locked": { + "message": "As configurações não podem ser alteradas neste dispositivo: um administrador bloqueou-as." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuração é gerida pela sua organização e não pode ser alterada." + }, "error.unknown": { "message": "A operação falhou." }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 61ece03b8..e11ed26a4 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." }, + "error.settings_locked": { + "message": "Настройки на этом устройстве изменить нельзя: администратор заблокировал их." + }, + "error.settings_managed_by_mdm": { + "message": "Эта настройка управляется вашей организацией и не может быть изменена." + }, "error.unknown": { "message": "Не удалось выполнить операцию." }, diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index f8fe71562..01d2f4452 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1361,6 +1361,12 @@ "error.daemon_unreachable": { "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба." }, + "error.settings_locked": { + "message": "Налаштування на цьому пристрої змінити неможливо: адміністратор їх заблокував." + }, + "error.settings_managed_by_mdm": { + "message": "Це налаштування керується вашою організацією і не може бути змінене." + }, "error.unknown": { "message": "Помилка операції." }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 126b11851..64725a69f 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" }, + "error.settings_locked": { + "message": "此设备上的设置无法更改:管理员已将其锁定。" + }, + "error.settings_managed_by_mdm": { + "message": "此设置由您的组织管理,无法更改。" + }, "error.unknown": { "message": "操作失败。" }, diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0c6f2f20f..d193e9f02 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -134,8 +134,19 @@ func (c errorClassifier) classify(err error) *ClientError { strings.Contains(lower, "connection refused"), strings.Contains(lower, "context deadline exceeded"): code = "daemon_unreachable" + case strings.Contains(lower, "update settings are disabled"): + code = "settings_locked" + case strings.Contains(lower, "managed by mdm"): + code = "settings_managed_by_mdm" } + // Deliberately no blanket mapping for FailedPrecondition below: the daemon + // returns it for two dozen states that are not settings refusals at all — + // "not logged in", "client is not running", "session can no longer be + // extended" — and this classifier is shared with the session and connection + // services. Only the two refusals the daemon composes are named, by their + // message. + // Fall back to the gRPC status code when the message didn't match a known // substring — the daemon now forwards the innermost code with a clean desc // that no longer contains the English marker text. diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 2f8f3d039..c2a10442f 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -34,6 +34,29 @@ func TestErrorClassifier_Classify(t *testing.T) { require.Equal(t, "session_expired", ce.Code) }) + t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "update settings are disabled, you cannot use this feature without update settings enabled") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "settings_locked", ce.Code) + }) + + t.Run("an MDM-managed field is named as such", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "fields managed by MDM cannot be modified: [managementURL]") + + require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code) + }) + + t.Run("an unrelated FailedPrecondition is not called a refusal", func(t *testing.T) { + // The daemon uses this code for states that are not settings refusals, + // and this classifier is shared with the session and connection + // services, so only the two refusals it composes are named. + require.Equal(t, "unknown", c.classify(gstatus.Error(gcodes.FailedPrecondition, "not logged in")).Code) + }) + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) require.Equal(t, "daemon_unreachable", ce.Code) From f5707c348532a4b15ea39fb53264339f6045932c Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 6 Oct 2026 12:08:11 +0200 Subject: [PATCH 08/70] [client] Add RTL layout support to the desktop UI (#8076) * [client] Add RTL layout support to the desktop UI follows the active language, Radix primitives get the matching dir, physical spacing/positioning uses logical utilities, and directional icons, animations, arrow-key navigation and tooltip sides flip in RTL. * [client] Address RTL review feedback Force LTR with isolation for monospace values, let truncated names take their direction from their content, and keep the profile name field in the UI direction. * [client] Keep translated monospace labels in their own direction The forced-LTR rule for monospace values now skips elements with an explicit dir, translated development labels use dir=auto, and monospace values rendered through TruncatedText opt into LTR. --------- Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com> --- .../src/components/CopyToClipboard.tsx | 6 ++-- .../frontend/src/components/DropdownMenu.tsx | 26 +++++++++------- .../src/components/LanguagePicker.tsx | 10 +++++-- client/ui/frontend/src/components/Tooltip.tsx | 11 ++++++- .../frontend/src/components/TruncatedText.tsx | 11 +++++-- .../frontend/src/components/VerticalTabs.tsx | 11 ++++--- .../src/components/dialog/ConfirmModal.tsx | 6 ++-- .../frontend/src/components/dialog/Dialog.tsx | 2 +- .../components/dialog/DialogDescription.tsx | 6 ++-- .../src/components/dialog/DialogHeading.tsx | 6 ++-- .../empty-state/DaemonOutdatedOverlay.tsx | 4 +-- .../frontend/src/components/inputs/Input.tsx | 19 ++++++------ .../frontend/src/components/inputs/Select.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 8 ++--- .../components/switches/SwitchItemGroup.tsx | 3 ++ .../src/components/switches/ToggleSwitch.tsx | 6 ++-- client/ui/frontend/src/globals.css | 5 ++++ client/ui/frontend/src/hooks/useDirection.ts | 6 ++++ .../ui/frontend/src/layouts/AppRightPanel.tsx | 7 +++-- client/ui/frontend/src/lib/i18n.ts | 1 + .../src/modules/error/ErrorDialog.tsx | 2 +- .../main/MainConnectionStatusSwitch.tsx | 9 +++--- .../src/modules/main/MainExitNodeSwitcher.tsx | 14 ++++++--- .../frontend/src/modules/main/MainHeader.tsx | 20 +++++++++---- .../ui/frontend/src/modules/main/MainPage.tsx | 4 +-- .../src/modules/main/advanced/Navigation.tsx | 13 +++++--- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 19 ++++++++---- .../main/advanced/peers/PeerDetailPanel.tsx | 30 +++++++++++++------ .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 17 +++++++---- .../modules/profiles/ProfileCreationModal.tsx | 5 ++-- .../src/modules/profiles/ProfileDropdown.tsx | 5 +++- .../src/modules/profiles/ProfilesTab.tsx | 2 +- .../src/modules/settings/SettingsAbout.tsx | 4 +-- .../src/modules/settings/SettingsAdvanced.tsx | 2 ++ .../src/modules/settings/SettingsGeneral.tsx | 1 + .../src/modules/settings/SettingsPage.tsx | 3 ++ .../src/modules/settings/SettingsSection.tsx | 2 +- .../settings/SettingsTroubleshooting.tsx | 3 +- .../modules/welcome/WelcomeStepManagement.tsx | 7 +++-- .../src/modules/welcome/WelcomeStepTray.tsx | 4 +-- 42 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 client/ui/frontend/src/hooks/useDirection.ts diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 4af4ecc8f..b3d5a8715 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -80,7 +80,7 @@ export const CopyToClipboard = ({ aria-label={resolvedLabel} aria-live={"polite"} className={cn( - "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", + "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} @@ -97,14 +97,14 @@ export const CopyToClipboard = ({ ) => { + const dir = useDirection(); + return ; +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; @@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef< {children} - + )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; @@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef< - + @@ -170,7 +174,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} @@ -190,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef< ref={ref} className={cn( "px-2 py-1.5 text-sm font-semibold text-nb-gray-200", - inset && "pl-8", + inset && "ps-8", className, )} {...props} @@ -212,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( ); diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index d0a95906f..1e1fdb17b 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { useDirection } from "@/hooks/useDirection"; // No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ @@ -21,6 +22,7 @@ const labelFor = (lang: Language): string => : lang.displayName; export function LanguagePicker() { + const dir = useDirection(); const { t, i18n } = useTranslation(); const [languages, setLanguages] = useState([]); const [open, setOpen] = useState(false); @@ -112,7 +114,7 @@ export function LanguagePicker() { aria-hidden={"true"} className={"shrink-0 text-nb-gray-200"} /> - + {current ? labelFor(current) : "—"} - + diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index d7a85277a..86589e47f 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import * as RTooltip from "@radix-ui/react-tooltip"; import { cn } from "@/lib/cn"; +import { useDirection } from "@/hooks/useDirection"; type Props = { content: ReactNode; @@ -29,6 +30,8 @@ export const Tooltip = ({ contentClassName, closeDelay = 0, }: Props) => { + const dir = useDirection(); + const physicalSide = dir === "rtl" ? mirrorSide(side) : side; const [open, setOpen] = useState(false); const hoveringRef = useRef(false); const closeTimer = useRef | null>(null); @@ -73,7 +76,7 @@ export const Tooltip = ({ ); }; + +function mirrorSide(side: Props["side"]): Props["side"] { + if (side === "left") return "right"; + if (side === "right") return "left"; + return side; +} diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx index 5b2d2160c..1174a8b42 100644 --- a/client/ui/frontend/src/components/TruncatedText.tsx +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -6,9 +6,16 @@ type Props = { className?: string; tooltipContent?: ReactNode; delayDuration?: number; + dir?: "ltr" | "rtl" | "auto"; }; -export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { +export const TruncatedText = ({ + text, + className, + tooltipContent, + delayDuration = 600, + dir = "auto", +}: Props) => { const ref = useRef(null); const [overflowing, setOverflowing] = useState(false); @@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration = }, [text]); const span = ( - + {text} ); diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 306850ee2..bd1912662 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs"; import { type LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const Root = forwardRef>( function VerticalTabsRoot({ className, ...props }, ref) { + const dir = useDirection(); return ( (function VerticalTab return ( ); @@ -46,7 +49,7 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab size={iconSize} aria-hidden={"true"} className={cn( - "ml-2 shrink-0 transition-colors duration-150", + "ms-2 shrink-0 transition-colors duration-150", "text-nb-gray-350 dark:text-nb-gray-400", "group-data-[state=active]:text-nb-gray-100", )} @@ -75,7 +78,7 @@ const Trigger = forwardRef(function VerticalTab {title} {adornment && ( -
+
{adornment}
)} diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 7a8a1c906..e9c33e677 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -54,9 +54,9 @@ export const ConfirmModal = ({ onOpenAutoFocus={(e) => e.preventDefault()} >
-
- {title} - +
+ {title} + {description}
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index c43c04b0b..3d0d47fc2 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -95,7 +95,7 @@ export const Content = forwardRef, Co {showClose && ( = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogDescriptionProps = { diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index b9dda72a9..52eb0c369 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -1,12 +1,12 @@ import { type ReactNode } from "react"; import { cn } from "@/lib/cn"; -type DialogAlign = "left" | "center" | "right"; +type DialogAlign = "start" | "center" | "end"; const alignClass: Record = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogHeadingProps = { diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index e8e7108eb..da04bb4d7 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => { {clientVersion === "development" ? ( {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index eada79a1f..9d82f4dad 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -86,13 +86,13 @@ function buildInputClassName( "file:border-0 file:bg-transparent file:text-sm file:font-medium", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-40", - opts.hasCustomPrefix && "!rounded-l-none !border-l-0", - opts.hasSuffix && "!pr-9", - opts.hasIcon && "!pl-10", + opts.hasCustomPrefix && "!rounded-s-none !border-s-0", + opts.hasSuffix && "!pe-9", + opts.hasIcon && "!ps-10", "border", opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", opts.showStepper && - "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + "!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", opts.className, ); } @@ -107,7 +107,7 @@ function InputAffix({
@@ -138,7 +138,7 @@ function InputSuffixSlot({ return (
@@ -157,7 +157,7 @@ function NumberStepper({
(function Input( showPasswordToggle = false, copy = false, id, + dir, ...props }, ref, @@ -336,7 +337,7 @@ export const Input = forwardRef(function Input( return (
{label && } -
+
{customPrefix && ( ({ className={"shrink-0 text-nb-gray-200"} /> )} - {current?.label ?? "—"} + {current?.label ?? "—"}
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
-
+
{helpText}
-
+
{ const layoutId = useId(); + const dir = useDirection(); const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); return ( { + const dir = useDirection(); + const parallaxX = dir === "rtl" ? 48 : -48; return (
{ function syncDocumentLang() { if (typeof document !== "undefined") { document.documentElement.lang = i18next.language; + document.documentElement.dir = i18next.dir(i18next.language); } } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 4fbb78052..0be13b86e 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -66,7 +66,7 @@ export default function ErrorDialog() { wrap variant={"bright"} className={ - "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left" + "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start" } aria-label={t("common.copy")} > diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 2f4014741..df48524c1 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => { tabIndex={show && fqdn ? 0 : -1} className={cn( "mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300", - "relative left-[0.55rem]", + "relative start-[0.55rem]", show && fqdn ? "opacity-100" : "pointer-events-none opacity-0", )} > @@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole size={14} aria-hidden={"true"} className={cn( - "absolute -right-5 top-1/2 -translate-y-1/2", + "absolute -end-5 top-1/2 -translate-y-1/2", "shrink-0 text-nb-gray-300 transition-colors", "group-hover:text-nb-gray-200", "group-data-[state=open]:text-nb-gray-200", @@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/iprow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 0d6579dd7..3e6281352 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText"; import { useNetworks } from "@/contexts/NetworksContext"; import { useStatus } from "@/contexts/StatusContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const NONE_VALUE = "__none__"; export const MainExitNodeSwitcher = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const { exitNodes, toggleExitNode } = useNetworks(); @@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => { handleSelect(NONE_VALUE)} /> {hasAny &&
} {hasAny && ( - + {exitNodes.map((n) => ( ( tabIndex={0} disabled={disabled} className={cn( - "flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none", + "flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none", "border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920", "transition-colors duration-150", "wails-no-draggable", @@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { value={NONE_VALUE} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} @@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( value={id} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index d79e76cd1..6aa5b7993 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -25,7 +25,8 @@ import { cn } from "@/lib/cn"; import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; -import { isWindows } from "@/lib/platform.ts"; +import { isMacOS, isWindows } from "@/lib/platform.ts"; +import { useDirection } from "@/hooks/useDirection"; const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; @@ -35,6 +36,8 @@ export const MainHeader = () => { const { viewMode, setViewMode } = useViewMode(); const { updateAvailable } = useClientVersion(); const { mdm, features } = useRestrictions(); + const dir = useDirection(); + const pinSettingsRight = isMacOS(); const openSettings = useCallback(() => { setMenuOpen(false); @@ -83,7 +86,7 @@ export const MainHeader = () => { /> { { )} >
-
{profileSlot}
+
{profileSlot}
-
{settingsSlot}
+
+ {settingsSlot} +
); }; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index c05b3a025..1d8f8d1f6 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -55,7 +55,7 @@ const MainBody = () => { > {!features.disableNetworks && ( -
+
)} @@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => { } overlayOpen={selected !== null} - className={"m-5 ml-0"} + className={"m-5 ms-0"} >
{ diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index dc8d7505b..1f0f527d1 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/cn"; import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; import { useStatus } from "@/contexts/StatusContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { useDirection } from "@/hooks/useDirection"; type TabEntry = { value: NavSection; @@ -48,6 +49,10 @@ export const Navigation = () => { requestAnimationFrame(() => tabRefs.current[value]?.focus()); }; + const dir = useDirection(); + const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight"; + const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft"; + const handleKeyDown = (e: KeyboardEvent) => { const enabled = tabs.filter((t) => isConnected || t.value === section); if (enabled.length < 2) return; @@ -55,10 +60,10 @@ export const Navigation = () => { if (currentIndex === -1) return; let nextIndex: number; switch (e.key) { - case "ArrowRight": + case forwardKey: nextIndex = (currentIndex + 1) % enabled.length; break; - case "ArrowLeft": + case backwardKey: nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; break; case "Home": @@ -106,8 +111,8 @@ export const Navigation = () => { "group relative flex flex-1 items-center justify-center", "gap-2.5 px-5 py-3.5", "outline-none transition-all", - isFirst && "rounded-tl-xl", - isLast && "rounded-tr-xl", + isFirst && "rounded-ss-xl", + isLast && "rounded-se-xl", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 0d8c9f4db..3686275f0 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index a13993aa9..da8028155 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; +import { useDirection } from "@/hooks/useDirection"; // Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". const INVALID_PREFIX = "invalid Prefix"; @@ -70,6 +71,7 @@ const buildOverlapMap = ( }; export const Networks = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; @@ -175,7 +177,11 @@ export const Networks = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && ( { { contentClassName={cn( "max-h-72 max-w-[18rem] overflow-auto", "rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935", - "p-2 pr-4", + "p-2 pe-4", )} > {span} @@ -508,7 +515,9 @@ type ToggleProps = { }; const NetworkToggle = ({ checked, mixed }: ToggleProps) => { - const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + const checkedTranslate = checked + ? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]" + : "translate-x-0.5 rtl:-translate-x-0.5"; return ( { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index ee3de6643..e22054c68 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { peerStatusLabelKey } from "./Peers"; +import { useDirection } from "@/hooks/useDirection"; const DEFAULT_TRANSITION: Transition = { duration: 0.32, @@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { t } = useTranslation(); const { selected, setSelected } = usePeerDetail(); const { status, refresh } = useStatus(); + const dir = useDirection(); + const offscreenX = dir === "rtl" ? "-100%" : "100%"; useEffect(() => { if (!selected) return; @@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { setSelected(null); return; } - if (e.key === "ArrowLeft") { + if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; @@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { }; globalThis.addEventListener("keydown", onKey); return () => globalThis.removeEventListener("keydown", onKey); - }, [selected, setSelected]); + }, [selected, setSelected, dir]); const dialogRef = useRef(null); const backButtonRef = useRef(null); @@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { aria-modal={"true"} aria-labelledby={"nb-peer-detail-title"} onKeyDown={onDialogKeyDown} - initial={{ x: "100%" }} + initial={{ x: offscreenX }} animate={{ x: 0 }} - exit={{ x: "100%" }} + exit={{ x: offscreenX }} transition={transition} style={{ willChange: "transform" }} className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")} @@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { "wails-no-draggable", )} > - + {
- + @@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { "inline-flex shrink-0 items-center gap-1 rounded", "bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910", "border border-nb-gray-900", - "py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300", + "py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300", "wails-no-draggable cursor-default outline-none transition-all", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} @@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/resourcerow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && @@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => { const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( ( {label} { {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index f3cb0f139..79a02a471 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; +import { useDirection } from "@/hooks/useDirection"; import { PeerFilters, type StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; @@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => { }; export const Peers = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const [search, setSearch] = useState(""); @@ -135,7 +137,11 @@ export const Peers = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && } @@ -167,6 +173,7 @@ type PeersListProps = { const PeersList = ({ data, scrollParent }: PeersListProps) => { const { setSelected } = usePeerDetail(); + const dir = useDirection(); const virtuosoRef = useRef(null); const rowRefs = useRef>(new Map()); @@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { e.preventDefault(); focusRow(Math.max(index - 1, 0)); break; - case "ArrowRight": + case dir === "rtl" ? "ArrowLeft" : "ArrowRight": e.preventDefault(); setSelected(data[index]); break; @@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { const ctx = useMemo( () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), // eslint-disable-next-line react-hooks/exhaustive-deps - [data, setSelected], + [data, setSelected, dir], ); return ( @@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) return (
diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index 19313ccb3..487080f3d 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
-
+
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {!managedManagementUrl && (
-
+
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {mode === ManagementMode.SelfHosted && ( void; @@ -19,6 +20,7 @@ type ProfileDropdownProps = { const MANAGE_VALUE = "__manage_profiles__"; export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const dir = useDirection(); const { t } = useTranslation(); const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); const [open, setOpen] = useState(false); @@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { {sortedProfiles.length > 0 && ( <> @@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { value={profile.id} onSelect={() => onSelect(profile.id)} className={cn( - "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", showEmail ? "items-start" : "items-center", diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index f2110fda0..509f48061 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -440,7 +440,7 @@ const ProfileRow = ({ {showEmail && }
- + {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -124,7 +124,7 @@ export function SettingsAbout() { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index 37b1932d9..6cb54d124 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -102,6 +102,7 @@ export function SettingsAdvanced() { setValues((v) => ({ ...v, interfaceName: e.target.value }))} @@ -151,6 +152,7 @@ export function SettingsAdvanced() { {t("settings.advanced.psk.help")} setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index bf0db3bec..9fb6e2129 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useDirection } from "@/hooks/useDirection"; const EVENT_SETTINGS_OPEN = "netbird:settings:open"; @@ -43,6 +44,7 @@ const TAB_CONTENT: Record = { }; export const SettingsPage = () => { + const dir = useDirection(); const location = useLocation(); const navState = location.state as { tab?: string } | null; const { mdm, features } = useRestrictions(); @@ -94,6 +96,7 @@ export const SettingsPage = () => { ( <>
-
+
- {showKey && } + {showKey && } {result.path && !showKey && (
- + {t("welcome.management.title")} - + {t("welcome.management.description")}
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
{mode === ManagementMode.SelfHosted && ( -
+
setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 5a8b0d015..8838e5c92 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- + {t(titleKey)} - {t(descriptionKey)} + {t(descriptionKey)}
From a816acd97c416ff2f6b263a2a1a7bd47b2affc11 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:16 +0200 Subject: [PATCH 09/70] [management] fix the concurrency handling within the update message tests (#8000) --- management/server/account_test.go | 87 +- management/server/affected_peers_jwt_test.go | 154 +- .../server/affected_peers_oldstate_test.go | 198 +-- .../affected_peers_router_paths_test.go | 264 ++-- .../server/affected_peers_router_test.go | 508 +++---- management/server/affected_peers_test.go | 1320 +++++++++-------- management/server/affected_peers_user_test.go | 238 ++- management/server/cache/idp.go | 12 +- management/server/dns_test.go | 284 ++-- management/server/group_test.go | 635 ++++---- management/server/nameserver_test.go | 260 ++-- management/server/peer_test.go | 696 +++++---- management/server/policy_test.go | 552 +++---- management/server/posture_checks_test.go | 586 ++++---- management/server/route_test.go | 496 ++++--- management/server/setupkey_test.go | 128 +- management/server/user_test.go | 266 ++-- 17 files changed, 3417 insertions(+), 3267 deletions(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 881ad19d7..6067b6023 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "testing/synctest" "time" "github.com/prometheus/client_golang/prometheus/push" @@ -40,6 +41,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/zones" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" "github.com/netbirdio/netbird/management/internals/server/config" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbAccount "github.com/netbirdio/netbird/management/server/account" @@ -1297,7 +1299,9 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { } func TestAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { - testAccountManager_NetworkUpdates_SaveGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SaveGroup(t) + }) } func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { @@ -1330,6 +1334,8 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1352,7 +1358,9 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePolicy(t) + }) } func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { @@ -1361,13 +1369,7 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) - // Ensure that we do not receive an update message before the policy is deleted - time.Sleep(time.Second) - select { - case <-updMsg: - t.Logf("received addPeer update message before policy deletion") - default: - } + settleAffectedUpdates(updMsg) wg := sync.WaitGroup{} wg.Add(1) @@ -1390,7 +1392,9 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_SavePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SavePolicy(t) + }) } func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { @@ -1410,6 +1414,8 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1443,7 +1449,9 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePeer(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePeer(t) + }) } func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { @@ -1482,6 +1490,8 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1503,7 +1513,9 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { - testAccountManager_NetworkUpdates_DeleteGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeleteGroup(t) + }) } func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { @@ -1550,6 +1562,8 @@ func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { } } + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -3582,6 +3596,9 @@ func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *up nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t)) require.NoError(t, err) + sqliteStore, ok := nmdataStore.Store.(*networkmap_sqlite.SqliteStore) + require.True(t, ok, "network map store is %T, want *networkmap_sqlite.SqliteStore", nmdataStore.Store) + t.Cleanup(func() { assert.NoError(t, sqliteStore.Db.Close()) }) manager, updateManager, err := buildTestManager(t, store, nmdataStore) require.NoError(t, err) @@ -3636,7 +3653,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb Return(nil). AnyTimes() - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // The go-cache janitor only stops via a GC finalizer and would outlive synctest bubbles. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -3648,8 +3666,11 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb if err != nil { return nil, nil, err } + cacheManager := manager.cacheManager + t.Cleanup(func() { assert.NoError(t, cacheManager.Close()) }) proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) + t.Cleanup(proxyGrpcServer.Close) proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) if err != nil { return nil, nil, err @@ -3743,6 +3764,33 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. // when the channel delivers. const peerUpdateTimeout = 5 * time.Second +// peerUpdateSettleTime bounds how far settleAffectedUpdates advances the fake clock. It must exceed +// the account request and peer update buffer intervals. +const peerUpdateSettleTime = time.Second + +// runPeerUpdateTest runs f inside synctest.Test, so the peer update helpers observe every background +// goroutine of the test, and lets the updates still in flight when f returns finish before the bubble +// ends, since the bubble's clock stops with it. +func runPeerUpdateTest(t *testing.T, f func(t *testing.T)) { + synctest.Test(t, func(t *testing.T) { + defer settleAffectedUpdates() + f(t) + }) +} + +// settleAffectedUpdates runs the synctest bubble's fake clock past every update buffer interval until +// all goroutines are blocked, then discards the updates already delivered to chans, so the next +// assertion only observes updates from the action under test. It must be called inside synctest.Test. +func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { + time.Sleep(peerUpdateSettleTime) + synctest.Wait() + for _, ch := range chans { + for len(ch) > 0 { + <-ch + } + } +} + func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { for { select { @@ -3756,6 +3804,19 @@ func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { } } +// step runs f as one named stage of a test inside synctest.Test, which forbids t.Run, and names the +// stage when it fails the test. Stages share t, so a fatal failure ends the remaining stages as well. +func step(t *testing.T, name string, f func(t *testing.T)) { + t.Helper() + failedBefore := t.Failed() + defer func() { + if !failedBefore && t.Failed() { + t.Logf("step %q failed", name) + } + }() + f(t) +} + func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) { t.Helper() select { diff --git a/management/server/affected_peers_jwt_test.go b/management/server/affected_peers_jwt_test.go index 766745cd7..0b0eba1c4 100644 --- a/management/server/affected_peers_jwt_test.go +++ b/management/server/affected_peers_jwt_test.go @@ -87,93 +87,93 @@ func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) // auto-group change updates only the user's peers and the peers linked to the changed // group through policies, instead of fanning out to the whole account. func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.JWTGroupsEnabled = true - account.Settings.JWTGroupsClaimName = "groups" - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.JWTGroupsEnabled = true + account.Settings.JWTGroupsClaimName = "groups" + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"jwt-grp"}, - Destinations: []string{"jwt-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"jwt-grp"}, + Destinations: []string{"jwt-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) - - updUser := updateManager.CreateChannel(ctx, userPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, userPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - userAuth := auth.UserAuth{ - AccountId: accountID, - UserId: userID, - Groups: []string{"jwt-linked"}, - } - - t.Run("adding JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) - - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + }, true) require.NoError(t, err) - assert.Contains(t, user.AutoGroups, "jwt-grp") - }) - t.Run("removing JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + updUser := updateManager.CreateChannel(ctx, userPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, userPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) - userAuth.Groups = nil - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + userAuth := auth.UserAuth{ + AccountId: accountID, + UserId: userID, + Groups: []string{"jwt-linked"}, + } - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "adding JWT group updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updUser, upd2, upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) - require.NoError(t, err) - assert.NotContains(t, user.AutoGroups, "jwt-grp") + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.Contains(t, user.AutoGroups, "jwt-grp") + }) + + step(t, "removing JWT group updates only linked peers", func(t *testing.T) { + drainPeerUpdates(updUser) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) + + userAuth.Groups = nil + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.NotContains(t, user.AutoGroups, "jwt-grp") + }) }) } diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go index bcb78a660..bfc0a51b9 100644 --- a/management/server/affected_peers_oldstate_test.go +++ b/management/server/affected_peers_oldstate_test.go @@ -24,120 +24,124 @@ import ( // detaching one of them must still refresh that group's policy source peers, even // though the post-update resource no longer maps to it. func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A second resource group + a second source group/peer that reaches the - // resource only through that second group. - const detachGroupID = "rs-detach-grp" - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) + // A second resource group + a second source group/peer that reaches the + // resource only through that second group. + const detachGroupID = "rs-detach-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID, detachGroupID}, - Enabled: true, + // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID, detachGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + // Policy granting the second source group access via the detach group. + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) + require.NoError(t, err) + + secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) + settleAffectedUpdates(secondSrcCh) + + done := make(chan struct{}) + go func() { + // Detaching the resource from detachGroup removes the second source's + // access; that source peer must be refreshed even though the post-update + // resource no longer maps to detachGroup. + peerShouldReceiveUpdate(t, secondSrcCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, // detached detachGroup + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") + } }) - require.NoError(t, err) - - // Policy granting the second source group access via the detach group. - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) - require.NoError(t, err) - - secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) - settleAffectedUpdates(secondSrcCh) - - done := make(chan struct{}) - go func() { - // Detaching the resource from detachGroup removes the second source's - // access; that source peer must be refreshed even though the post-update - // resource no longer maps to detachGroup. - peerShouldReceiveUpdate(t, secondSrcCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, // detached detachGroup - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") - } } // TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer: // changing router.Peer within the same network must still refresh the OLD routing // peer, which loses its routing role. func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - _, routersManager, _ := s.managers() + _, routersManager, _ := s.managers() - routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) - require.NoError(t, err) - require.Len(t, routers, 1) - router := routers[0] - oldRoutingPeer := router.Peer - require.NotEmpty(t, oldRoutingPeer) + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + router := routers[0] + oldRoutingPeer := router.Peer + require.NotEmpty(t, oldRoutingPeer) - // A new peer to become the routing peer in place of the old one. - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + // A new peer to become the routing peer in place of the old one. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) - settleAffectedUpdates(oldCh) + oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) + settleAffectedUpdates(oldCh) - done := make(chan struct{}) - go func() { - // The old routing peer stops serving the resource and must be refreshed. - peerShouldReceiveUpdate(t, oldCh) - close(done) - }() + done := make(chan struct{}) + go func() { + // The old routing peer stops serving the resource and must be refreshed. + peerShouldReceiveUpdate(t, oldCh) + close(done) + }() - _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ - ID: router.ID, - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: newRoutingPeer.ID, // repoint within the same network - Masquerade: true, - Metric: 9999, - Enabled: true, + _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ + ID: router.ID, + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: newRoutingPeer.ID, // repoint within the same network + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") + } }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") - } } diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go index d5868a5c1..185c03ddb 100644 --- a/management/server/affected_peers_router_paths_test.go +++ b/management/server/affected_peers_router_paths_test.go @@ -165,148 +165,154 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context } func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + ID: checkID, + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, + }, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: editing a posture check did not refresh source + routing peers") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ - ID: checkID, - Name: "rs-min-version", - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, - }, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: editing a posture check did not refresh source + routing peers") - } } func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") - } } // A disabled sibling router routes to nobody, so updating a resource on its network // must NOT refresh its peer (the enabled router carries the bridge instead). func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - resourcesManager, routersManager, _ := s.managers() + resourcesManager, routersManager, _ := s.managers() - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: disabledRouterPeer.ID, - Masquerade: true, - Metric: 9000, - Enabled: false, + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: disabledRouterPeer.ID, + Masquerade: true, + Metric: 9000, + Enabled: false, + }) + require.NoError(t, err) + + disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(disabledCh, enabledCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } }) - require.NoError(t, err) - - disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) - enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - }) - - settleAffectedUpdates(disabledCh, enabledCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, enabledCh) - peerShouldNotReceiveUpdate(t, disabledCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } } func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) { @@ -346,8 +352,10 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) // shortcut (the denied peer's map holds no router) and the allow direction // depends on which meta field moved, leaving the routers with a stale map. func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + }) }) } @@ -355,8 +363,10 @@ func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { // scenario with the source peer named directly in the rule: it must receive its posture // checks and have its flips detected exactly like a group member. func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + }) }) } diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go index 7e3f02b27..e2bdff4ad 100644 --- a/management/server/affected_peers_router_test.go +++ b/management/server/affected_peers_router_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/groups" @@ -320,190 +319,189 @@ func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) { } func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A pre-existing policy grants the source group access to the resource. - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + // A pre-existing policy grants the source group access to the resource. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - // Drive an update through the resource manager and assert the routing peer - // is among the affected set by observing the channel. This path walks - // policies whose destinations reference the resource's groups, folds in the - // source groups, and loads the network's routers, so it reaches both the - // source peer and the routing peer. - permissionsManager := permissions.NewManager(s.manager.Store) - groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) - rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + // Drive an update through the resource manager and assert the routing peer + // is among the affected set by observing the channel. This path walks + // policies whose destinations reference the resource's groups, folds in the + // source groups, and loads the network's routers, so it reaches both the + // source peer and the routing peer. + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh source peer + routing peer") + } }) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: resource update did not refresh source peer + routing peer") - } -} - -// settleAffectedUpdates waits for in-flight async updates to arrive, then drains -// every given channel so subsequent assertions start from a clean slate. -// -// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers -// goroutines; draining first means the assertion only observes updates from the -// action under test, not setup stragglers. -func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { - time.Sleep(300 * time.Millisecond) - for _, ch := range chans { - drainPeerUpdates(ch) - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: creating peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: creating peer->resource policy did not refresh the routing peer") - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") - } } func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") - } } func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") - } } func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) { @@ -572,113 +570,119 @@ func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTo } func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) { - s := setupRouterScenario(t, true) - second := s.addSecondTopology(t, "b") - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "b") + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, second.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, second.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerACh, routerBCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerACh) + peerShouldReceiveUpdate(t, routerBCh) + close(done) + }() + + policy.Rules[0].Destinations = []string{second.resourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") + } }) - - settleAffectedUpdates(srcCh, routerACh, routerBCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerACh) - peerShouldReceiveUpdate(t, routerBCh) - close(done) - }() - - policy.Rules[0].Destinations = []string{second.resourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") - } } func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(newSrcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, newSrcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") + } }) - - settleAffectedUpdates(newSrcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, newSrcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") - } } func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") - } } func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index 235128693..90408f70a 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -955,73 +955,77 @@ func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { } func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, - {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, - {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ap-grpA"}, - Destinations: []string{"ap-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) - assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) - - t.Run("group change updates all peers in policy groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "ap-grpA", - Name: "AP-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, + {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, + {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ap-grpA"}, + Destinations: []string{"ap-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) + assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "group change updates all peers in policy groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "ap-grpA", + Name: "AP-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1037,243 +1041,259 @@ func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) { // TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a // policy only sends updates to peers in the policy's groups, not to unrelated peers. func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, - {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, - {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"pol-grpA"}, - Destinations: []string{"pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, + {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, + {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"pol-grpA"}, + Destinations: []string{"pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route // only sends updates to peers in the route's groups, not to unrelated peers. func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, - {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, - {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.10.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"rt-grpA"}, - "test route", - "routenoaffect", - false, - 9999, - []string{"rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, + {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, + {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.10.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"rt-grpA"}, + "test route", + "routenoaffect", + false, + 9999, + []string{"rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a // nameserver group only sends updates to peers in its groups, not to unrelated peers. func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, - {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"ns-grpA"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, + {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"ns-grpA"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS // settings only sends updates to peers in the affected groups, not to unrelated peers. func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, - {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("dns settings change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"dns-grpA"}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, + {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "dns settings change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"dns-grpA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1281,429 +1301,451 @@ func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { // updating a group that is NOT referenced by any policy/route/ns/dns should not send // updates to any peer. func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "unlinked-grp", - Name: "Unlinked", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("updating unlinked group sends no peer updates", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ ID: "unlinked-grp", Name: "Unlinked", - Peers: []string{peer1.ID, peer2.ID}, + Peers: []string{peer1.ID}, }) - assert.NoError(t, err) + require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "updating unlinked group sends no peer updates", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer // groups updates only those groups' peers (and resource policy sources), not others. func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { - // Delete the default policy before adding peers so AddPeer schedules no async - // update that races with the test. - manager, updateManager, err := createManager(t) - require.NoError(t, err) - - ctx := context.Background() - - account, err := createAccount(manager, "nr_test_account", userID, "") - require.NoError(t, err) - accountID := account.Id - - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + runPeerUpdateTest(t, func(t *testing.T) { + // Delete the default policy before adding peers so AddPeer schedules no async + // update that races with the test. + manager, updateManager, err := createManager(t) require.NoError(t, err) - } - setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) + ctx := context.Background() - peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) - - for _, g := range []*types.Group{ - {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, - {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) + account, err := createAccount(manager, "nr_test_account", userID, "") require.NoError(t, err) - } + accountID := account.Id - net1 := &networkTypes.Network{ - ID: "nr-net-test", - AccountID: accountID, - Name: "nr-test-network", - } - err = manager.Store.SaveNetwork(ctx, net1) - require.NoError(t, err) - - err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ - ID: "nr-router-test", - NetworkID: net1.ID, - AccountID: accountID, - PeerGroups: []string{"nr-grpA"}, - Enabled: true, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("network router group change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "nr-grpA", - Name: "NR-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) + + for _, g := range []*types.Group{ + {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, + {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + net1 := &networkTypes.Network{ + ID: "nr-net-test", + AccountID: accountID, + Name: "nr-test-network", + } + err = manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "nr-router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{"nr-grpA"}, + Enabled: true, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "network router group change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "nr-grpA", + Name: "NR-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2) // and a separate route (peer3), changing one entity's groups affects only its peers. func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, - {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, - {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"iso-grpA"}, - Destinations: []string{"iso-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - _, err = manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.20.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"iso-grpC"}, - "isolated route", - "isonet2", - false, - 9999, - []string{"iso-grpC"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - // The setup policy/route above dispatch affected-peer updates asynchronously; - // drain any in-flight ones so the assertions only observe the UpdateGroup below. - settleAffectedUpdates(updMsg1, updMsg2, updMsg3) - - t.Run("policy group change does not affect route-only peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "iso-grpA", - Name: "ISO-A-updated", - Peers: []string{peer1.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, + {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, + {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"iso-grpA"}, + Destinations: []string{"iso-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.20.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"iso-grpC"}, + "isolated route", + "isonet2", + false, + 9999, + []string{"iso-grpC"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup policy/route above dispatch affected-peer updates asynchronously; + // drain any in-flight ones so the assertions only observe the UpdateGroup below. + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "policy group change does not affect route-only peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "iso-grpA", + Name: "ISO-A-updated", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route // only sends updates to peers in the route's groups. func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, - {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - newRoute, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.30.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"del-rt-grpA"}, - "deletable route", - "delnet", - false, - 9999, - []string{"del-rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, + {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + newRoute, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.30.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"del-rt-grpA"}, + "deletable route", + "delnet", + false, + 9999, + []string{"del-rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy // only sends updates to peers in the policy's groups. func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, - {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"del-pol-grpA"}, - Destinations: []string{"del-pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, + {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"del-pol-grpA"}, + Destinations: []string{"del-pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a // nameserver group only sends updates to peers in its groups. func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "del-ns-grpA", - Name: "Del-NS-A", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"del-ns-grpA"}, - true, nil, true, userID, false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "del-ns-grpA", + Name: "Del-NS-A", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"del-ns-grpA"}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } diff --git a/management/server/affected_peers_user_test.go b/management/server/affected_peers_user_test.go index 3d73bbed0..3f4658de4 100644 --- a/management/server/affected_peers_user_test.go +++ b/management/server/affected_peers_user_test.go @@ -17,156 +17,152 @@ import ( // A user update refreshes only the peers its auto-group change reaches, and a user // update that changes no group membership refreshes nobody. func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - const targetUserID = "target-user" - require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - })) + const targetUserID = "target-user" + require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + })) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) + }, true) + require.NoError(t, err) - updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, targetPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("auto group change updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, + updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, targetPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) }) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "auto group change updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updTarget, upd2, upd3) - t.Run("update without group changes refreshes nobody", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, + }) + require.NoError(t, err) - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, Name: "renamed", + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updTarget) - peerShouldNotReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "update without group changes refreshes nobody", func(t *testing.T) { + drainPeerUpdates(updTarget) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - assert.Equal(t, "renamed", user.Name) - }) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, Name: "renamed", + }) + require.NoError(t, err) - t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"})) - // Apply through the settings API so the reconciliation that strips the other + peerShouldNotReceiveUpdate(t, updTarget) + peerShouldNotReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + assert.Equal(t, "renamed", user.Name) + }) + + step(t, "auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { + + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))// Apply through the settings API so the reconciliation that strips the other // peers' addresses happens here, leaving the target as the only peer the // user update reassigns. updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { s.IPv6EnabledGroups = []string{"ug-v6"} }) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + settleAffectedUpdates(updTarget, upd2, upd3) - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) + + // The reassigned peer refreshes with everyone it can reach: peer2 via the + // policy, but not peer3, which shares no group or policy with it. + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - // The reassigned peer refreshes with everyone it can reach: peer2 via the - // policy, but not peer3, which shares no group or policy with it. - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { + // An SSH rule that authorizes no group of its own ships the account's + // allowed-user set to its destinations, so those are the peers an unblock + // reaches — not the whole account. + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + Action: types.PolicyTrafficActionAccept, + }}, + }, true) + require.NoError(t, err) - t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { - // An SSH rule that authorizes no group of its own ships the account's - // allowed-user set to its destinations, so those are the peers an unblock - // reaches — not the whole account. - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{{ - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Protocol: types.PolicyRuleProtocolNetbirdSSH, - Action: types.PolicyTrafficActionAccept, - }}, - }, true) - require.NoError(t, err) + blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + blocked.Blocked = true + require.NoError(t, manager.Store.SaveUser(ctx, blocked)) - blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - blocked.Blocked = true - require.NoError(t, manager.Store.SaveUser(ctx, blocked)) + settleAffectedUpdates(updTarget, upd2, upd3) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + // Same auto-groups as the previous subtest left them, so no group change and + // no IPv6 reconciliation interferes: the unblock alone drives the refresh. + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) - // Same auto-groups as the previous subtest left them, so no group change and - // no IPv6 reconciliation interferes: the unblock alone drives the refresh. - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) }) } diff --git a/management/server/cache/idp.go b/management/server/cache/idp.go index 6ec42e217..ec2684d6f 100644 --- a/management/server/cache/idp.go +++ b/management/server/cache/idp.go @@ -88,7 +88,8 @@ func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl { // AccountUserDataCache wraps the basic Get, Set and Delete methods for []*idp.UserData objects. type AccountUserDataCache struct { - cache Marshaler + cache Marshaler + loadable *cache.LoadableCache[any] } func (a *AccountUserDataCache) Get(ctx context.Context, key string) ([]*idp.UserData, error) { @@ -127,13 +128,18 @@ func (a *AccountUserDataCache) Delete(ctx context.Context, key string) error { return a.cache.Delete(ctx, key) } +// Close stops the goroutine that stores loaded values. The cache must not be used afterwards. +func (a *AccountUserDataCache) Close() error { + return a.loadable.Close() +} + // NewAccountUserDataCache creates a new AccountUserDataCache object. func NewAccountUserDataCache(loadableFunc cache.LoadFunction[any], store store.StoreInterface) *AccountUserDataCache { simpleCache := cache.New[any](store) loadable := cache.NewLoadable[any](loadableFunc, simpleCache) if store.GetType() == redis.RedisType { m := marshaler.New(loadable) - return &AccountUserDataCache{cache: m} + return &AccountUserDataCache{cache: m, loadable: loadable} } - return &AccountUserDataCache{cache: &marshalerWraper{loadable}} + return &AccountUserDataCache{cache: &marshalerWraper{loadable}, loadable: loadable} } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 25bef664c..d21864cbb 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -363,163 +363,167 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account } func TestDNSAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates - t.Run("saving dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have no peers should not update account peers or send peer update - t.Run("creating dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have peers should update account peers and send peer update - t.Run("creating dns setting with used groups", func(t *testing.T) { - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates + step(t, "saving dns setting with unused groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) - // Saving DNS settings with groups that have peers should update account peers and send peer update - t.Run("saving dns setting with used groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA", "groupB"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Creating DNS settings with groups that have no peers should not update account peers or send peer update + step(t, "creating dns setting with unused groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates - t.Run("removing group with no peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Creating DNS settings with groups that have peers should update account peers and send peer update + step(t, "creating dns setting with used groups", func(t *testing.T) { + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) - // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates - t.Run("removing group with peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{}, + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Saving DNS settings with groups that have peers should update account peers and send peer update + step(t, "saving dns setting with used groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA", "groupB"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates + step(t, "removing group with no peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates + step(t, "removing group with peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/group_test.go b/management/server/group_test.go index fa351a43e..f0ccd385c 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/networks/routers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -685,342 +686,368 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t } func TestGroupAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{}, - }, - { - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving a group that is not linked to any resource should not update account peers - t.Run("saving unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding a peer to a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Removing a peer from a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("removing peer from unliked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting group should not update account peers and not send peer update - t.Run("deleting group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // adding a group to policy - _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{}, + }, + { + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID}, }, - }, - }, true) - assert.NoError(t, err) - - // Saving a group linked to policy should update account peers and send peer update - t.Run("saving linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // adding peer to a used group should update account peers and send peer update - t.Run("adding peer to linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // removing peer from a linked group should update account peers and send peer update - t.Run("removing peer from linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + // Saving a group that is not linked to any resource should not update account peers + step(t, "saving unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding a peer to a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing a peer from a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "removing peer from unliked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting group should not update account peers and not send peer update + step(t, "deleting group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // adding a group to policy + _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to policy should update account peers and send peer update + step(t, "saving linked group to policy", func(t *testing.T) { + settleAffectedUpdates(updMsg) - // Saving a group linked to name server group should update account peers and send peer update - t.Run("saving group linked to name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // adding peer to a used group should update account peers and send peer update + step(t, "adding peer to linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to route should update account peers and send peer update - t.Run("saving group linked to route", func(t *testing.T) { - newRoute := route.Route{ - ID: "route", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, - ) - require.NoError(t, err) + err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // removing peer from a linked group should update account peers and send peer update + step(t, "removing peer from linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to dns settings should update account peers and send peer update - t.Run("saving group linked to dns settings", func(t *testing.T) { - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupD"}, + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to name server group should update account peers and send peer update + step(t, "saving group linked to name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID}, + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to route should update account peers and send peer update + step(t, "saving group linked to route", func(t *testing.T) { + newRoute := route.Route{ + ID: "route", + Network: netip.MustParsePrefix("192.168.0.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, + ) + require.NoError(t, err) - // Saving a group linked to network router should update account peers and send peer update - t.Run("saving group linked to network router", func(t *testing.T) { - permissionsManager := permissions.NewManager(manager.Store) - groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) - resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) - routersManager := routers.NewManager(manager.Store, permissionsManager, manager) - networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) + settleAffectedUpdates(updMsg) - network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ - ID: "network_test", - AccountID: account.Id, - Name: "network_test", - Description: "", + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ - ID: "router_test", - NetworkID: network.ID, - AccountID: account.Id, - PeerGroups: []string{"groupE"}, - Masquerade: true, - Metric: 9999, - Enabled: true, + // Saving a group linked to dns settings should update account peers and send peer update + step(t, "saving group linked to dns settings", func(t *testing.T) { + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupD"}, + }) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to network router should update account peers and send peer update + step(t, "saving group linked to network router", func(t *testing.T) { + permissionsManager := permissions.NewManager(manager.Store) + groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) + routersManager := routers.NewManager(manager.Store, permissionsManager, manager) + networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID, peer3.ID}, + network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ + ID: "network_test", + AccountID: account.Id, + Name: "network_test", + Description: "", + }) + require.NoError(t, err) + + _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ + ID: "router_test", + NetworkID: network.ID, + AccountID: account.Id, + PeerGroups: []string{"groupE"}, + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(context.Background(), userID, &resourceTypes.NetworkResource{ + AccountID: account.Id, + NetworkID: network.ID, + Name: "resource_test", + Address: "10.20.30.0/24", + Enabled: true, + }) + require.NoError(t, err) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, peerToResourcePolicyByResource("groupA", resource.ID), true) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } }) } diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index deed9c34f..1460893cf 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -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" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -966,147 +966,151 @@ func TestValidateDomain(t *testing.T) { } func TestNameServerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - var newNameServerGroupA *nbdns.NameServerGroup - var newNameServerGroupB *nbdns.NameServerGroup + var newNameServerGroupA *nbdns.NameServerGroup + var newNameServerGroupB *nbdns.NameServerGroup - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }) - assert.NoError(t, err) - - err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a nameserver group with a distribution group no peers should not update account peers - // and not send peer update - t.Run("creating nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - newNameServerGroupA, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // saving a nameserver group with a distribution group with no peers should not update account peers - // and not send peer update - t.Run("saving nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Creating a nameserver group with a distribution group no peers should update account peers and send peer update - t.Run("creating nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a nameserver group with a distribution group no peers should not update account peers + // and not send peer update + step(t, "creating nameserver group with distribution group no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) - newNameServerGroupB, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + newNameServerGroupA, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - // saving a nameserver group with a distribution group with peers should update account peers and send peer update - t.Run("saving nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - newNameServerGroupB.NameServers = []nbdns.NameServer{ - { - IP: netip.MustParseAddr("1.1.1.2"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - } - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) - assert.NoError(t, err) + // saving a nameserver group with a distribution group with no peers should not update account peers + // and not send peer update + step(t, "saving nameserver group with distribution group no peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + assert.NoError(t, err) - // Deleting a nameserver group should update account peers and send peer update - t.Run("deleting nameserver group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) - assert.NoError(t, err) + // Creating a nameserver group with a distribution group no peers should update account peers and send peer update + step(t, "creating nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + newNameServerGroupB, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // saving a nameserver group with a distribution group with peers should update account peers and send peer update + step(t, "saving nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + newNameServerGroupB.NameServers = []nbdns.NameServer{ + { + IP: netip.MustParseAddr("1.1.1.2"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + } + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting a nameserver group should update account peers and send peer update + step(t, "deleting nameserver group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5c3e02af5..5307300d6 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1844,374 +1844,336 @@ func Test_LoginPeer(t *testing.T) { } func TestPeerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) - require.NoError(t, err) - - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err) - } - - // create a user with auto groups - _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ - { - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupA"}, - }, - { - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupB"}, - }, - { - Id: "regularUser3", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupC"}, - }, - }, true) - require.NoError(t, err) - - var peer4 *nbpeer.Peer - var peer5 *nbpeer.Peer - var peer6 *nbpeer.Peer - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Updating not expired peer and peer expiration is enabled should not update account peers and not send peer update - t.Run("updating not expired peer and peer expiration is enabled", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.UpdatePeer(context.Background(), account.Id, userID, peer2) + err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, } - }) - - // Adding peer to unlinked group should not update account peers and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting peer with unlinked group should not update account peers and not send peer update - t.Run("deleting peer with unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating peer label should update account peers and send peer update - t.Run("updating peer label", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - peer1.Name = "peer-1" - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires update", func(t *testing.T) { - requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, true, nil + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err) } - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires no update", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - - requireNoUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, false, nil - } - - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireNoUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding peer to group linked with policy should update account peers and send peer update - t.Run("adding peer to group linked with policy", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + // create a user with auto groups + _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ + { + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupA"}, + }, + { + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupB"}, + }, + { + Id: "regularUser3", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupC"}, }, }, true) require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + var peer4 *nbpeer.Peer + var peer5 *nbpeer.Peer + var peer6 *nbpeer.Peer - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + // Adding peer to unlinked group should not update account peers and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - // Deleting peer with linked group to policy should update account peers and send peer update - t.Run("deleting peer with linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Deleting peer with unlinked group should not update account peers and not send peer update + step(t, "deleting peer with unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - // Adding peer to group linked with route should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with route", func(t *testing.T) { - route := nbroute.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), - NetID: "superNet", - NetworkType: nbroute.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupB"}, - } + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) + // Updating peer label should update account peers and send peer update + step(t, "updating peer label", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + peer1.Name = "peer-1" + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - expectedPeerKey := key.PublicKey().String() - peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + step(t, "validator requires update", func(t *testing.T) { + requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { + return update, true, nil + } - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to route should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Adding peer to group linked with policy should update account peers and send peer update + step(t, "adding peer to group linked with policy", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) - // Adding peer to group linked with name server group should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, []string{}, true, userID, false, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - expectedPeerKey := key.PublicKey().String() - peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Deleting peer with linked group to policy should update account peers and send peer update + step(t, "deleting peer with linked group to policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + + // Adding peer to group linked with route should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with route", func(t *testing.T) { + route := nbroute.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: nbroute.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupB"}, + } + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to route should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding peer to group linked with name server group should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, []string{}, true, userID, false, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to name server group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } @@ -2859,45 +2821,47 @@ func TestPeerWillHaveIPv6(t *testing.T) { // flipping --disable-ipv6) without bumping its WtVersion, other account peers // receive a fresh network map so their AAAA records for it become unstale. func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { - manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Drain any initial updates from setup. - drain := func() { - for { - select { - case <-updMsg: - case <-time.After(200 * time.Millisecond): - return + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } } } - } - drain() + drain() - t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: peer2.Meta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updMsg) - }) + step(t, "no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) - t.Run("propagation when IPv6 capability is added", func(t *testing.T) { - newMeta := peer2.Meta - newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) - newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + step(t, "propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: newMeta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updMsg) + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) }) } diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 6fb573b9e..7607a7567 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -1135,287 +1135,291 @@ func sortFunc() func(a *types.FirewallRule, b *types.FirewallRule) int { } func TestPolicyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID, peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - var policyWithGroupRulesNoPeers *types.Policy - var policyWithDestinationPeersOnly *types.Policy - var policyWithSourceAndDestinationPeers *types.Policy - var err error - - // Saving policy with rule groups with no peers should not update account's peers and not send peer update - t.Run("saving policy with rule groups with no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer3.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Saving policy with source group containing peers, but destination group without peers should - // update account's peers and send peer update - t.Run("saving policy where source has peers but destination does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Protocol: types.PolicyRuleProtocolTCP, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("saving policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupC"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Protocol: types.PolicyRuleProtocolTCP, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("saving policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID, peer2.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // Disabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("disabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = false - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating disabled policy with destination and source groups containing peers should still update account's peers - // because affected peer resolution does not filter by policy enabled state - t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) { - drainPeerUpdates(updMsg) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Description = "updated description" - policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Enabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("enabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = true - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy should trigger account peers update and send peer update - t.Run("deleting policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + var policyWithGroupRulesNoPeers *types.Policy + var policyWithDestinationPeersOnly *types.Policy + var policyWithSourceAndDestinationPeers *types.Policy + var err error + + // Saving policy with rule groups with no peers should not update account's peers and not send peer update + step(t, "saving policy with rule groups with no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving policy with source group containing peers, but destination group without peers should + // update account's peers and send peer update + step(t, "saving policy where source has peers but destination does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "saving policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupC"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Protocol: types.PolicyRuleProtocolTCP, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "saving policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Disabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "disabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = false + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating disabled policy with destination and source groups containing peers should still update account's peers + // because affected peer resolution does not filter by policy enabled state + step(t, "updating disabled policy with source and destination groups with peers", func(t *testing.T) { + drainPeerUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Description = "updated description" + policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Enabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "enabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = true + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy should trigger account peers update and send peer update + step(t, "deleting policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + + }) + + // Deleting policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "deleting policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy with no peers in groups should not update account's peers and not send peer update + step(t, "deleting policy with no peers in groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + }) - - // Deleting policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("deleting policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy with no peers in groups should not update account's peers and not send peer update - t.Run("deleting policy with no peers in groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - } diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 74738e72d..601294eda 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -123,324 +123,334 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er } func TestPostureCheckAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - postureCheckA := &posture.Checks{ - Name: "postureCheckA", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, - }, - }, - } - postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) - require.NoError(t, err) - - postureCheckB := &posture.Checks{ - Name: "postureCheckB", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.28.0", - }, - }, - } - - // Saving unused posture check should not update account peers and not send peer update - t.Run("saving unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating unused posture check should not update account peers and not send peer update - t.Run("updating unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - } - - // Linking posture check to policy should trigger update account peers and send peer update - t.Run("linking posture check to policy with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating linked posture checks should update account peers and send peer update - t.Run("updating linked to posture check with peers", func(t *testing.T) { - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, } - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // Removing posture check from policy should trigger account peers update and send peer update - t.Run("removing posture check from policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy.SourcePostureChecks = []string{} - _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting unused posture check should not trigger account peers update and not send peer update - t.Run("deleting unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update - t.Run("updating linked posture check to policy with no peers", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating linked posture check to policy where destination has peers but source does not - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { - updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer2.ID) + updateManager.CloseChannel(context.Background(), peer1.ID) }) - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + postureCheckA := &posture.Checks{ + Name: "postureCheckA", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, + }, + }, + }, + } + postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) + require.NoError(t, err) + + postureCheckB := &posture.Checks{ + Name: "postureCheckB", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.28.0", + }, + }, + } + + // Saving unused posture check should not update account peers and not send peer update + step(t, "saving unused posture check", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating unused posture check should not update account peers and not send peer update + step(t, "updating unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + policy := &types.Policy{ Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, - Sources: []string{"groupB"}, + Sources: []string{"groupA"}, Destinations: []string{"groupA"}, Bidirectional: true, Action: types.PolicyTrafficActionAccept, }, }, SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Linking posture check to policy should trigger update account peers and send peer update + step(t, "linking posture check to policy with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Updating linked client posture check to policy where source has peers but destination does not, - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked posture checks should update account peers and send peer update + step(t, "updating linked to posture check with peers", func(t *testing.T) { + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - { - LinuxPath: "/usr/bin/netbird", + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, }, }, - }, - } + } + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing posture check from policy should trigger account peers update and send peer update + step(t, "removing posture check from policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policy.SourcePostureChecks = []string{} + _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting unused posture check should not trigger account peers update and not send peer update + step(t, "deleting unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update + step(t, "updating linked posture check to policy with no peers", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating linked posture check to policy where destination has peers but source does not + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { + updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer2.ID) + }) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg, updMsg1) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked client posture check to policy where source has peers but destination does not, + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + { + LinuxPath: "/usr/bin/netbird", + }, + }, + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/route_test.go b/management/server/route_test.go index 4ca9ee48f..69b9aec6c 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" @@ -1262,7 +1262,10 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. } eventStore := &activity.InMemoryEventStore{} - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + metrics, err := telemetry.NewDefaultAppMetrics(ctx) require.NoError(t, err) ctrl := gomock.NewController(t) @@ -1290,9 +1293,8 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. permissionsManager := permissions.NewManager(store) peersManager := peers.NewManager(store, permissionsManager) - ctx := context.Background() - - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // A go-cache janitor only stops through a GC finalizer, which would leave synctest bubbles with a goroutine that never exits. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -1301,10 +1303,12 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. requestBuffer := NewAccountRequestBuffer(ctx, store) networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } + cacheManager := am.cacheManager + t.Cleanup(func() { _ = cacheManager.Close() }) return am, updateManager, nil } @@ -1893,265 +1897,269 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { } func TestRouteAccountPeersUpdate(t *testing.T) { - manager, updateManager, err := createRouterManager(t) - require.NoError(t, err, "failed to create account manager") + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, err := createRouterManager(t) + require.NoError(t, err, "failed to create account manager") - account, err := initTestRouteAccount(t, manager) - require.NoError(t, err, "failed to init testing account") + account, err := initTestRouteAccount(t, manager) + require.NoError(t, err, "failed to init testing account") - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err, "failed to create group %s", group.Name) - } + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, + } + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err, "failed to create group %s", group.Name) + } - updMsg := updateManager.CreateChannel(context.Background(), peer1ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1ID) + }) - // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update - t.Run("creating route no routing peer and no peers in groups", func(t *testing.T) { - route := route.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), + // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update + step(t, "creating route no routing peer and no peers in groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupA"}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update + step(t, "creating a route with peers in PeerGroups and Groups", func(t *testing.T) { + drainPeerUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute2", + Network: netip.MustParsePrefix("192.0.2.0/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{routeGroup3}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup3}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + baseRoute := route.Route{ + ID: "testingRoute3", + Network: netip.MustParsePrefix("192.168.0.0/16"), NetID: "superNet", NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupA"}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update - t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) { - drainPeerUpdates(updMsg) - - route := route.Route{ - ID: "testingRoute2", - Network: netip.MustParsePrefix("192.0.2.0/32"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{routeGroup3}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup3}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - baseRoute := route.Route{ - ID: "testingRoute3", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - Peer: peer1ID, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup1}, - } - - // Creating route should update account peers and send peer update - t.Run("creating route with a routing peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - newRoute, err := manager.CreateRoute( - context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, - baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, - baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, - ) - require.NoError(t, err) - baseRoute = *newRoute - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating the route should update account peers and send peer update when there is peers in group - t.Run("updating route", func(t *testing.T) { - baseRoute.Groups = []string{routeGroup1, routeGroup2} - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting the route should update account peers and send peer update - t.Run("deleting route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Adding peer to route peer groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route peer groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.12.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, + Peer: peer1ID, Description: "super", Masquerade: false, Metric: 9999, Enabled: true, Groups: []string{routeGroup1}, } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating route should update account peers and send peer update + step(t, "creating route with a routing peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1ID}, + newRoute, err := manager.CreateRoute( + context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, + baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, + baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, + ) + require.NoError(t, err) + baseRoute = *newRoute + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Updating the route should update account peers and send peer update when there is peers in group + step(t, "updating route", func(t *testing.T) { + baseRoute.Groups = []string{routeGroup1, routeGroup2} - // Adding peer to route groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.13.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) + require.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Deleting the route should update account peers and send peer update + step(t, "deleting route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route peer groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route peer groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.12.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup1}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.13.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go index 2d43ea28b..87959113d 100644 --- a/management/server/setupkey_test.go +++ b/management/server/setupkey_test.go @@ -397,75 +397,77 @@ func TestSetupKey_Copy(t *testing.T) { } func TestSetupKeyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"group"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // The setup policy above dispatches affected-peer updates asynchronously; drain - // any in-flight ones so the assertions only observe the setup-key operations. - settleAffectedUpdates(updMsg) - - var setupKey *types.SetupKey - - // Creating setup key should not update account peers and not send peer update - t.Run("creating setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"group"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, } - }) - - // Saving setup key should not update account peers and not send peer update - t.Run("saving setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // The setup policy above dispatches affected-peer updates asynchronously; drain + // any in-flight ones so the assertions only observe the setup-key operations. + settleAffectedUpdates(updMsg) + + var setupKey *types.SetupKey + + // Creating setup key should not update account peers and not send peer update + step(t, "creating setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving setup key should not update account peers and not send peer update + step(t, "saving setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } diff --git a/management/server/user_test.go b/management/server/user_test.go index 2d1a5f1e9..fcfb7a1e7 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -1544,166 +1544,170 @@ func TestDefaultAccountManager_SaveUser(t *testing.T) { } func TestUserAccountPeersUpdate(t *testing.T) { - // account groups propagation is enabled - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + // account groups propagation is enabled + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - require.NoError(t, err) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + require.NoError(t, err) - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a new regular user should send peer update (as users are not filtered yet) - t.Run("creating new regular user with no groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, true) + } + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // updating user with no linked peers should update account peers and send peer update (as users are not filtered yet) - t.Run("updating user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a new regular user should send peer update (as users are not filtered yet) + step(t, "creating new regular user with no groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, false) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - // deleting user with no linked peers should not update account peers and not send peer update - t.Run("deleting user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + // saving an unchanged user with no linked peers should not update account peers and not send peer update + step(t, "updating user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") - require.NoError(t, err) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - // create a user and add new peer with the user - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - }, true) - require.NoError(t, err) + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + // deleting user with no linked peers should not update account peers and not send peer update + step(t, "deleting user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") + require.NoError(t, err) - // updating user with linked peers should update account peers and send peer update - t.Run("updating user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + // create a user and add new peer with the user _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ Id: "regularUser2", AccountID: account.Id, Role: types.UserRoleAdmin, Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // updating user with linked peers should update account peers and send peer update + step(t, "updating user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer4.ID) - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - // deleting user with linked peers should update account peers and send peer update - t.Run("deleting user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, peer4UpdMsg) - close(done) - }() + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") - require.NoError(t, err) + peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer4.ID) + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // deleting user with linked peers should update account peers and send peer update + step(t, "deleting user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, peer4UpdMsg) + close(done) + }() + + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } From c5aa55d2b911f85fc8d2e7b8c6206be8e7dcdcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Xos=C3=A9=20M=C3=A9ndez=20Taboada?= Date: Tue, 6 Oct 2026 15:05:01 +0200 Subject: [PATCH 10/70] [client] feat(i18n): Add Galician (gl) localization for desktop client (#7041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Galician (gl) localization for desktop client * [client] Address review feedback and sync latest i18n keys for Galician Fix terms per review feedback (usarase, creen, auditar, JWT cache, poscuántico, etc.) and translate newly added upstream i18n keys. * [client] Fix translations, polish terminology, and sync latest keys for Galician * [client] Fix i18n parity against branch source of truth and drop undeclared uk * [ci] Skip Galician locales in codespell check * [client] Add latest error keys for Galician and revert _index.json formatting Add error.settings_locked and error.settings_managed_by_mdm to Galician locale for 100% key parity with en/common.json. Revert multi-line formatting in _index.json to keep a single-line entry for Galician. --- .github/workflows/golangci-lint.yml | 2 +- client/ui/i18n/locales/_index.json | 3 +- client/ui/i18n/locales/gl/common.json | 1397 +++++++++++++++++++++++++ 3 files changed, 1400 insertions(+), 2 deletions(-) create mode 100644 client/ui/i18n/locales/gl/common.json diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index ff36a0854..843177b88 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -30,7 +30,7 @@ jobs: # segment by codespell and behave the same across versions; the # recursive "**" form did not take effect with the codespell shipped # by this action. - skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/gl/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 17fb1d8ea..a52ff5929 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -10,6 +10,7 @@ {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, - {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"}, + {"code": "gl", "displayName": "Galego", "englishName": "Galician"} ] } diff --git a/client/ui/i18n/locales/gl/common.json b/client/ui/i18n/locales/gl/common.json new file mode 100644 index 000000000..2b948104e --- /dev/null +++ b/client/ui/i18n/locales/gl/common.json @@ -0,0 +1,1397 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Non está en execución" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requirido" + }, + "tray.status.loginFailed": { + "message": "Erro ao iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión caducada" + }, + "tray.session.expiresIn": { + "message": "A sesión caduca en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos dun minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfís" + }, + "tray.menu.manageProfiles": { + "message": "Xestionar perfís" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de depuración" + }, + "tray.menu.about": { + "message": "Axuda e soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Resolución de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar a última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Saír de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O servizo NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice o servizo NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird dispoñible" + }, + "notify.update.body": { + "message": "NetBird {version} está dispoñible." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador require esta actualización." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Erro ao conectar" + }, + "notify.error.disconnect": { + "message": "Erro ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Erro ao cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Erro ao actualizar o nodo de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión caducada" + }, + "notify.sessionExpired.body": { + "message": "A súa sesión caducou. Volva iniciar sesión para conectar." + }, + "notify.sessionWarning.title": { + "message": "A sesión caduca en breve" + }, + "notify.sessionWarning.body": { + "message": "A súa sesión de NetBird caduca en {remaining}. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A súa sesión de NetBird está a punto de caducar. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.extend": { + "message": "Prolongar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Erro ao prolongar a sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird prolongada" + }, + "notify.sessionWarning.successBody": { + "message": "A súa sesión foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Data límite de sesión rexeitada" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou unha data límite de sesión non válida. Por favor, inicie sesión de novo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "A súa configuración de NetBird foi actualizada pola súa política de IT." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Gardar" + }, + "common.saveChanges": { + "message": "Gardar cambios" + }, + "common.saving": { + "message": "Gardando…" + }, + "common.close": { + "message": "Pechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Amosar ou ocultar o contrasinal" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuír" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Engadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Non se puido atopar ningún resultado" + }, + "common.noResults.description": { + "message": "Non puidemos atopar ningún resultado. Por favor, tente cun termo de busca diferente ou cambie os seus filtros." + }, + "notConnected.title": { + "message": "Sen conexión" + }, + "notConnected.description": { + "message": "Conéctese primeiro a NetBird para ver información detallada sobre os seus peers, recursos de rede e nodos de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Establecendo conexión..." + }, + "connect.status.connected": { + "message": "Conectado á rede NetBird" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non dispoñible" + }, + "connect.status.loginRequired": { + "message": "Inicie sesión para conectar" + }, + "connect.error.loginTitle": { + "message": "Erro ao iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Erro ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Erro ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de saída" + }, + "nav.exitNode.none": { + "message": "Non activo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Alternar panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sen perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Non se atoparon perfís" + }, + "profile.selector.emptyDescription": { + "message": "Probe cun termo de busca diferente ou cree un novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Máis opcións" + }, + "profile.selector.deregister": { + "message": "Dar de baixa" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Gardar cambios" + }, + "profile.dialog.title": { + "message": "Introduza o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Estableza un nome facilmente identificable para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ex. Traballo" + }, + "profile.dialog.submit": { + "message": "Engadir perfil" + }, + "profile.dialog.required": { + "message": "Por favor, introduza un nome de perfil, p. ex. traballo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud ou o seu propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou engada o perfil de todas formas se ten a certeza de que é correcto." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización dispoñible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexión de NetBird" + }, + "connect.localIp.label": { + "message": "Enderezos IP locais" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de saída" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles do peer" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visibles" + }, + "profile.switch.title": { + "message": "Cambiar ao perfil «{name}»?" + }, + "profile.switch.message": { + "message": "Ten a certeza de que quere cambiar de perfil?\nO seu perfil actual desconectarase." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Dar de baixa o perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "Ten a certeza de que quere dar de baixa este perfil?\nTerá que iniciar sesión de novo para usalo." + }, + "profile.deregister.confirm": { + "message": "Dar de baixa" + }, + "profile.delete.title": { + "message": "Eliminar o perfil «{name}»?" + }, + "profile.delete.message": { + "message": "Ten a certeza de que quere eliminar este perfil?\nEsta acción non se pode desfacer." + }, + "profile.delete.disabledActive": { + "message": "Os perfís activos non se poden eliminar. Cambie a un perfil diferente antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil predeterminado non se pode eliminar." + }, + "profile.error.switchTitle": { + "message": "Erro ao cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Erro ao dar de baixa o perfil" + }, + "profile.error.deleteTitle": { + "message": "Erro ao eliminar o perfil" + }, + "profile.error.createTitle": { + "message": "Erro ao crear o perfil" + }, + "profile.error.editTitle": { + "message": "Erro ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Erro ao cargar os perfís" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Engadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Xestionar perfís" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfís" + }, + "settings.profiles.intro": { + "message": "Manteña identidades de NetBird separadas lado a lado, por exemplo contas de traballo e persoais, ou diferentes servidores de xestión. Engada, dea de baixa ou elimine perfís a continuación." + }, + "settings.profiles.addProfile": { + "message": "Engadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sen perfís" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de xestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Erro ao cargar a configuración" + }, + "settings.error.saveTitle": { + "message": "Erro ao gardar a configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Erro no paquete de depuración" + }, + "settings.nav.label": { + "message": "Seccións de configuración" + }, + "settings.tabs.general": { + "message": "Xeral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Seguridade" + }, + "settings.tabs.profiles": { + "message": "Perfís" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Resolución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización dispoñible" + }, + "settings.general.section.general": { + "message": "Xeral" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece a conexión automaticamente cando se inicia o servizo." + }, + "settings.general.notifications.label": { + "message": "Notificacións de escritorio" + }, + "settings.general.notifications.help": { + "message": "Amosar notificacións de escritorio para novas actualizacións e eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar UI de NetBird ao acceder" + }, + "settings.general.autostart.help": { + "message": "Inicie a interface de NetBird automaticamente cando inicie sesión. Isto afecta só á interface gráfica, non ao servizo en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Erro ao cambiar o inicio automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Manter conectado ao pechar" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Mantén a conexión de NetBird en segundo plano cando se pecha a xanela principal." + }, + "settings.general.language.label": { + "message": "Idioma da interface" + }, + "settings.general.language.help": { + "message": "Escolla o idioma no que se amosa a aplicación NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.theme.label": { + "message": "Tema" + }, + "settings.general.theme.help": { + "message": "Escolla claro ou escuro, ou siga a aparencia do seu sistema." + }, + "settings.general.theme.system": { + "message": "Sistema" + }, + "settings.general.theme.light": { + "message": "Claro" + }, + "settings.general.theme.dark": { + "message": "Escuro" + }, + "settings.general.management.label": { + "message": "Servidor de xestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud ou ao seu propio servidor de xestión autoaloxado. Os cambios volverán conectar o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoaloxado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou garde de todas formas se ten a certeza de que é correcto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor autoaloxado.\nPode que teña que iniciar sesión de novo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Encamiñamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao cambiar de rede" + }, + "settings.network.monitor.help": { + "message": "Supervisa a rede e reconéctase automaticamente ante cambios como o cambio de Wi-Fi ou o reinicio tras a suspensión." + }, + "settings.network.dns.label": { + "message": "Activar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica a configuración DNS xestionada por NetBird ao resolutor do sistema." + }, + "settings.network.clientRoutes.label": { + "message": "Activar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas doutros peers para acadar as súas redes." + }, + "settings.network.serverRoutes.label": { + "message": "Activar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia as rutas locais deste equipo a outros peers." + }, + "settings.network.ipv6.label": { + "message": "Activar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Use o enderezamento IPv6 para a rede superposta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafogos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rexeita conexións non solicitadas de peers a este dispositivo e a calquera rede que encamiñe. O tráfico saínte non se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a LAN" + }, + "settings.security.blockLan.help": { + "message": "Evita que os peers acaden a súa rede local ou os seus dispositivos cando este dispositivo encamiñe o seu tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Activar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Engade un intercambio de chaves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexións con peers que non teñan soporte de resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Activar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executa o servidor SSH de NetBird neste equipo para que outros peers poidan conectarse a el." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión de root" + }, + "settings.ssh.root.help": { + "message": "Permite que os peers inicien sesión como usuario root. Desactíveo para requirir unha conta sen privilexios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfire ficheiros de xeito seguro usando clientes nativos de SFTP ou SCP." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de portos locais" + }, + "settings.ssh.localForward.help": { + "message": "Permite que os peers conectados fagan túneles de portos locais cara a servizos accesibles desde este equipo." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de portos remotos" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que os peers conectados expoñan portos neste equipo cara á súa propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Activar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH co seu IdP para a identidade do usuario e auditar. Desactíveo para depender só de políticas ACL de rede, útil cando non hai IdP dispoñible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL da caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Canto tempo almacena este cliente un JWT na caché antes de solicitalo de novo nas conexións SSH saíntes. Estableza en 0 para desactivar a caché e autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Segundo(s)" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Seguridade" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use entre 1 e 15 letras, díxitos, puntos, guións ou guións baixos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe comezar por \"utun\" seguido dun número (p. ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porto" + }, + "settings.advanced.port.error": { + "message": "Introduza un porto entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se se establece en 0, usarase un porto libre ao azar." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduza un valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave precompartida" + }, + "settings.advanced.psk.help": { + "message": "Chave PSK opcional de WireGuard para cifrado simétrico adicional. Non é o mesmo que unha chave de configuración de NetBird. Só se comunicará con peers que usen a mesma chave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de depuración" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información confidencial" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta enderezos IP públicos e dominios que non sexan de NetBird nos rexistros." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O nivel predeterminado mantén lexibles os enderezos IPv4 internos e os nomes dos peers para o soporte. O nivel estrito anonimiza ademais os enderezos IP privados (RFC 1918), CGNAT e de ligazón local, os nomes dos peers e as chaves públicas de WireGuard. Os valores recorrentes asígnanse ao mesmo marcador, polo que os peers seguen sendo distinguibles. Use Estrito cando comparta o paquete fóra da súa organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ningunha" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminada" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrita" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluír información do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclúe SO, kernel, interfaces de rede e táboas de encamiñamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir paquete aos servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devolve unha chave de subida para compartir co soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar rexistros de traza" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nivel de rexistro a TRACE e restaúrao despois." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Volve conectar e agarda para que poida reproducir a incidencia." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Garda un ficheiro .pcap do tráfico de rede durante a xanela de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Canto tempo se executa a sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minuto(s)" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recompilando rexistros, detalles do sistema e estado de conexión. Isto adoita levar un momento. Pode seguir usando NetBird ou pechar a Configuración mentres remata." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Paquete de depuración subido con éxito!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete gardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta a chave de subida de abaixo co soporte de NetBird. Tamén se gardou unha copia local no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu paquete de depuración gardouse localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir cartafol" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir localización do ficheiro" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Erro na subida: {reason} O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Erro na subida. O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando rexistros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Xerando paquete de depuración…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subindo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "Cliente NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Cliente NetBird" + }, + "settings.about.development": { + "message": "[Desenvolvemento]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os dereitos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos do servizo" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Máis tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versión {version} está dispoñible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "A versión {version} está dispoñible para instalar." + }, + "update.card.whatsNew": { + "message": "Que hai de novo?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizacións en segundo plano." + }, + "update.card.changelog": { + "message": "Rexistro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Ten a versión máis recente" + }, + "update.header.tooltip": { + "message": "Actualización dispoñible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird á v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Unha versión máis recente está dispoñible e estase instalando. NetBird reiniciarase automaticamente unha vez remate a actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "A actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalación de {target} tardou demasiado e non rematou." + }, + "update.overlay.error.canceledTitle": { + "message": "Detívose a actualización" + }, + "update.overlay.error.canceledDescription": { + "message": "A actualización a {target} cancelouse antes de rematar." + }, + "update.overlay.error.failTitle": { + "message": "Non se puido instalar a actualización" + }, + "update.overlay.error.failDescription": { + "message": "Non se puido instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro descoñecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versión" + }, + "update.error.loadStateTitle": { + "message": "Erro ao cargar o estado de actualización" + }, + "update.error.triggerTitle": { + "message": "Erro ao iniciar a actualización" + }, + "update.page.versionLine": { + "message": "Actualizando cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando cliente." + }, + "update.page.outdated": { + "message": "A súa versión de cliente é máis antiga que a versión de actualización automática establecida na xestión." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Esgotouse o tempo de espera da actualización. Por favor, inténteo de novo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Erro na actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de actualización descoñecido" + }, + "update.page.failedTitle": { + "message": "Erro na actualización" + }, + "update.page.timeoutMessage": { + "message": "Esgotouse o tempo de espera da actualización." + }, + "update.page.dontClose": { + "message": "Por favor, non peche esta xanela." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Erro na actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Iniciar sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de caducar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Benvido a NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Busque NetBird na súa área de notificación" + }, + "welcome.titleMac": { + "message": "Busque NetBird na súa barra de menú" + }, + "welcome.description": { + "message": "NetBird reside na súa área de notificación. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.descriptionMac": { + "message": "NetBird reside na súa barra de menú. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Volver" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Faga clic en Continuar para comezar, ou escolla Autoaloxado se ten o seu propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o noso servizo aloxado. Sen necesidade de configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoaloxado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese ao seu propio servidor de xestión." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de xestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou a súa rede, e continúe se ten a certeza de que é correcto." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Complete o inicio de sesión no navegador" + }, + "browserLogin.notSeeing": { + "message": "Abrimos unha lapela no seu navegador para que poida rematar de iniciar sesión. Non a ve?" + }, + "browserLogin.tryAgain": { + "message": "Tentar de novo" + }, + "browserLogin.openFailedTitle": { + "message": "Erro ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sesión caduca en breve" + }, + "sessionExpiration.titleLater": { + "message": "A súa sesión caducará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo desconectarase en breve. Renove iniciando sesión no navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Un inicio de sesión no navegador mantén este dispositivo conectado á súa rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Pechar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión caducada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese cun inicio de sesión no navegador para volver conectar." + }, + "sessionExpiration.close": { + "message": "Pechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Erro ao prolongar a sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Erro ao pechar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En liña" + }, + "peers.filter.offline": { + "message": "Fóra de liña" + }, + "peers.empty.title": { + "message": "Sen peers dispoñibles" + }, + "peers.empty.description": { + "message": "Ou non ten ningún peer dispoñible ou non ten acceso a ningún deles." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Servidor de retransmisión" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitido" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Sen recursos dispoñibles" + }, + "networks.empty.description": { + "message": "Ou non ten recursos de rede dispoñibles ou non ten acceso a ningún deles." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "Non seleccionado" + }, + "networks.ips.heading": { + "message": "IPs resoltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} Activos" + }, + "networks.bulk.enableAll": { + "message": "Activar todos" + }, + "networks.bulk.disableAll": { + "message": "Desactivar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de saída" + }, + "exitNodes.none": { + "message": "Ningún" + }, + "exitNodes.empty.title": { + "message": "Sen nodos de saída dispoñibles" + }, + "exitNodes.empty.description": { + "message": "Non se compartiron nodos de saída con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de saída" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ningún" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sen nodo de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O servizo NetBird non está en execución" + }, + "daemon.unavailable.description": { + "message": "A aplicación volverá conectar automaticamente cando o servizo estea en execución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "O cliente NetBird está desactualizado" + }, + "daemon.outdated.description": { + "message": "A nova GUI non é compatible co cliente NetBird máis antigo. Actualice o seu cliente para usar a nova aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar a última versión" + }, + "error.jwt_clock_skew": { + "message": "Erro ao iniciar sesión: o reloxo deste dispositivo non está sincronizado co servidor. Por favor, sincronice o reloxo do sistema e inténteo de novo." + }, + "error.jwt_expired": { + "message": "O seu token de inicio de sesión caducou. Por favor, inicie sesión de novo." + }, + "error.jwt_signature_invalid": { + "message": "Erro ao iniciar sesión: a sinatura do token non é válida. Por favor, contacte co seu administrador." + }, + "error.session_expired": { + "message": "A súa sesión caducou. Por favor, inicie sesión de novo." + }, + "error.invalid_setup_key": { + "message": "A chave de configuración falta ou non é válida." + }, + "error.permission_denied": { + "message": "O inicio de sesión foi rexeitado polo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon de NetBird non responde. Por favor, comprobe que o servizo está en execución." + }, + "error.settings_locked": { + "message": "A configuración non se pode cambiar neste dispositivo: un administrador bloqueouna." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está xestionada pola súa organización e non se pode cambiar." + }, + "error.unknown": { + "message": "A operación fallou." + }, + "error.elevation_unavailable": { + "message": "NetBird non puido solicitar a este sistema os privilexios que o cambio require. Execute isto no seu lugar:" + }, + "error.elevation_failed": { + "message": "O cambio non se puido aplicar con privilexios elevados. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilexios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Require {actor}. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Pode desactivalo, pero para volvelo activar requírese {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Pode activalo, pero para volvelo desactivar requírese {actor}:" + }, + "settings.ssh.privilege.authorizePending": { + "message": "Agardando pola autorización…" + } +} From d56e6fc5f4ae07e352ada103fd6354d95d1715c3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 15:41:01 +0200 Subject: [PATCH 11/70] [client, android, ios] Coalesce peer list change notifications to the mobile listener (#7546) * [client] Coalesce peer list change notifications to the mobile listener Every peer state change spawned a goroutine to call the platform listener. During a reconnect storm this pinned hundreds of OS threads in JNI and let the UI call back into the engine from each of them. Deliver peer list changes from a single goroutine per listener and collapse pending changes into the latest count. * [client] Cover a pending wake-up when the peer list deliverer is replaced The replacement test waited for the old callback to finish before swapping listeners, so it never exercised the stop check that runs after a wake-up. Block the old callback, queue a peer list change and swap while it is blocked, then assert the old listener never sees the new count. * [client] Signal peer list deliverer exit and wait for it in the test The replacement test sampled the old listener after a fixed sleep, so a late stale delivery could slip past it. Close a done channel when the deliverer goroutine returns and let the test wait on it instead. * [client] Drop the test-only peer list deliverer exit channel The done channel was only read by the replacement test. Production code cannot wait on it, since joining the deliverer would block on a mobile callback. The tests now drive the deliverer loop directly and check that setListener and removeListener close its stop channel. --- client/internal/peer/notifier.go | 60 ++++++++-- client/internal/peer/notifier_test.go | 155 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 8 deletions(-) diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 1ee1d32ea..564098bd4 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -12,6 +12,8 @@ type notifier struct { serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener + peerListWake chan struct{} + peerListStop chan struct{} currentClientState bool lastNotification ClientState lastNumberOfPeers int @@ -62,7 +64,6 @@ func (n *notifier) setNetworkAvailable(available bool) { func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() lastNotification := n.effectiveState(n.lastNotification) - numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress n.serverStateLock.Unlock() @@ -70,17 +71,19 @@ func (n *notifier) setListener(listener Listener) { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = listener listener.OnAddressChanged(fqdnAddress, address) notifyListener(listener, lastNotification) - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) + n.startPeerListDelivererLocked(listener) + n.wakePeerListDelivererLocked() } func (n *notifier) removeListener() { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = nil } @@ -178,15 +181,56 @@ func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Unlock() n.listenersLock.Lock() - listener := n.listener - n.listenersLock.Unlock() + defer n.listenersLock.Unlock() + n.wakePeerListDelivererLocked() +} - if listener == nil { +func (n *notifier) startPeerListDelivererLocked(listener Listener) { + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + n.peerListWake = wake + n.peerListStop = stop + go n.deliverPeerListChanges(listener, wake, stop) +} + +func (n *notifier) stopPeerListDelivererLocked() { + if n.peerListStop == nil { return } + close(n.peerListStop) + n.peerListStop = nil + n.peerListWake = nil +} - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) +func (n *notifier) wakePeerListDelivererLocked() { + if n.peerListWake == nil { + return + } + select { + case n.peerListWake <- struct{}{}: + default: + } +} + +func (n *notifier) deliverPeerListChanges(listener Listener, wake <-chan struct{}, stop <-chan struct{}) { + for { + select { + case <-stop: + return + case <-wake: + } + select { + case <-stop: + return + default: + } + + n.serverStateLock.Lock() + numOfPeers := n.lastNumberOfPeers + n.serverStateLock.Unlock() + + listener.OnPeersListChanged(numOfPeers) + } } func (n *notifier) localAddressChanged(fqdn, address string) { diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index a73016b05..f81866214 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -2,7 +2,9 @@ package peer import ( "sync" + "sync/atomic" "testing" + "time" ) type mocListener struct { @@ -115,3 +117,156 @@ func Test_notifier_RemoveListener(t *testing.T) { t.Errorf("invalid state: %d", listener.peers) } } + +type coalescingListener struct { + final int + calls atomic.Int32 + inFlight atomic.Int32 + maxInFlight atomic.Int32 + last atomic.Int32 + done chan struct{} + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (l *coalescingListener) OnStateChanged(ClientState) {} +func (l *coalescingListener) OnConnected() {} +func (l *coalescingListener) OnDisconnected() {} +func (l *coalescingListener) OnConnecting() {} +func (l *coalescingListener) OnDisconnecting() {} +func (l *coalescingListener) OnAddressChanged(string, string) {} + +func (l *coalescingListener) OnPeersListChanged(size int) { + current := l.inFlight.Add(1) + for { + seen := l.maxInFlight.Load() + if current <= seen || l.maxInFlight.CompareAndSwap(seen, current) { + break + } + } + if l.calls.Add(1) == 1 && l.entered != nil { + close(l.entered) + } + if l.release != nil { + <-l.release + } + time.Sleep(time.Millisecond) + l.last.Store(int32(size)) + l.inFlight.Add(-1) + if size == l.final { + l.once.Do(func() { close(l.done) }) + } +} + +func Test_notifier_PeerListChangedCoalesces(t *testing.T) { + const events = 1000 + listener := &coalescingListener{final: events, done: make(chan struct{})} + n := newNotifier() + n.setListener(listener) + + for i := 1; i <= events; i++ { + n.peerListChanged(i) + } + + select { + case <-listener.done: + case <-time.After(5 * time.Second): + t.Fatalf("last peer count not delivered, last seen: %d", listener.last.Load()) + } + + if got := listener.maxInFlight.Load(); got != 1 { + t.Errorf("concurrent deliveries: %d, expected 1", got) + } + if got := listener.calls.Load(); got >= events { + t.Errorf("deliveries not coalesced: %d calls for %d events", got, events) + } +} + +func Test_notifier_SetListenerStopsPreviousDeliverer(t *testing.T) { + old := &coalescingListener{final: -1} + replacement := &coalescingListener{final: 7, done: make(chan struct{})} + n := newNotifier() + n.setListener(old) + oldStop := n.peerListStop + + n.peerListChanged(7) + n.setListener(replacement) + + select { + case <-oldStop: + default: + t.Fatal("old deliverer not stopped on listener replacement") + } + waitFor(t, replacement.done, "replacement listener not notified") +} + +func Test_notifier_RemoveListenerStopsDeliverer(t *testing.T) { + n := newNotifier() + n.setListener(&coalescingListener{final: -1}) + stop := n.peerListStop + + n.removeListener() + + select { + case <-stop: + default: + t.Fatal("deliverer not stopped on listener removal") + } +} + +func Test_notifier_DelivererExitsAfterInFlightCallback(t *testing.T) { + listener := &coalescingListener{ + final: -1, + entered: make(chan struct{}), + release: make(chan struct{}), + } + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + exited := make(chan struct{}) + go func() { + n.deliverPeerListChanges(listener, wake, stop) + close(exited) + }() + + wake <- struct{}{} + waitFor(t, listener.entered, "listener not called") + + n.peerListChanged(7) + wake <- struct{}{} + close(stop) + close(listener.release) + + waitFor(t, exited, "deliverer did not exit after stop") + if got := listener.calls.Load(); got != 1 { + t.Errorf("deliverer ran %d callbacks after stop, expected only the in-flight one", got) + } + if got := listener.last.Load(); got == 7 { + t.Errorf("deliverer delivered the peer count queued after stop") + } +} + +func Test_notifier_DelivererPrefersStopOverPendingWake(t *testing.T) { + listener := &coalescingListener{final: -1} + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + + wake <- struct{}{} + close(stop) + n.deliverPeerListChanges(listener, wake, stop) + + if got := listener.calls.Load(); got != 0 { + t.Errorf("deliverer ran %d callbacks with stop closed, expected 0", got) + } +} + +func waitFor(t *testing.T, ch <-chan struct{}, msg string) { + t.Helper() + select { + case <-ch: + case <-time.After(5 * time.Second): + t.Fatal(msg) + } +} From 2623feeb5b7ca6338f98de697076bc835bed6fee Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:35:31 +0200 Subject: [PATCH 12/70] [management] remove ingress ports (#8062) --- client/cmd/forwarding_rules.go | 98 -- client/cmd/root.go | 3 - client/cmd/testutil_test.go | 7 +- client/embed/embed_test.go | 7 +- client/firewall/iptables/dnat_linux.go | 166 ---- .../iptables/dnat_refcount_linux_test.go | 240 ----- client/firewall/iptables/family_linux.go | 4 - client/firewall/iptables/filter_linux.go | 9 - client/firewall/iptables/manager_linux.go | 25 - .../firewall/iptables/manager_linux_test.go | 10 - client/firewall/manager/firewall.go | 6 - client/firewall/manager/forward_rule.go | 27 - client/firewall/nftables/dnat_linux.go | 321 ------- .../nftables/dnat_refcount_linux_test.go | 249 ----- client/firewall/nftables/family_linux.go | 8 - client/firewall/nftables/filter_linux.go | 5 - client/firewall/nftables/manager_linux.go | 47 +- .../firewall/nftables/manager_linux_test.go | 24 - client/firewall/nftables/routing_linux.go | 35 - client/firewall/uspfilter/nat.go | 10 - client/internal/engine.go | 92 +- client/internal/engine_privileged_test.go | 5 +- client/internal/ingressgw/manager.go | 111 --- client/internal/ingressgw/manager_test.go | 281 ------ client/internal/message_convert.go | 43 - client/internal/peer/status.go | 35 - .../routemanager/ipfwdstate/ipfwdstate.go | 28 +- .../ipfwdstate_privileged_linux_test.go | 10 +- client/proto/daemon.pb.go | 55 +- client/proto/daemon.proto | 18 +- client/proto/daemon_grpc.pb.go | 5 + client/server/forwardingrules.go | 54 -- client/server/server_privileged_test.go | 7 +- client/status/status.go | 94 +- client/status/status_test.go | 2 - client/ui/frontend/WAILS-API.md | 17 +- client/ui/main.go | 1 - client/ui/services/forwarding.go | 83 -- go.mod | 2 +- go.sum | 4 +- .../network_map/controller/controller.go | 122 +-- .../network_map/controller/controller_test.go | 3 +- .../network_map/controller/repository_mock.go | 15 + .../controllers/network_map/interface.go | 2 +- .../controllers/network_map/interface_mock.go | 11 +- .../network_map/nmaptest/canonicalize.go | 11 - .../network_map/nmaptest/runner.go | 2 +- management/internals/server/controllers.go | 11 +- management/internals/server/modules.go | 4 +- .../shared/grpc/components_encoder.go | 6 - .../shared/grpc/components_encoder_test.go | 60 -- .../grpc/components_envelope_response.go | 43 - .../internals/shared/grpc/conversion.go | 8 - management/internals/shared/grpc/server.go | 4 +- management/server/account.go | 4 - management/server/account_test.go | 5 +- management/server/dns_test.go | 5 +- .../testing/testing_tools/channel/channel.go | 11 +- management/server/identity_provider_test.go | 5 +- .../port_forwarding/controller.go | 38 - management/server/management_proto_test.go | 7 +- management/server/management_test.go | 6 +- management/server/nameserver_test.go | 5 +- management/server/peer.go | 9 - management/server/peer_test.go | 39 +- management/server/route_test.go | 5 +- .../server/types/account_networkmapdata.go | 3 +- management/server/types/aliases.go | 1 - management/server/types/legacynmap/aliases.go | 1 - .../server/types/legacynmap/converters.go | 1 - .../types/legacynmap/equivalence_test.go | 12 - .../server/types/legacynmap/proto_legacy.go | 8 - shared/management/client/client_test.go | 7 +- shared/management/client/rest/client.go | 5 - shared/management/client/rest/ingress.go | 92 -- shared/management/client/rest/ingress_test.go | 184 ---- shared/management/client/rest/peers.go | 92 -- shared/management/client/rest/peers_test.go | 145 --- shared/management/http/api/openapi.yml | 554 ----------- shared/management/http/api/types.gen.go | 223 ----- shared/management/networkmap/envelope.go | 68 +- shared/management/proto/management.pb.go | 907 +++++++++--------- shared/management/proto/management.proto | 35 +- shared/management/types/network.go | 104 -- shared/management/types/network_test.go | 41 - 85 files changed, 667 insertions(+), 4505 deletions(-) delete mode 100644 client/cmd/forwarding_rules.go delete mode 100644 client/firewall/iptables/dnat_refcount_linux_test.go delete mode 100644 client/firewall/manager/forward_rule.go delete mode 100644 client/firewall/nftables/dnat_refcount_linux_test.go delete mode 100644 client/internal/ingressgw/manager.go delete mode 100644 client/internal/ingressgw/manager_test.go delete mode 100644 client/internal/message_convert.go delete mode 100644 client/server/forwardingrules.go delete mode 100644 client/ui/services/forwarding.go delete mode 100644 management/server/integrations/port_forwarding/controller.go delete mode 100644 shared/management/client/rest/ingress.go delete mode 100644 shared/management/client/rest/ingress_test.go delete mode 100644 shared/management/types/network_test.go diff --git a/client/cmd/forwarding_rules.go b/client/cmd/forwarding_rules.go deleted file mode 100644 index b3052746a..000000000 --- a/client/cmd/forwarding_rules.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" -) - -var forwardingRulesCmd = &cobra.Command{ - Use: "forwarding", - Short: "List forwarding rules", - Long: `Commands to list forwarding rules.`, -} - -var forwardingRulesListCmd = &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List forwarding rules", - Example: " netbird forwarding list", - Long: "Commands to list forwarding rules.", - RunE: listForwardingRules, -} - -func listForwardingRules(cmd *cobra.Command, _ []string) error { - conn, err := getClient(cmd) - if err != nil { - return err - } - defer conn.Close() - - client := proto.NewDaemonServiceClient(conn) - resp, err := client.ForwardingRules(cmd.Context(), &proto.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to list network: %v", status.Convert(err).Message()) - } - - if len(resp.GetRules()) == 0 { - cmd.Println("No forwarding rules available.") - return nil - } - - printForwardingRules(cmd, resp.GetRules()) - return nil -} - -func printForwardingRules(cmd *cobra.Command, rules []*proto.ForwardingRule) { - cmd.Println("Available forwarding rules:") - - // Sort rules by translated address - sort.Slice(rules, func(i, j int) bool { - if rules[i].GetTranslatedAddress() != rules[j].GetTranslatedAddress() { - return rules[i].GetTranslatedAddress() < rules[j].GetTranslatedAddress() - } - if rules[i].GetProtocol() != rules[j].GetProtocol() { - return rules[i].GetProtocol() < rules[j].GetProtocol() - } - - return getFirstPort(rules[i].GetDestinationPort()) < getFirstPort(rules[j].GetDestinationPort()) - }) - - var lastIP string - for _, rule := range rules { - dPort := portToString(rule.GetDestinationPort()) - tPort := portToString(rule.GetTranslatedPort()) - if lastIP != rule.GetTranslatedAddress() { - lastIP = rule.GetTranslatedAddress() - cmd.Printf("\nTranslated peer: %s\n", rule.GetTranslatedHostname()) - } - - cmd.Printf(" Local %s/%s to %s:%s\n", rule.GetProtocol(), dPort, rule.GetTranslatedAddress(), tPort) - } -} - -func getFirstPort(portInfo *proto.PortInfo) int { - switch v := portInfo.PortSelection.(type) { - case *proto.PortInfo_Port: - return int(v.Port) - case *proto.PortInfo_Range_: - return int(v.Range.GetStart()) - default: - return 0 - } -} - -func portToString(translatedPort *proto.PortInfo) string { - switch v := translatedPort.PortSelection.(type) { - case *proto.PortInfo_Port: - return fmt.Sprintf("%d", v.Port) - case *proto.PortInfo_Range_: - return fmt.Sprintf("%d-%d", v.Range.GetStart(), v.Range.GetEnd()) - default: - return "No port specified" - } -} diff --git a/client/cmd/root.go b/client/cmd/root.go index 2ca14c39c..4525a9bd6 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -177,7 +177,6 @@ func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(sshCmd) rootCmd.AddCommand(networksCMD) - rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(profileCmd) rootCmd.AddCommand(exposeCmd) @@ -185,8 +184,6 @@ func init() { networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) - forwardingRulesCmd.AddCommand(forwardingRulesListCmd) - debugCmd.AddCommand(debugBundleCmd) debugCmd.AddCommand(logCmd) logCmd.AddCommand(logLevelCmd) diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 328a15454..46bf31837 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "google.golang.org/grpc" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" @@ -28,7 +28,6 @@ import ( mgmt "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -124,9 +123,9 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", manager.NewEphemeralManager(store, peersmanager), config, nil) - accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 4ff5c9978..a818af055 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "google.golang.org/grpc" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -21,7 +21,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "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" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -146,8 +145,8 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) - accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(testStore, peersManager), cfg, nil) + accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager) diff --git a/client/firewall/iptables/dnat_linux.go b/client/firewall/iptables/dnat_linux.go index eca8386c0..f118c9dfe 100644 --- a/client/firewall/iptables/dnat_linux.go +++ b/client/firewall/iptables/dnat_linux.go @@ -8,177 +8,11 @@ import ( "strconv" "strings" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - toDestination := rule.TranslatedAddress.String() - switch { - case len(rule.TranslatedPort.Values) == 0: - // no translated port, use original port - case len(rule.TranslatedPort.Values) == 1: - toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - // need the "/originalport" suffix to avoid dnat port randomization - toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) - default: - return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } - - proto := strings.ToLower(string(rule.Protocol)) - - rules := make(map[firewall.RuleID]ruleInfo, 3) - - // DNAT rule - dnatRule := []string{ - "!", "-i", r.wgIface.Name(), - "-p", proto, - "-j", "DNAT", - "--to-destination", toDestination, - } - dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) - rules[ruleID+dnatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTRdr, - rule: dnatRule, - } - - // SNAT rule - snatRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "MASQUERADE", - } - snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+snatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTNAT, - rule: snatRule, - } - - // Forward filtering rule, if fwd policy is DROP - forwardRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "ACCEPT", - } - forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+fwdSuffix] = ruleInfo{ - table: tableFilter, - chain: chainRTFwdOut, - rule: forwardRule, - } - - for key, ruleInfo := range rules { - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("add rule %s: %w", key, err) - } - r.rules[key] = ruleInfo.rule - } - - if err := r.ipFwdState.RequestForwarding(r.v6); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - r.updateState() - return rule, nil -} - -// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied -// AddDNATRule before rolling back the kernel rules, so no entries remain that -// never got a forwarding refcount. rollbackRules re-adds entries it failed to -// remove from the kernel. -func (r *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) { - for key := range rules { - delete(r.rules, key) - } - if err := r.rollbackRules(rules); err != nil { - log.Errorf("rollback failed: %v", err) - } -} - -func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error { - var merr *multierror.Error - for key, ruleInfo := range rules { - if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) - // On rollback error, add to rules map for next cleanup - r.rules[key] = ruleInfo.rule - } - } - if merr != nil { - r.updateState() - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - _, hadDNAT := r.rules[ruleID+dnatSuffix] - _, hadSNAT := r.rules[ruleID+snatSuffix] - _, hadFWD := r.rules[ruleID+fwdSuffix] - if !hadDNAT && !hadSNAT && !hadFWD { - return nil - } - - var merr *multierror.Error - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+dnatSuffix) - } - } - - if snatRule, exists := r.rules[ruleID+snatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+snatSuffix) - } - } - - if fwdRule, exists := r.rules[ruleID+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFwdOut, fwdRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) - } else { - delete(r.rules, ruleID+fwdSuffix) - } - } - - // Release the refcount only once all rules are gone from the kernel. On - // partial failure the failed entries stay in r.rules so a retry can remove - // them and release then. - if merr == nil { - r.releaseForwarding() - } - - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go deleted file mode 100644 index 40ebc6cc3..000000000 --- a/client/firewall/iptables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,240 +0,0 @@ -//go:build privileged - -package iptables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func iptRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - } - }, - } -} - -func iptRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newIptRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - var ifMock *iFaceMock - if dual { - ifMock = iptRefcountIfaceDual() - } else { - ifMock = iptRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func iptDnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func iptDnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4. -func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newIptRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV4(7081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(iptDnatV4(7082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and -// decrements back to zero. -func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newIptRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9081)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(iptDnatV6(9082)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns -// without bumping the refcount. -func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := iptDnatV4(7083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err) - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule -// neither errors nor releases the refcount. -func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - phantom := iptDnatV4(7099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - phantom6 := iptDnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - r1, err := m.AddDNATRule(iptDnatV4(7100)) - require.NoError(t, err) - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same -// rule is a no-op. -func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9083)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go index 0e1ce5440..2ac860a0a 100644 --- a/client/firewall/iptables/family_linux.go +++ b/client/firewall/iptables/family_linux.go @@ -56,10 +56,6 @@ const ( markManglePost = "mark-mangle-post" matchSet = "--match-set" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - fwdSuffix firewall.RuleID = "_fwd" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. diff --git a/client/firewall/iptables/filter_linux.go b/client/firewall/iptables/filter_linux.go index dc606da2d..30cd81018 100644 --- a/client/firewall/iptables/filter_linux.go +++ b/client/firewall/iptables/filter_linux.go @@ -81,15 +81,6 @@ func (r *family) hasRule(id nbid.RuleID) bool { return ok } -// hasDNATRule reports whether this family owns the DNAT rule set for -// the given user id. DNAT rules live in r.rules under the well-known -// "_dnat" key; the lookup here is used by Manager.DeleteDNATRule -// to pick the right family. -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. The // rule's stored chain/table identify where to delete from; source set // references are recovered from the spec via findSets and dropped diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 0f0b0110e..a566909c8 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -323,31 +323,6 @@ func (m *Manager) DisableRouting() error { return m.family4.ipFwdState.ReleaseRouting() } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) { - return m.family6.DeleteDNATRule(rule) - } - return m.family4.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index 9f53352e1..8435bf6a5 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -497,16 +497,6 @@ func TestIptablesCloseRemovesAllState(t *testing.T) { require.NoError(t, manager.AddNatRule(pair), "add nat rule") require.NoError(t, manager.EnableRouting(), "enable routing") - // A DNAT redirect, which also holds a forwarding reference. - dnat := fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.44"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } - _, err = manager.AddDNATRule(dnat) - require.NoError(t, err, "add dnat rule") - require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state") // Everything above stays in place, so Close is what has to remove it. diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 0eb376875..f8de1e2b5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -172,12 +172,6 @@ type Manager interface { DisableRouting() error - // AddDNATRule adds outbound DNAT rule for forwarding external traffic to the NetBird network. - AddDNATRule(ForwardRule) (Rule, error) - - // DeleteDNATRule deletes the outbound DNAT rule. - DeleteDNATRule(Rule) error - // UpdateSet updates the set with the given prefixes UpdateSet(hash Set, prefixes []netip.Prefix) error diff --git a/client/firewall/manager/forward_rule.go b/client/firewall/manager/forward_rule.go deleted file mode 100644 index c2e9e5c60..000000000 --- a/client/firewall/manager/forward_rule.go +++ /dev/null @@ -1,27 +0,0 @@ -package manager - -import ( - "fmt" - "net/netip" -) - -// ForwardRule todo figure out better place to this to avoid circular imports -type ForwardRule struct { - Protocol Protocol - DestinationPort Port - TranslatedAddress netip.Addr - TranslatedPort Port -} - -func (r ForwardRule) ID() RuleID { - id := fmt.Sprintf("%s;%s;%s;%s", - r.Protocol, - r.DestinationPort.String(), - r.TranslatedAddress.String(), - r.TranslatedPort.String()) - return RuleID(id) -} - -func (r ForwardRule) String() string { - return fmt.Sprintf("protocol: %s, destinationPort: %s, translatedAddress: %s, translatedPort: %s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String()) -} diff --git a/client/firewall/nftables/dnat_linux.go b/client/firewall/nftables/dnat_linux.go index 8eae694a2..c179d60cc 100644 --- a/client/firewall/nftables/dnat_linux.go +++ b/client/firewall/nftables/dnat_linux.go @@ -9,332 +9,11 @@ import ( "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" - "github.com/google/nftables/xt" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - protoNum, err := r.af.protoNum(rule.Protocol) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - - // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq - // buffer netlink messages on r.conn that the next caller's Flush would - // commit if we returned without flushing them ourselves. - if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil { - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - return nil, err - } - - if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - return nil, err - } - - // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. - // To overcome DROP policies in other chains, we'd have to add rules to the chains there. - // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. - // TODO: find chains with drop policies and add rules there - - if err := r.conn.Flush(); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - return nil, fmt.Errorf("flush rules: %w", err) - } - - return &rule, nil -} - -func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - dnatExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - } - portExprs, err := r.applyPort(&rule.DestinationPort, false) - if err != nil { - return fmt.Errorf("apply destination port: %w", err) - } - dnatExprs = append(dnatExprs, portExprs...) - - // shifted translated port is not supported in nftables, so we hand this over to xtables - if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { - if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || - rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { - return r.addXTablesRedirect(dnatExprs, ruleID, rule) - } - } - - additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) - if err != nil { - return err - } - dnatExprs = append(dnatExprs, additionalExprs...) - - dnatExprs = append(dnatExprs, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: regProtoMin, - RegProtoMax: regProtoMax, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - switch { - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - return r.handlePortRange(rule) - case len(rule.TranslatedPort.Values) == 0: - return r.handleAddressOnly(rule) - case len(rule.TranslatedPort.Values) == 1: - return r.handleSinglePort(rule) - default: - return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } -} - -func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - &expr.Immediate{ - Register: 3, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), - }, - } - return exprs, 2, 3, nil -} - -func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - return exprs, 0, 0, nil -} - -func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - } - return exprs, 2, 0, nil -} - -func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error { - dnatExprs = append(dnatExprs, - &expr.Counter{}, - &expr.Target{ - Name: "DNAT", - Rev: 2, - Info: &xt.NatRange2{ - NatRange: xt.NatRange{ - Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), - MinIP: rule.TranslatedAddress.AsSlice(), - MaxIP: rule.TranslatedAddress.AsSlice(), - MinPort: rule.TranslatedPort.Values[0], - MaxPort: rule.TranslatedPort.Values[1], - }, - BasePort: rule.DestinationPort.Values[0], - }, - }, - ) - - natTable := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - dnatRule := &nftables.Rule{ - Table: natTable, - Chain: &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: natTable, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - }, - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - portExprs, err := r.applyPort(&rule.TranslatedPort, false) - if err != nil { - return fmt.Errorf("apply translated port: %w", err) - } - - masqExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: r.af.dstAddrOffset, - Len: r.af.addrLen, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - - masqExprs = append(masqExprs, portExprs...) - masqExprs = append(masqExprs, &expr.Masq{}) - - masqRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: masqExprs, - UserData: []byte(ruleID + snatSuffix), - } - r.conn.AddRule(masqRule) - r.rules[ruleID+snatSuffix] = masqRule - - return nil -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - var needsFlush bool - var found bool - - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - found = true - if dnatRule.Handle == 0 { - log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix) - delete(r.rules, ruleID+dnatSuffix) - } else if err := r.conn.DelRule(dnatRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) - } else { - needsFlush = true - } - } - - if masqRule, exists := r.rules[ruleID+snatSuffix]; exists { - found = true - if masqRule.Handle == 0 { - log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix) - delete(r.rules, ruleID+snatSuffix) - } else if err := r.conn.DelRule(masqRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) - } else { - needsFlush = true - } - } - - if needsFlush { - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - } - - if merr != nil { - return nberrors.FormatErrorOrNil(merr) - } - - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - - // Release once, only if the rule was present and removed. - if found { - r.releaseForwarding() - } - - return nil -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - -// isV6 reports whether this family handles the IPv6 table. -func (r *family) isV6() bool { - return r.af.tableFamily == nftables.TableFamilyIPv6 -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go deleted file mode 100644 index cdc24e77f..000000000 --- a/client/firewall/nftables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,249 +0,0 @@ -//go:build privileged - -package nftables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func nftRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - } - }, - } -} - -func nftRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newNftRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - if check() != NFTABLES { - t.Skip("nftables not supported on this system") - } - var ifMock *iFaceMock - if dual { - ifMock = nftRefcountIfaceDual() - } else { - ifMock = nftRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func dnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func dnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the -// v4 refcount at zero. -func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newNftRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV4(8081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(dnatV4(8082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only -// and decrements back to zero on Delete. -func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newNftRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9091)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(dnatV6(9092)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same -// ForwardRule) does not double-increment the refcount. -func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := dnatV4(8083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err, "add v4 dnat") - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - // duplicate add: same rule ID, must be a no-op for the refcount. - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was -// never added does not underflow the refcount. -func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - // Construct a Rule reference for something never added. The router stores - // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing - // entry must be a no-op rather than calling Release. - phantom := dnatV4(8099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") - assert.Equal(t, 0, v6, "v6 refcount unaffected") - - phantom6 := dnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") - - // And after a phantom delete, a real add still results in count=1. - r1, err := m.AddDNATRule(dnatV4(8100)) - require.NoError(t, err, "add v4 dnat after phantom delete") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule -// twice does not underflow the refcount (the second delete is a no-op). -func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9093)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/nftables/family_linux.go b/client/firewall/nftables/family_linux.go index 7a5df3ed7..4169c9d2d 100644 --- a/client/firewall/nftables/family_linux.go +++ b/client/firewall/nftables/family_linux.go @@ -24,7 +24,6 @@ const ( tableRaw = "raw" tableSecurity = "security" - chainNameNatPrerouting = "PREROUTING" chainNameRoutingFw = "netbird-rt-fwd" chainNameRoutingNat = "netbird-rt-postrouting" chainNameRoutingRdr = "netbird-rt-redirect" @@ -47,9 +46,6 @@ const ( userDataAcceptForwardRuleOif = "frwacceptoif" userDataAcceptInputRule = "inputaccept" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. @@ -167,10 +163,6 @@ func (r *family) Reset() error { merr = multierror.Append(merr, err) } - if err := r.removeNatPreroutingRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) - } - return nberrors.FormatErrorOrNil(merr) } diff --git a/client/firewall/nftables/filter_linux.go b/client/firewall/nftables/filter_linux.go index ebd238063..bb3ac1dfe 100644 --- a/client/firewall/nftables/filter_linux.go +++ b/client/firewall/nftables/filter_linux.go @@ -197,11 +197,6 @@ func (r *family) hasRule(id firewall.RuleID) bool { return ok } -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. Source // set references are recovered from the stored rule's expressions via // findSets and dropped from the shared refcounter. diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 87651761f..75405e213 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -252,7 +252,7 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false) + fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule) if err != nil { return err } @@ -260,11 +260,8 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { } // familyForRuleID picks the family holding the rule with the given id, using -// the supplied lookup. With refresh set, a miss in both cached maps reloads -// the NAT/DNAT rule maps from the kernel once and re-checks before falling -// back to the v4 family. Filter rules are tracked only in memory and have no -// kernel-backed reload, so their callers pass refresh as false. -func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) { +// the supplied lookup, and falls back to the v4 family on a miss. +func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool) (*family, error) { if has(m.family4, id) { return m.family4, nil } @@ -274,18 +271,6 @@ func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall if has(m.family6, id) { return m.family6, nil } - if !refresh { - return m.family4, nil - } - if err := m.family4.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v4 rules: %w", err) - } - if err := m.family6.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v6 rules: %w", err) - } - if has(m.family6, id) && !has(m.family4, id) { - return m.family6, nil - } return m.family4, nil } @@ -450,32 +435,6 @@ func (m *Manager) Flush() error { return nil } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true) - if err != nil { - return err - } - return r.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index 0ca56409e..4d6eec3c1 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -378,18 +378,6 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { err = manager.AddNatRule(pair) require.NoError(t, err, "failed to add NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "failed to add DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule") - }) - stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } @@ -453,18 +441,6 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { }) require.NoError(t, err, "add v6 NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "add v6 DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule") - }) - stdout, stderr := runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go index d619c5543..e98471e8f 100644 --- a/client/firewall/nftables/routing_linux.go +++ b/client/firewall/nftables/routing_linux.go @@ -459,41 +459,6 @@ func (r *family) RemoveAllLegacyRouteRules() error { return nberrors.FormatErrorOrNil(merr) } -func (r *family) removeNatPreroutingRules() error { - table := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - chain := &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: table, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - } - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from nat table: %w", err) - } - - var merr *multierror.Error - - // Delete rules that have our UserData suffix - for _, rule := range rules { - if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) - } - } - - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - return nberrors.FormatErrorOrNil(merr) -} - func (r *family) RemoveNatRule(pair firewall.RouterPair) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 06312aabf..49c26766a 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -486,16 +486,6 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { return ^uint16(sum) } -// AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network. -func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) { - return nil, errNotSupported -} - -// DeleteDNATRule deletes outbound DNAT rule. -func (m *Manager) DeleteDNATRule(firewall.Rule) error { - return errNotSupported -} - // addPortRedirection adds a port redirection rule. func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index 7e9375771..4d731cbd7 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -42,7 +42,6 @@ import ( dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" @@ -262,11 +261,10 @@ type Engine struct { statusRecorder *peer.Status - firewall firewallManager.Manager - routeManager routemanager.Manager - acl acl.Manager - dnsForwardMgr *dnsfwd.Manager - ingressGatewayMgr *ingressgw.Manager + firewall firewallManager.Manager + routeManager routemanager.Manager + acl acl.Manager + dnsForwardMgr *dnsfwd.Manager dnsServer dns.Server @@ -448,13 +446,6 @@ func (e *Engine) stopLocked() { e.cleanupSSHConfig() - if e.ingressGatewayMgr != nil { - if err := e.ingressGatewayMgr.Close(); err != nil { - log.Warnf("failed to cleanup forward rules: %v", err) - } - e.ingressGatewayMgr = nil - } - if e.srWatcher != nil { e.srWatcher.Close() } @@ -1627,13 +1618,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) done() - // Ingress forward rules - done = e.phase("forward_rules") - if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { - log.Errorf("failed to update forward rules, err: %v", err) - } - done() - log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) done = e.phase("offline_peers") @@ -2733,74 +2717,6 @@ func (e *Engine) setForwarderCapture(pc device.PacketCapture) { } } -func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { - if e.firewall == nil { - log.Warn("firewall is disabled, not updating forwarding rules") - return nil, nil - } - - if len(rules) == 0 { - if e.ingressGatewayMgr == nil { - return nil, nil - } - - err := e.ingressGatewayMgr.Close() - e.ingressGatewayMgr = nil - e.statusRecorder.SetIngressGwMgr(nil) - return nil, err - } - - if e.ingressGatewayMgr == nil { - mgr := ingressgw.NewManager(e.firewall) - e.ingressGatewayMgr = mgr - e.statusRecorder.SetIngressGwMgr(mgr) - } - - var merr *multierror.Error - forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules)) - for _, rule := range rules { - proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err)) - continue - } - - dstPortInfo, err := convertPortInfo(rule.GetDestinationPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid destination port '%v': %w", rule.GetDestinationPort(), err)) - continue - } - - translateIP, err := convertToIP(rule.GetTranslatedAddress()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert translated address '%s': %w", rule.GetTranslatedAddress(), err)) - continue - } - - translatePort, err := convertPortInfo(rule.GetTranslatedPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid translate port '%v': %w", rule.GetTranslatedPort(), err)) - continue - } - - forwardRule := firewallManager.ForwardRule{ - Protocol: proto, - DestinationPort: *dstPortInfo, - TranslatedAddress: translateIP, - TranslatedPort: *translatePort, - } - - forwardingRules = append(forwardingRules, forwardRule) - } - - log.Infof("updating forwarding rules: %d", len(forwardingRules)) - if err := e.ingressGatewayMgr.Update(forwardingRules); err != nil { - log.Errorf("failed to update forwarding rules: %v", err) - } - - return forwardingRules, nberrors.FormatErrorOrNil(merr) -} - // toExcludedLazyPeers returns the peers that must have an always-active // connection: those that are not lazy by policy (the per-peer lazy state or the // account flag, subject to the local override). diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 2db0cd5ed..4449b5788 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -42,7 +42,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "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" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -523,8 +522,8 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/internal/ingressgw/manager.go b/client/internal/ingressgw/manager.go deleted file mode 100644 index 605543d1c..000000000 --- a/client/internal/ingressgw/manager.go +++ /dev/null @@ -1,111 +0,0 @@ -package ingressgw - -import ( - "fmt" - "sync" - - "github.com/hashicorp/go-multierror" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -type DNATFirewall interface { - AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) - DeleteDNATRule(rule firewall.Rule) error -} - -type RulePair struct { - firewall.ForwardRule - firewall.Rule -} - -type Manager struct { - dnatFirewall DNATFirewall - - rules map[firewall.RuleID]RulePair - rulesMu sync.Mutex -} - -func NewManager(dnatFirewall DNATFirewall) *Manager { - return &Manager{ - dnatFirewall: dnatFirewall, - rules: make(map[firewall.RuleID]RulePair), - } -} - -func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - var mErr *multierror.Error - - toDelete := make(map[firewall.RuleID]RulePair, len(h.rules)) - for id, r := range h.rules { - toDelete[id] = r - } - - // Process new/updated rules - for _, fwdRule := range forwardRules { - id := fwdRule.ID() - if _, ok := h.rules[id]; ok { - delete(toDelete, id) - continue - } - - rule, err := h.dnatFirewall.AddDNATRule(fwdRule) - if err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err)) - continue - } - if rule == nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String())) - continue - } - log.Infof("forward rule has been added '%s'", fwdRule) - h.rules[id] = RulePair{ - ForwardRule: fwdRule, - Rule: rule, - } - } - - // Remove deleted rules - for id, rulePair := range toDelete { - if err := h.dnatFirewall.DeleteDNATRule(rulePair.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rulePair.ForwardRule.String(), err)) - } - log.Infof("forward rule has been deleted '%s'", rulePair.ForwardRule) - delete(h.rules, id) - } - - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Close() error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - log.Infof("clean up all (%d) forward rules", len(h.rules)) - var mErr *multierror.Error - for _, rule := range h.rules { - if err := h.dnatFirewall.DeleteDNATRule(rule.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rule, err)) - } - } - - h.rules = make(map[firewall.RuleID]RulePair) - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Rules() []firewall.ForwardRule { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - rules := make([]firewall.ForwardRule, 0, len(h.rules)) - for _, rulePair := range h.rules { - rules = append(rules, rulePair.ForwardRule) - } - - return rules -} diff --git a/client/internal/ingressgw/manager_test.go b/client/internal/ingressgw/manager_test.go deleted file mode 100644 index 0cd40fcc4..000000000 --- a/client/internal/ingressgw/manager_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package ingressgw - -import ( - "fmt" - "net/netip" - "testing" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -var ( - _ firewall.Rule = (*MocFwRule)(nil) - _ DNATFirewall = &MockDNATFirewall{} -) - -type MocFwRule struct { - id firewall.RuleID -} - -func (m *MocFwRule) ID() firewall.RuleID { - return m.id -} - -type MockDNATFirewall struct { - throwError bool -} - -func (m *MockDNATFirewall) AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) { - if m.throwError { - return nil, fmt.Errorf("moc error") - } - - fwRule := &MocFwRule{ - id: fwdRule.ID(), - } - return fwRule, nil -} - -func (m *MockDNATFirewall) DeleteDNATRule(rule firewall.Rule) error { - if m.throwError { - return fmt.Errorf("moc error") - } - return nil -} - -func (m *MockDNATFirewall) forceToThrowErrors() { - m.throwError = true -} - -func TestManager_AddRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - - updates := []firewall.ForwardRule{ - { - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }, - { - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }} - - if err := mgr.Update(updates); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != len(updates) { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UpdateRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - if rules[0].TranslatedAddress.String() != ruleUDP.TranslatedAddress.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].TranslatedPort.String() != ruleUDP.TranslatedPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].DestinationPort.String() != ruleUDP.DestinationPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].Protocol != ruleUDP.Protocol { - t.Errorf("unexpected rule: %v", rules[0]) - } -} - -func TestManager_ExtendRules(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 2 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UnderlingError(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - fw.forceToThrowErrors() - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err == nil { - t.Errorf("expected error") - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_Cleanup(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_DeleteBrokenRule(t *testing.T) { - fw := &MockDNATFirewall{} - - // force to throw errors when Add DNAT Rule - fw.forceToThrowErrors() - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err == nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - // simulate that to remove a broken rule - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } -} - -func TestManager_Close(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} diff --git a/client/internal/message_convert.go b/client/internal/message_convert.go deleted file mode 100644 index 60f19e228..000000000 --- a/client/internal/message_convert.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "net" - "net/netip" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) { - if portInfo == nil { - return nil, errors.New("portInfo cannot be nil") - } - - if portInfo.GetPort() != 0 { - return firewallManager.NewPort(int(portInfo.GetPort())) - } - - if portInfo.GetRange() != nil { - return firewallManager.NewPort(int(portInfo.GetRange().Start), int(portInfo.GetRange().End)) - } - - return nil, fmt.Errorf("invalid portInfo: %v", portInfo) -} - -func convertToIP(rawIP []byte) (netip.Addr, error) { - if rawIP == nil { - return netip.Addr{}, errors.New("input bytes cannot be nil") - } - - if len(rawIP) != net.IPv4len && len(rawIP) != net.IPv6len { - return netip.Addr{}, fmt.Errorf("invalid IP length: %d", len(rawIP)) - } - - if len(rawIP) == net.IPv4len { - return netip.AddrFrom4([4]byte(rawIP)), nil - } - - return netip.AddrFrom16([16]byte(rawIP)), nil -} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 826bf6fe0..6c44178e1 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -18,9 +18,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" - firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/configurer" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/route" @@ -161,7 +159,6 @@ type FullStatus struct { RosenpassState RosenpassState Relays []relay.ProbeResult NSGroupStates []NSGroupState - NumOfForwardingRules int LazyConnectionEnabled bool Events []*proto.SystemEvent } @@ -247,8 +244,6 @@ type Status struct { // read it without taking mux. networksRevision atomic.Uint64 - ingressGwMgr *ingressgw.Manager - routeIDLookup routeIDLookup wgIface WGIfaceStatus } @@ -276,12 +271,6 @@ func (d *Status) SetRelayMgr(manager *relayClient.Manager) { d.relayMgr = manager } -func (d *Status) SetIngressGwMgr(ingressGwMgr *ingressgw.Manager) { - d.mux.Lock() - defer d.mux.Unlock() - d.ingressGwMgr = ingressGwMgr -} - // ReplaceOfflinePeers replaces func (d *Status) ReplaceOfflinePeers(replacement []State) { d.mux.Lock() @@ -332,18 +321,6 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) { return state, nil } -func (d *Status) PeerByIP(ip string) (string, bool) { - d.mux.RLock() - defer d.mux.RUnlock() - - for _, state := range d.peers { - if state.IP == ip { - return state.FQDN, true - } - } - return "", false -} - // PeerStateByIP returns the full peer State for the given tunnel IP. // Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel // address so dual-stack peers are reachable on either family. Only @@ -1163,16 +1140,6 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } -func (d *Status) ForwardingRules() []firewall.ForwardRule { - d.mux.RLock() - defer d.mux.RUnlock() - if d.ingressGwMgr == nil { - return nil - } - - return d.ingressGwMgr.Rules() -} - func (d *Status) GetDNSStates() []NSGroupState { d.mux.RLock() defer d.mux.RUnlock() @@ -1207,7 +1174,6 @@ func (d *Status) GetFullStatus() FullStatus { Relays: d.GetRelayStates(), RosenpassState: d.GetRosenpassState(), NSGroupStates: d.GetDNSStates(), - NumOfForwardingRules: len(d.ForwardingRules()), LazyConnectionEnabled: d.GetLazyConnection(), } @@ -1579,7 +1545,6 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort) pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled - pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes) diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index 3d571e16b..22f7bd07a 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -19,8 +19,7 @@ type IPForwardingState struct { // routingV4/routingV6 track whether the routing path currently holds a // reference, so repeated EnableRouting calls (one per network-map update) - // hold at most one reference per family and an unpaired DisableRouting - // can't release references held by DNAT rules. + // hold at most one reference per family. routingV4 bool routingV6 bool @@ -95,31 +94,6 @@ func (f *IPForwardingState) ReleaseRouting() error { return nil } -// RequestForwarding enables the family's forwarding sysctl on first request. -func (f *IPForwardingState) RequestForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.requestV6() - } - return f.requestV4() -} - -// ReleaseForwarding decrements the family counter. The last v6 release restores -// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other -// tooling (docker, k8s, libvirt). -func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.releaseV6() - } - f.releaseV4() - return nil -} - func (f *IPForwardingState) requestV4() error { if f.v4Count == 0 { if err := systemops.EnableV4IPForwarding(); err != nil { diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go index b4615ff02..75209965c 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -10,8 +10,7 @@ import ( ) // TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request -// releases a previously held routing-owned v6 reference without touching -// references held by DNAT rules. +// releases a previously held routing-owned v6 reference. func TestRequestRoutingV6ToV4Transition(t *testing.T) { f := NewIPForwardingState("wt-fwd-test") @@ -25,13 +24,6 @@ func TestRequestRoutingV6ToV4Transition(t *testing.T) { assert.Equal(t, 1, v4, "v4 reference kept") assert.Equal(t, 0, v6, "routing-owned v6 reference released") - // A DNAT-held reference survives a v4-only routing request. - require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") - require.NoError(t, f.RequestRouting(false), "repeat v4-only request") - _, v6 = f.Counts() - assert.Equal(t, 1, v6, "dnat-held v6 reference survives") - require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") - require.NoError(t, f.ReleaseRouting(), "release routing") v4, v6 = f.Counts() assert.Equal(t, 0, v4, "all v4 references released") diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 7f3ce1bbf..ec0cd6258 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2176,17 +2176,20 @@ func (x *SSHServerState) GetSessions() []*SSHSessionInfo { // FullStatus contains the full state held by the Status instance type FullStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` - SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` - LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` - Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` - Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` - DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` - NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` - Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` - LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` - SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` + SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` + LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` + Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` + Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` + DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Marked as deprecated in daemon.proto. + NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` + Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` + LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` + SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` // networksRevision bumps whenever the set of routed networks (route and // exit-node candidates) or their selected state changes. The UI fingerprints // on it to know when to re-fetch ListNetworks via the push stream, instead @@ -2268,6 +2271,7 @@ func (x *FullStatus) GetDnsServers() []*NSGroupState { return nil } +// Deprecated: Marked as deprecated in daemon.proto. func (x *FullStatus) GetNumberOfForwardingRules() int32 { if x != nil { return x.NumberOfForwardingRules @@ -2600,7 +2604,10 @@ func (x *Network) GetResolvedIPs() map[string]*IPList { return nil } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. +// +// Deprecated: Marked as deprecated in daemon.proto. type PortInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to PortSelection: @@ -2683,6 +2690,7 @@ func (*PortInfo_Port) isPortInfo_PortSelection() {} func (*PortInfo_Range_) isPortInfo_PortSelection() {} +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRule struct { state protoimpl.MessageState `protogen:"open.v1"` Protocol string `protobuf:"bytes,1,opt,name=protocol,proto3" json:"protocol,omitempty"` @@ -2759,6 +2767,7 @@ func (x *ForwardingRule) GetTranslatedPort() *PortInfo { return nil } +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRulesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rules []*ForwardingRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"` @@ -7303,7 +7312,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdf\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -7312,8 +7321,8 @@ const file_daemon_proto_rawDesc = "" + "\x05peers\x18\x04 \x03(\v2\x11.daemon.PeerStateR\x05peers\x12*\n" + "\x06relays\x18\x05 \x03(\v2\x12.daemon.RelayStateR\x06relays\x125\n" + "\vdns_servers\x18\x06 \x03(\v2\x14.daemon.NSGroupStateR\n" + - "dnsServers\x128\n" + - "\x17NumberOfForwardingRules\x18\b \x01(\x05R\x17NumberOfForwardingRules\x12+\n" + + "dnsServers\x12<\n" + + "\x17NumberOfForwardingRules\x18\b \x01(\x05B\x02\x18\x01R\x17NumberOfForwardingRules\x12+\n" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + @@ -7339,22 +7348,22 @@ const file_daemon_proto_rawDesc = "" + "\vresolvedIPs\x18\x05 \x03(\v2 .daemon.Network.ResolvedIPsEntryR\vresolvedIPs\x1aN\n" + "\x10ResolvedIPsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12$\n" + - "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x92\x01\n" + + "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x96\x01\n" + "\bPortInfo\x12\x14\n" + "\x04port\x18\x01 \x01(\rH\x00R\x04port\x12.\n" + "\x05range\x18\x02 \x01(\v2\x16.daemon.PortInfo.RangeH\x00R\x05range\x1a/\n" + "\x05Range\x12\x14\n" + "\x05start\x18\x01 \x01(\rR\x05start\x12\x10\n" + - "\x03end\x18\x02 \x01(\rR\x03endB\x0f\n" + - "\rportSelection\"\x80\x02\n" + + "\x03end\x18\x02 \x01(\rR\x03end:\x02\x18\x01B\x0f\n" + + "\rportSelection\"\x84\x02\n" + "\x0eForwardingRule\x12\x1a\n" + "\bprotocol\x18\x01 \x01(\tR\bprotocol\x12:\n" + "\x0fdestinationPort\x18\x02 \x01(\v2\x10.daemon.PortInfoR\x0fdestinationPort\x12,\n" + "\x11translatedAddress\x18\x03 \x01(\tR\x11translatedAddress\x12.\n" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + - "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort:\x02\x18\x01\"K\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules:\x02\x18\x01\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -7705,7 +7714,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xa3\x1c\n" + + "EXPOSE_TLS\x10\x042\xa6\x1c\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -7716,8 +7725,8 @@ const file_daemon_proto_rawDesc = "" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + "\x0eSelectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12S\n" + - "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12J\n" + - "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x00\x12H\n" + + "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12M\n" + + "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x03\x88\x02\x01\x12H\n" + "\vDebugBundle\x12\x1a.daemon.DebugBundleRequest\x1a\x1b.daemon.DebugBundleResponse\"\x00\x12H\n" + "\vGetLogLevel\x12\x1a.daemon.GetLogLevelRequest\x1a\x1b.daemon.GetLogLevelResponse\"\x00\x12H\n" + "\vSetLogLevel\x12\x1a.daemon.SetLogLevelRequest\x1a\x1b.daemon.SetLogLevelResponse\"\x00\x12E\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3953f9c15..39a8ea7c6 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -45,7 +45,10 @@ service DaemonService { // Deselect specific routes rpc DeselectNetworks(SelectNetworksRequest) returns (SelectNetworksResponse) {} - rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) {} + // Unused; the ingress port-forwarding feature was discontinued. + rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) { + option deprecated = true; + } // DebugBundle creates a debug bundle rpc DebugBundle(DebugBundleRequest) returns (DebugBundleResponse) {} @@ -468,7 +471,8 @@ message FullStatus { repeated PeerState peers = 4; repeated RelayState relays = 5; repeated NSGroupState dns_servers = 6; - int32 NumberOfForwardingRules = 8; + // Unused; the ingress port-forwarding feature was discontinued. + int32 NumberOfForwardingRules = 8 [deprecated = true]; repeated SystemEvent events = 7; @@ -511,8 +515,11 @@ message Network { map resolvedIPs = 5; } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. message PortInfo { + option deprecated = true; + oneof portSelection { uint32 port = 1; Range range = 2; @@ -525,6 +532,8 @@ message PortInfo { } message ForwardingRule { + option deprecated = true; + string protocol = 1; PortInfo destinationPort = 2; string translatedAddress = 3; @@ -533,10 +542,11 @@ message ForwardingRule { } message ForwardingRulesResponse { + option deprecated = true; + repeated ForwardingRule rules = 1; } - // DebugBundler message DebugBundleRequest { bool anonymize = 1; diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474d..c9b291e14 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -95,6 +95,8 @@ type DaemonServiceClient interface { SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) @@ -290,6 +292,7 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe return out, nil } +// Deprecated: Do not use. func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardingRulesResponse) @@ -705,6 +708,8 @@ type DaemonServiceServer interface { SelectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(context.Context, *EmptyRequest) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(context.Context, *DebugBundleRequest) (*DebugBundleResponse, error) diff --git a/client/server/forwardingrules.go b/client/server/forwardingrules.go deleted file mode 100644 index 3d706c36d..000000000 --- a/client/server/forwardingrules.go +++ /dev/null @@ -1,54 +0,0 @@ -package server - -import ( - "context" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/proto" -) - -func (s *Server) ForwardingRules(context.Context, *proto.EmptyRequest) (*proto.ForwardingRulesResponse, error) { - s.mutex.Lock() - defer s.mutex.Unlock() - - rules := s.statusRecorder.ForwardingRules() - responseRules := make([]*proto.ForwardingRule, 0, len(rules)) - for _, rule := range rules { - respRule := &proto.ForwardingRule{ - Protocol: string(rule.Protocol), - DestinationPort: portToProto(rule.DestinationPort), - TranslatedAddress: rule.TranslatedAddress.String(), - TranslatedHostname: s.hostNameByTranslateAddress(rule.TranslatedAddress.String()), - TranslatedPort: portToProto(rule.TranslatedPort), - } - responseRules = append(responseRules, respRule) - - } - - return &proto.ForwardingRulesResponse{Rules: responseRules}, nil -} - -func (s *Server) hostNameByTranslateAddress(ip string) string { - hostName, ok := s.statusRecorder.PeerByIP(ip) - if !ok { - return ip - } - - return hostName -} - -func portToProto(port firewall.Port) *proto.PortInfo { - var portInfo proto.PortInfo - - if !port.IsRange { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(port.Values[0])} - } else { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(port.Values[0]), - End: uint32(port.Values[1]), - }, - } - } - return &portInfo -} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index aa6e99026..bea2e8568 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" @@ -36,7 +36,6 @@ import ( "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -200,8 +199,8 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/status/status.go b/client/status/status.go index 1c204cdb1..e0585b8f0 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -140,28 +140,27 @@ type SSHServerStateOutput struct { } type OutputOverview struct { - Peers PeersStateOutput `json:"peers" yaml:"peers"` - CliVersion string `json:"cliVersion" yaml:"cliVersion"` - DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` - DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` - ManagementState ManagementStateOutput `json:"management" yaml:"management"` - SignalState SignalStateOutput `json:"signal" yaml:"signal"` - Relays RelayStateOutput `json:"relays" yaml:"relays"` - IP string `json:"netbirdIp" yaml:"netbirdIp"` - IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` - PubKey string `json:"publicKey" yaml:"publicKey"` - KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` - WgPort int `json:"wireguardPort" yaml:"wireguardPort"` - FQDN string `json:"fqdn" yaml:"fqdn"` - RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` - RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` - Networks []string `json:"networks" yaml:"networks"` - NumberOfForwardingRules int `json:"forwardingRules" yaml:"forwardingRules"` - NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` - Events []SystemEventOutput `json:"events" yaml:"events"` - LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` - ProfileName string `json:"profileName" yaml:"profileName"` - SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + Peers PeersStateOutput `json:"peers" yaml:"peers"` + CliVersion string `json:"cliVersion" yaml:"cliVersion"` + DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` + DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` + ManagementState ManagementStateOutput `json:"management" yaml:"management"` + SignalState SignalStateOutput `json:"signal" yaml:"signal"` + Relays RelayStateOutput `json:"relays" yaml:"relays"` + IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` + PubKey string `json:"publicKey" yaml:"publicKey"` + KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` + WgPort int `json:"wireguardPort" yaml:"wireguardPort"` + FQDN string `json:"fqdn" yaml:"fqdn"` + RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` + RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` + Networks []string `json:"networks" yaml:"networks"` + NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` + Events []SystemEventOutput `json:"events" yaml:"events"` + LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` + ProfileName string `json:"profileName" yaml:"profileName"` + SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` // SessionExpiresAt is the absolute UTC instant at which the peer's SSO // session expires. nil when the peer is not SSO-tracked or login // expiration is disabled. Pointer (rather than zero-value time.Time) so @@ -190,28 +189,27 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ - Peers: peersOverview, - CliVersion: version.NetbirdVersion(), - DaemonVersion: opts.DaemonVersion, - DaemonStatus: opts.DaemonStatus, - ManagementState: managementOverview, - SignalState: signalOverview, - Relays: relayOverview, - IP: pbFullStatus.GetLocalPeerState().GetIP(), - IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), - PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), - KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), - WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), - FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), - RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), - RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), - Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), - NumberOfForwardingRules: int(pbFullStatus.GetNumberOfForwardingRules()), - NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), - Events: mapEvents(pbFullStatus.GetEvents()), - LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), - ProfileName: opts.ProfileName, - SSHServerState: sshServerOverview, + Peers: peersOverview, + CliVersion: version.NetbirdVersion(), + DaemonVersion: opts.DaemonVersion, + DaemonStatus: opts.DaemonStatus, + ManagementState: managementOverview, + SignalState: signalOverview, + Relays: relayOverview, + IP: pbFullStatus.GetLocalPeerState().GetIP(), + IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), + PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), + KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), + WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), + FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), + RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), + RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), + Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), + NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), + Events: mapEvents(pbFullStatus.GetEvents()), + LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), + ProfileName: opts.ProfileName, + SSHServerState: sshServerOverview, } if !opts.SessionExpiresAt.IsZero() { t := opts.SessionExpiresAt @@ -573,11 +571,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS ) } - var forwardingRulesString string - if o.NumberOfForwardingRules > 0 { - forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) - } - goos := runtime.GOOS goarch := runtime.GOARCH goarm := "" @@ -619,7 +612,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ - "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), daemonVersion, @@ -638,7 +630,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS lazyConnectionEnabledStatus, sshServerStatus, networks, - forwardingRulesString, sessionExpiryString, peersCountString, ) @@ -691,7 +682,6 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) - pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled for _, peerState := range fullStatus.Peers { diff --git a/client/status/status_test.go b/client/status/status_test.go index 2babd9342..1580aca6d 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -378,7 +378,6 @@ func TestParsingToJSON(t *testing.T) { "networks": [ "10.10.0.0/24" ], - "forwardingRules": 0, "dnsServers": [ { "servers": [ @@ -496,7 +495,6 @@ quantumResistance: false quantumResistancePermissive: false networks: - 10.10.0.0/24 -forwardingRules: 0 dnsServers: - servers: - 8.8.8.8:53 diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md index 494812d35..6cc1dd79c 100644 --- a/client/ui/frontend/WAILS-API.md +++ b/client/ui/frontend/WAILS-API.md @@ -10,7 +10,7 @@ Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise` // Services import { Connection, Peers, ProfileSwitcher, Profiles, - Settings, Networks, Forwarding, Debug, Update, WindowManager, + Settings, Networks, Debug, Update, WindowManager, I18n, Preferences, } from "@bindings/services"; @@ -20,7 +20,6 @@ import type { Profile, ProfileRef, ActiveProfile, Config, ConfigParams, SetConfigParams, Features, Network, SelectNetworksParams, - ForwardingRule, PortInfo, PortRange, LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, DebugBundleParams, DebugBundleResult, LogLevel, UpdateResult, UpdateAvailable, UpdateProgress, @@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. -## `Forwarding` - -```ts -Forwarding.List(): Promise -``` - -`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). - ## `Debug` ```ts @@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front `Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. -`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. - -`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). - -`PortRange`: `{ start, end: number }` (inclusive). - `LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. `LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. diff --git a/client/ui/main.go b/client/ui/main.go index 74a87b4df..764562fe7 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -336,7 +336,6 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) app.RegisterService(application.NewService(s.settings)) app.RegisterService(application.NewService(s.networks)) - app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(s.profiles)) app.RegisterService(application.NewService(services.NewDebug(conn))) app.RegisterService(application.NewService(s.update)) diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go deleted file mode 100644 index 4ba979ad0..000000000 --- a/client/ui/services/forwarding.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build !android && !ios && !freebsd && !js - -package services - -import ( - "context" - - "github.com/netbirdio/netbird/client/proto" -) - -// PortRange is a port range; both ends are inclusive. -type PortRange struct { - Start uint32 `json:"start"` - End uint32 `json:"end"` -} - -// PortInfo holds exactly one of Port or Range (the daemon's oneof). -type PortInfo struct { - Port *uint32 `json:"port,omitempty"` - Range *PortRange `json:"range,omitempty"` -} - -// ForwardingRule is one entry from the daemon's reverse-proxy table. -type ForwardingRule struct { - Protocol string `json:"protocol"` - DestinationPort PortInfo `json:"destinationPort"` - TranslatedAddress string `json:"translatedAddress"` - TranslatedHostname string `json:"translatedHostname"` - TranslatedPort PortInfo `json:"translatedPort"` -} - -// Forwarding groups the daemon RPCs that surface exposed/forwarded services. -type Forwarding struct { - conn DaemonConn -} - -func NewForwarding(conn DaemonConn) *Forwarding { - return &Forwarding{conn: conn} -} - -func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { - cli, err := s.conn.Client() - if err != nil { - return nil, err - } - resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) - if err != nil { - return nil, err - } - out := make([]ForwardingRule, 0, len(resp.GetRules())) - for _, r := range resp.GetRules() { - out = append(out, forwardingRuleFromProto(r)) - } - return out, nil -} - -func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { - return ForwardingRule{ - Protocol: r.GetProtocol(), - DestinationPort: portInfoFromProto(r.GetDestinationPort()), - TranslatedAddress: r.GetTranslatedAddress(), - TranslatedHostname: r.GetTranslatedHostname(), - TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), - } -} - -func portInfoFromProto(p *proto.PortInfo) PortInfo { - if p == nil { - return PortInfo{} - } - switch sel := p.GetPortSelection().(type) { - case *proto.PortInfo_Port: - port := sel.Port - return PortInfo{Port: &port} - case *proto.PortInfo_Range_: - r := sel.Range - if r == nil { - return PortInfo{} - } - return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} - } - return PortInfo{} -} diff --git a/go.mod b/go.mod index 35e254f8e..eeb73cd68 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 + github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 diff --git a/go.sum b/go.sum index c193a388f..115bb3373 100644 --- a/go.sum +++ b/go.sum @@ -519,8 +519,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e h1:SnDCreUnY+QDxKPueUoNrniMALCu2VFkXTCr/RIZjdg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9727ff958..b9c27e57e 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -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 { @@ -226,12 +222,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 +263,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 +283,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 +299,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 +433,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 +666,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 +698,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 +718,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 +734,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 +811,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 +842,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 +855,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 +905,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 +923,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 +957,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 +971,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 +1115,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 +1388,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 } diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go index dfbbb2915..74e2553d6 100644 --- a/management/internals/controllers/network_map/controller/controller_test.go +++ b/management/internals/controllers/network_map/controller/controller_test.go @@ -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) diff --git a/management/internals/controllers/network_map/controller/repository_mock.go b/management/internals/controllers/network_map/controller/repository_mock.go index 5246eef4b..9c6b1af4c 100644 --- a/management/internals/controllers/network_map/controller/repository_mock.go +++ b/management/internals/controllers/network_map/controller/repository_mock.go @@ -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() diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 1e8c219b3..f447387b4 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -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) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index 8b104dfa0..5dcd241e1 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -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. diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go index ec6614d81..643105b89 100644 --- a/management/internals/controllers/network_map/nmaptest/canonicalize.go +++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go @@ -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 "" diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index ffce6483e..b6fc81df6 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -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") diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index a9293d266..d9c8ee9d8 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -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()) }) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 4840e40ad..f548e9238 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -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,7 +96,7 @@ 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) } diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index a2aad19b6..a4516be52 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -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, diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index 6ee554e8b..a6421af11 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -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. diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cdd2a7f37..cbf9bb477 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -9,7 +9,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" ) @@ -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; diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 96bd9f1f4..908118aff 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -199,14 +199,6 @@ 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 - } - if networkMap.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index c178b6fa1..6e95a8998 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -955,12 +955,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) } diff --git a/management/server/account.go b/management/server/account.go index 038c5d8db..1b7e4d66c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -84,7 +83,6 @@ type DefaultAccountManager struct { requestBuffer *AccountRequestBuffer - proxyController port_forwarding.Controller settingsManager settings.Manager serviceManager service.Manager @@ -225,7 +223,6 @@ func BuildManager( userDeleteFromIDPEnabled bool, integratedPeerValidator integrated_validator.IntegratedValidator, metrics telemetry.AppMetrics, - proxyController port_forwarding.Controller, settingsManager settings.Manager, permissionsManager permissions.Manager, disableDefaultPolicy bool, @@ -253,7 +250,6 @@ func BuildManager( integratedPeerValidator: integratedPeerValidator, metrics: metrics, requestBuffer: NewAccountRequestBuffer(ctx, store), - proxyController: proxyController, settingsManager: settingsManager, permissionsManager: permissionsManager, disableDefaultPolicy: disableDefaultPolicy, diff --git a/management/server/account_test.go b/management/server/account_test.go index 6067b6023..7dc02b428 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -49,7 +49,6 @@ import ( "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -3661,8 +3660,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) - manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) + manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index d21864cbb..bd1b4c923 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -16,7 +16,6 @@ import ( ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -234,9 +233,9 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createDNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index c3f6a06e0..3f2056c32 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -29,7 +29,6 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/internals/modules/peers" ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server" @@ -88,7 +87,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -102,8 +100,8 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } @@ -228,7 +226,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -242,8 +239,8 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index c7a8af1d2..bb576a71f 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -23,7 +23,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -112,8 +111,8 @@ func createManagerWithEmbeddedIdPModeAndSetup( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, testStore) - networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) - manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) + manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/integrations/port_forwarding/controller.go b/management/server/integrations/port_forwarding/controller.go deleted file mode 100644 index f2ce81839..000000000 --- a/management/server/integrations/port_forwarding/controller.go +++ /dev/null @@ -1,38 +0,0 @@ -package port_forwarding - -import ( - "context" - - "github.com/netbirdio/netbird/management/server/peer" - nbtypes "github.com/netbirdio/netbird/management/server/types" -) - -type Controller interface { - SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) - GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) -} - -type ControllerMock struct { -} - -func NewControllerMock() *ControllerMock { - return &ControllerMock{} -} - -func (c *ControllerMock) SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) { - // noop -} - -func (c *ControllerMock) GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) { - return false, nil -} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 4f8aa8265..4e21ea7a3 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -31,7 +31,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -376,9 +375,9 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config return nil, nil, "", cleanup, err } - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeralMgr, config, nil) accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", - eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { cleanup() diff --git a/management/server/management_test.go b/management/server/management_test.go index 3a8d6ecc2..4d27e6edc 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" pb "github.com/golang/protobuf/proto" //nolint log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -30,7 +30,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -216,7 +215,7 @@ func startServer( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(ctx, str) - networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) + networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) accountManager, err := server.BuildManager( context.Background(), @@ -231,7 +230,6 @@ func startServer( false, server.MockIntegratedValidator{}, metrics, - port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 1460893cf..1e24390be 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -803,9 +802,9 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/peer.go b/management/server/peer.go index 5d5863fa7..8d99bebb0 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1839,15 +1839,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto // validatePeerDelete checks if the peer can be deleted. func (am *DefaultAccountManager) validatePeerDelete(ctx context.Context, transaction store.Store, accountId, peerId string) error { - linkedInIngressPorts, err := am.proxyController.IsPeerInIngressPorts(ctx, accountId, peerId) - if err != nil { - return err - } - - if linkedInIngressPorts { - return status.Errorf(status.PreconditionFailed, "peer is linked to ingress ports: %s", peerId) - } - linked, router := isPeerLinkedToNetworkRouter(ctx, transaction, accountId, peerId) if linked { return status.Errorf(status.PreconditionFailed, "peer is linked to a network router: %s", router.ID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5307300d6..ec4f0ef01 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -40,7 +40,6 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -1226,20 +1225,6 @@ func TestToSyncResponse(t *testing.T) { FirewallRules: []*types.FirewallRule{ {PeerIP: "192.168.1.2", Direction: types.FirewallRuleDirectionIN, Action: string(types.PolicyTrafficActionAccept), Protocol: string(types.PolicyRuleProtocolTCP), Port: "80"}, }, - ForwardingRules: []*types.ForwardingRule{ - { - RuleProtocol: "tcp", - DestinationPorts: types.RulePortRange{ - Start: 1000, - End: 2000, - }, - TranslatedAddress: net.IPv4(192, 168, 1, 2), - TranslatedPorts: types.RulePortRange{ - Start: 11000, - End: 12000, - }, - }, - }, } dnsName := "example.com" checks := []*nmdata.PostureChecks{ @@ -1334,14 +1319,6 @@ func TestToSyncResponse(t *testing.T) { // assert posture checks assert.Equal(t, 1, len(response.Checks)) assert.Equal(t, "/usr/bin/netbird", response.Checks[0].Files[0]) - // assert network map ForwardingRules - assert.Equal(t, 1, len(response.NetworkMap.ForwardingRules)) - assert.Equal(t, proto.RuleProtocol_TCP, response.NetworkMap.ForwardingRules[0].Protocol) - assert.Equal(t, uint32(1000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().Start) - assert.Equal(t, uint32(2000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().End) - assert.Equal(t, net.IPv4(192, 168, 1, 2).To4(), net.IP(response.NetworkMap.ForwardingRules[0].TranslatedAddress)) - assert.Equal(t, uint32(11000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().Start) - assert.Equal(t, uint32(12000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().End) } func Test_RegisterPeerByUser(t *testing.T) { @@ -1373,9 +1350,9 @@ func Test_RegisterPeerByUser(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1464,9 +1441,9 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1623,9 +1600,9 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1708,9 +1685,9 @@ func Test_LoginPeer(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" diff --git a/management/server/route_test.go b/management/server/route_test.go index 69b9aec6c..d4bfa417e 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -1301,9 +1300,9 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index 80052d393..4ebd23f95 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -213,8 +213,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer { } } -// TwinPeer converts a real peer to its slim nmdata twin. Exported for the -// port-forwarding integration, which builds proxy NetworkMaps holding twins. +// TwinPeer converts a real peer to its slim nmdata twin. func TwinPeer(p *nbpeer.Peer) *nmdata.Peer { return twinPeer(p) } diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index 452a2746d..fa9af12f8 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -15,7 +15,6 @@ import ( type FirewallRule = sharedtypes.FirewallRule type NetworkMap = sharedtypes.NetworkMap -type ForwardingRule = sharedtypes.ForwardingRule type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go index 82a18192b..6b8f9574b 100644 --- a/management/server/types/legacynmap/aliases.go +++ b/management/server/types/legacynmap/aliases.go @@ -10,7 +10,6 @@ type ( DNSSettings = types.DNSSettings FirewallRule = sharedtypes.FirewallRule - ForwardingRule = sharedtypes.ForwardingRule Group = types.Group Network = types.Network Policy = types.Policy diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go index 34e709413..d1cae6b63 100644 --- a/management/server/types/legacynmap/converters.go +++ b/management/server/types/legacynmap/converters.go @@ -19,7 +19,6 @@ type NetworkMap struct { OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go index d12e666b8..20770e29b 100644 --- a/management/server/types/legacynmap/equivalence_test.go +++ b/management/server/types/legacynmap/equivalence_test.go @@ -320,7 +320,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) @@ -550,16 +549,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 "" @@ -591,7 +580,6 @@ func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID str {"Routes", len(legacy.Routes), len(updated.Routes), func() string { return diffLists(legacy.Routes, updated.Routes) }}, {"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules), func() string { return diffLists(legacy.FirewallRules, updated.FirewallRules) }}, {"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules), func() string { return diffLists(legacy.RoutesFirewallRules, updated.RoutesFirewallRules) }}, - {"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules), func() string { return diffLists(legacy.ForwardingRules, updated.ForwardingRules) }}, } for _, l := range lens { if l.a != l.b { diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go index 74451b268..36dc35401 100644 --- a/management/server/types/legacynmap/proto_legacy.go +++ b/management/server/types/legacynmap/proto_legacy.go @@ -186,14 +186,6 @@ func ToProtoNetworkMap( pm.RoutesFirewallRules = routesFirewallRules pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 - if nm.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) - for _, rule := range nm.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - pm.ForwardingRules = forwardingRules - } - if nm.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index e6335dccb..c1d394e7e 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -128,8 +127,8 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) - accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) + accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index 6154a6637..7dea4a3f0 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -127,10 +127,6 @@ type Client struct { // see more: https://docs.netbird.io/api/resources/identity-providers IdentityProviders *IdentityProvidersAPI - // Ingress NetBird Ingress Peers APIs - // see more: https://docs.netbird.io/api/resources/ingress-ports - Ingress *IngressAPI - // Instance NetBird Instance API // see more: https://docs.netbird.io/api/resources/instance Instance *InstanceAPI @@ -207,7 +203,6 @@ func (c *Client) initialize() { c.OktaScimIDP = &OktaScimIDPAPI{c} c.EventStreaming = &EventStreamingAPI{c} c.IdentityProviders = &IdentityProvidersAPI{c} - c.Ingress = &IngressAPI{c} c.Instance = &InstanceAPI{c} c.ReverseProxyServices = &ReverseProxyServicesAPI{c} c.ReverseProxyClusters = &ReverseProxyClustersAPI{c} diff --git a/shared/management/client/rest/ingress.go b/shared/management/client/rest/ingress.go deleted file mode 100644 index f69288d7e..000000000 --- a/shared/management/client/rest/ingress.go +++ /dev/null @@ -1,92 +0,0 @@ -package rest - -import ( - "bytes" - "context" - "encoding/json" - - "github.com/netbirdio/netbird/shared/management/http/api" -) - -// IngressAPI APIs for Ingress Peers, do not use directly -type IngressAPI struct { - c *Client -} - -// List all ingress peers -// See more: https://docs.netbird.io/api/resources/ingress#list-all-ingress-peers -func (a *IngressAPI) List(ctx context.Context) ([]api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPeer](resp) - return ret, err -} - -// Get ingress peer info -// See more: https://docs.netbird.io/api/resources/ingress#retrieve-an-ingress-peer -func (a *IngressAPI) Get(ctx context.Context, ingressPeerID string) (*api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Create new ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#create-an-ingress-peer -func (a *IngressAPI) Create(ctx context.Context, request api.PostApiIngressPeersJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/ingress/peers", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Update update ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#update-an-ingress-peer -func (a *IngressAPI) Update(ctx context.Context, ingressPeerID string, request api.PutApiIngressPeersIngressPeerIdJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/ingress/peers/"+ingressPeerID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Delete delete ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#delete-an-ingress-peer -func (a *IngressAPI) Delete(ctx context.Context, ingressPeerID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} diff --git a/shared/management/client/rest/ingress_test.go b/shared/management/client/rest/ingress_test.go deleted file mode 100644 index c915db094..000000000 --- a/shared/management/client/rest/ingress_test.go +++ /dev/null @@ -1,184 +0,0 @@ -//go:build integration - -package rest_test - -import ( - "context" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/shared/management/client/rest" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" -) - -var testIngressPeer = api.IngressPeer{ - Connected: true, - Enabled: true, - Id: "Test", -} - -func TestIngress_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPeer{testIngressPeer}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPeer, ret[0]) - }) -} - -func TestIngress_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPeer) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PostApiIngressPeersJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, "peer-id", req.PeerId) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PutApiIngressPeersIngressPeerIdJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, true, req.Enabled) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Ingress.Delete(context.Background(), "Test") - require.NoError(t, err) - }) -} - -func TestIngress_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Ingress.Delete(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} diff --git a/shared/management/client/rest/peers.go b/shared/management/client/rest/peers.go index b22bcae67..30faff925 100644 --- a/shared/management/client/rest/peers.go +++ b/shared/management/client/rest/peers.go @@ -125,98 +125,6 @@ func (a *PeersAPI) CreateTemporaryAccess(ctx context.Context, peerID string, req return &ret, err } -// PeerIngressPortsAPI APIs for Peer Ingress Ports, do not use directly -type PeerIngressPortsAPI struct { - c *Client - peerID string -} - -// IngressPorts APIs for peer ingress ports -func (a *PeersAPI) IngressPorts(peerID string) *PeerIngressPortsAPI { - return &PeerIngressPortsAPI{ - c: a.c, - peerID: peerID, - } -} - -// List list all ingress port allocations for a peer -// See more: https://docs.netbird.io/api/resources/peers#list-all-ingress-port-allocations -func (a *PeerIngressPortsAPI) List(ctx context.Context) ([]api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPortAllocation](resp) - return ret, err -} - -// Get get ingress port allocation info -// See more: https://docs.netbird.io/api/resources/peers#retrieve-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Get(ctx context.Context, allocationID string) (*api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Create create new ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#create-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Create(ctx context.Context, request api.PostApiPeersPeerIdIngressPortsJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/peers/"+a.peerID+"/ingress/ports", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Update update ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#update-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Update(ctx context.Context, allocationID string, request api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Delete delete ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#delete-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Delete(ctx context.Context, allocationID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} - // PeerJobsAPI APIs for Peer Jobs, do not use directly type PeerJobsAPI struct { c *Client diff --git a/shared/management/client/rest/peers_test.go b/shared/management/client/rest/peers_test.go index 5724b57f9..7370b238b 100644 --- a/shared/management/client/rest/peers_test.go +++ b/shared/management/client/rest/peers_test.go @@ -31,11 +31,6 @@ var ( Name: "test-peer", } - testIngressPortAllocation = api.IngressPortAllocation{ - Enabled: true, - Id: "alloc-1", - } - testJobResponse = api.JobResponse{ Id: "job-1", Status: "pending", @@ -221,146 +216,6 @@ func TestPeers_CreateTemporaryAccess_Err(t *testing.T) { }) } -func TestPeerIngressPorts_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPortAllocation{testIngressPortAllocation}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPortAllocation, ret[0]) - }) -} - -func TestPeerIngressPorts_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - require.NoError(t, err) - }) -} - -func TestPeerIngressPorts_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} - func TestPeerJobs_List_200(t *testing.T) { withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test/jobs", func(w http.ResponseWriter, r *http.Request) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 90b87462f..a8e0a7a56 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -31,9 +31,6 @@ tags: description: View information about the account and network events. - name: Accounts description: View information about the accounts. - - name: Ingress Ports - description: Interact with and view information about the ingress peers and ports. - x-cloud-only: true - name: Identity Providers description: Interact with and view information about identity providers. - name: Services @@ -2439,222 +2436,6 @@ components: - initiator_email - target_id - meta - IngressPeerCreateRequest: - type: object - properties: - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - peer_id - - enabled - - fallback - IngressPeerUpdateRequest: - type: object - properties: - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - enabled - - fallback - IngressPeer: - type: object - properties: - id: - description: ID of the ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: x7p3kqf2rdd8j5zxw4n9 - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - available_ports: - $ref: '#/components/schemas/AvailablePorts' - enabled: - description: Indicates if an ingress peer is enabled - type: boolean - example: true - connected: - description: Indicates if an ingress peer is connected to the management server - type: boolean - example: true - fallback: - description: Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - region: - description: Region of the ingress peer - type: string - example: germany - required: - - id - - peer_id - - ingress_ip - - available_ports - - enabled - - connected - - fallback - - region - AvailablePorts: - type: object - properties: - tcp: - description: Number of available TCP ports left on the ingress peer - type: integer - example: 45765 - udp: - description: Number of available UDP ports left on the ingress peer - type: integer - example: 50000 - required: - - tcp - - udp - IngressPortAllocationRequest: - type: object - properties: - name: - description: Name of the ingress port allocation - type: string - example: Ingress Port Allocation 1 - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - port_ranges: - description: List of port ranges that are forwarded by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationRequestPortRange' - direct_port: - description: Direct port allocation - $ref: '#/components/schemas/IngressPortAllocationRequestDirectPort' - required: - - name - - enabled - IngressPortAllocationRequestPortRange: - type: object - properties: - start: - description: The starting port of the range of forwarded ports - type: integer - example: 80 - end: - description: The ending port of the range of forwarded ports - type: integer - example: 320 - protocol: - description: The protocol accepted by the port range - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - start - - end - - protocol - IngressPortAllocationRequestDirectPort: - type: object - properties: - count: - description: The number of ports to be forwarded - type: integer - example: 5 - protocol: - description: The protocol accepted by the port - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: udp - required: - - count - - protocol - IngressPortAllocation: - type: object - properties: - id: - description: ID of the ingress port allocation - type: string - example: ch8i4ug6lnn4g9hqv7m0 - name: - description: Name of the ingress port allocation - type: string - example: Ingress Peer Allocation 1 - ingress_peer_id: - description: ID of the ingress peer that forwards the ports - type: string - example: x7p3kqf2rdd8j5zxw4n9 - region: - description: Region of the ingress peer - type: string - example: germany - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - port_range_mappings: - description: List of port ranges that are allowed to be used by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationPortMapping' - required: - - id - - name - - ingress_peer_id - - region - - enabled - - ingress_ip - - port_range_mappings - IngressPortAllocationPortMapping: - type: object - properties: - translated_start: - description: The starting port of the translated range of forwarded ports - type: integer - example: 80 - translated_end: - description: The ending port of the translated range of forwarded ports - type: integer - example: 320 - ingress_start: - description: The starting port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1080 - ingress_end: - description: The ending port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1320 - protocol: - description: Protocol accepted by the ports - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - translated_start - - translated_end - - ingress_start - - ingress_end - - protocol NetworkTrafficLocation: type: object properties: @@ -7719,341 +7500,6 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports: - get: - x-cloud-only: true - summary: List all Port Allocations - description: Returns a list of all ingress port allocations for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: query - name: name - schema: - type: string - description: Filters ingress port allocations by name - responses: - '200': - description: A JSON Array of Ingress Port Allocations - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Port Allocation - description: Creates a new ingress port allocation for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - requestBody: - description: New Ingress Port Allocation request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports/{allocationId}: - get: - x-cloud-only: true - summary: Retrieve a Port Allocation - description: Get information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Port Allocation - description: Update information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - requestBody: - description: update an ingress port allocation - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Port Allocation - description: Delete an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers: - get: - x-cloud-only: true - summary: List all Ingress Peers - description: Returns a list of all ingress peers - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - responses: - '200': - description: A JSON Array of Ingress Peers - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Ingress Peer - description: Creates a new ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - requestBody: - description: New Ingress Peer request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerCreateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers/{ingressPeerId}: - get: - x-cloud-only: true - summary: Retrieve a Ingress Peer - description: Get information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Ingress Peer - description: Update information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - requestBody: - description: update an ingress peer - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerUpdateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Ingress Peer - description: Delete an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" /api/setup-keys: get: summary: List all Setup Keys diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 009a9a7a7..9be676a91 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -608,69 +608,6 @@ func (e IdentityProviderType) Valid() bool { } } -// Defines values for IngressPortAllocationPortMappingProtocol. -const ( - IngressPortAllocationPortMappingProtocolTcp IngressPortAllocationPortMappingProtocol = "tcp" - IngressPortAllocationPortMappingProtocolTcpudp IngressPortAllocationPortMappingProtocol = "tcp/udp" - IngressPortAllocationPortMappingProtocolUdp IngressPortAllocationPortMappingProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationPortMappingProtocol enum. -func (e IngressPortAllocationPortMappingProtocol) Valid() bool { - switch e { - case IngressPortAllocationPortMappingProtocolTcp: - return true - case IngressPortAllocationPortMappingProtocolTcpudp: - return true - case IngressPortAllocationPortMappingProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestDirectPortProtocol. -const ( - IngressPortAllocationRequestDirectPortProtocolTcp IngressPortAllocationRequestDirectPortProtocol = "tcp" - IngressPortAllocationRequestDirectPortProtocolTcpudp IngressPortAllocationRequestDirectPortProtocol = "tcp/udp" - IngressPortAllocationRequestDirectPortProtocolUdp IngressPortAllocationRequestDirectPortProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestDirectPortProtocol enum. -func (e IngressPortAllocationRequestDirectPortProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestDirectPortProtocolTcp: - return true - case IngressPortAllocationRequestDirectPortProtocolTcpudp: - return true - case IngressPortAllocationRequestDirectPortProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestPortRangeProtocol. -const ( - IngressPortAllocationRequestPortRangeProtocolTcp IngressPortAllocationRequestPortRangeProtocol = "tcp" - IngressPortAllocationRequestPortRangeProtocolTcpudp IngressPortAllocationRequestPortRangeProtocol = "tcp/udp" - IngressPortAllocationRequestPortRangeProtocolUdp IngressPortAllocationRequestPortRangeProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestPortRangeProtocol enum. -func (e IngressPortAllocationRequestPortRangeProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestPortRangeProtocolTcp: - return true - case IngressPortAllocationRequestPortRangeProtocolTcpudp: - return true - case IngressPortAllocationRequestPortRangeProtocolUdp: - return true - default: - return false - } -} - // Defines values for IntegrationResponsePlatform. const ( IntegrationResponsePlatformDatadog IntegrationResponsePlatform = "datadog" @@ -2600,15 +2537,6 @@ type AgentNetworkUsageBucket struct { TotalTokens int64 `json:"total_tokens"` } -// AvailablePorts defines model for AvailablePorts. -type AvailablePorts struct { - // Tcp Number of available TCP ports left on the ingress peer - Tcp int `json:"tcp"` - - // Udp Number of available UDP ports left on the ingress peer - Udp int `json:"udp"` -} - // AzureIntegration defines model for AzureIntegration. type AzureIntegration struct { // ClientId Azure AD application (client) ID @@ -3461,139 +3389,6 @@ type IdpIntegrationSyncLog struct { Timestamp time.Time `json:"timestamp"` } -// IngressPeer defines model for IngressPeer. -type IngressPeer struct { - AvailablePorts AvailablePorts `json:"available_ports"` - - // Connected Indicates if an ingress peer is connected to the management server - Connected bool `json:"connected"` - - // Enabled Indicates if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // Id ID of the ingress peer - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPeerCreateRequest defines model for IngressPeerCreateRequest. -type IngressPeerCreateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` -} - -// IngressPeerUpdateRequest defines model for IngressPeerUpdateRequest. -type IngressPeerUpdateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` -} - -// IngressPortAllocation defines model for IngressPortAllocation. -type IngressPortAllocation struct { - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Id ID of the ingress port allocation - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // IngressPeerId ID of the ingress peer that forwards the ports - IngressPeerId string `json:"ingress_peer_id"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRangeMappings List of port ranges that are allowed to be used by the ingress peer - PortRangeMappings []IngressPortAllocationPortMapping `json:"port_range_mappings"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPortAllocationPortMapping defines model for IngressPortAllocationPortMapping. -type IngressPortAllocationPortMapping struct { - // IngressEnd The ending port of the range of ingress ports mapped to the forwarded ports - IngressEnd int `json:"ingress_end"` - - // IngressStart The starting port of the range of ingress ports mapped to the forwarded ports - IngressStart int `json:"ingress_start"` - - // Protocol Protocol accepted by the ports - Protocol IngressPortAllocationPortMappingProtocol `json:"protocol"` - - // TranslatedEnd The ending port of the translated range of forwarded ports - TranslatedEnd int `json:"translated_end"` - - // TranslatedStart The starting port of the translated range of forwarded ports - TranslatedStart int `json:"translated_start"` -} - -// IngressPortAllocationPortMappingProtocol Protocol accepted by the ports -type IngressPortAllocationPortMappingProtocol string - -// IngressPortAllocationRequest defines model for IngressPortAllocationRequest. -type IngressPortAllocationRequest struct { - DirectPort *IngressPortAllocationRequestDirectPort `json:"direct_port,omitempty"` - - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRanges List of port ranges that are forwarded by the ingress peer - PortRanges *[]IngressPortAllocationRequestPortRange `json:"port_ranges,omitempty"` -} - -// IngressPortAllocationRequestDirectPort defines model for IngressPortAllocationRequestDirectPort. -type IngressPortAllocationRequestDirectPort struct { - // Count The number of ports to be forwarded - Count int `json:"count"` - - // Protocol The protocol accepted by the port - Protocol IngressPortAllocationRequestDirectPortProtocol `json:"protocol"` -} - -// IngressPortAllocationRequestDirectPortProtocol The protocol accepted by the port -type IngressPortAllocationRequestDirectPortProtocol string - -// IngressPortAllocationRequestPortRange defines model for IngressPortAllocationRequestPortRange. -type IngressPortAllocationRequestPortRange struct { - // End The ending port of the range of forwarded ports - End int `json:"end"` - - // Protocol The protocol accepted by the port range - Protocol IngressPortAllocationRequestPortRangeProtocol `json:"protocol"` - - // Start The starting port of the range of forwarded ports - Start int `json:"start"` -} - -// IngressPortAllocationRequestPortRangeProtocol The protocol accepted by the port range -type IngressPortAllocationRequestPortRangeProtocol string - // InstanceStatus Instance status information type InstanceStatus struct { // SetupRequired Indicates whether the instance requires initial setup @@ -6314,12 +6109,6 @@ type GetApiPeersParams struct { Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } -// GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts. -type GetApiPeersPeerIdIngressPortsParams struct { - // Name Filters ingress port allocations by name - Name *string `form:"name,omitempty" json:"name,omitempty"` -} - // GetApiUsersParams defines parameters for GetApiUsers. type GetApiUsersParams struct { // ServiceUser Filters users and returns either regular users or service users @@ -6401,12 +6190,6 @@ type PostApiIdentityProvidersJSONRequestBody = IdentityProviderRequest // PutApiIdentityProvidersIdpIdJSONRequestBody defines body for PutApiIdentityProvidersIdpId for application/json ContentType. type PutApiIdentityProvidersIdpIdJSONRequestBody = IdentityProviderRequest -// PostApiIngressPeersJSONRequestBody defines body for PostApiIngressPeers for application/json ContentType. -type PostApiIngressPeersJSONRequestBody = IngressPeerCreateRequest - -// PutApiIngressPeersIngressPeerIdJSONRequestBody defines body for PutApiIngressPeersIngressPeerId for application/json ContentType. -type PutApiIngressPeersIngressPeerIdJSONRequestBody = IngressPeerUpdateRequest - // CreateAzureIntegrationJSONRequestBody defines body for CreateAzureIntegration for application/json ContentType. type CreateAzureIntegrationJSONRequestBody = CreateAzureIntegrationRequest @@ -6515,12 +6298,6 @@ type PutApiNetworksNetworkIdRoutersRouterIdJSONRequestBody = NetworkRouterReques // PutApiPeersPeerIdJSONRequestBody defines body for PutApiPeersPeerId for application/json ContentType. type PutApiPeersPeerIdJSONRequestBody = PeerRequest -// PostApiPeersPeerIdIngressPortsJSONRequestBody defines body for PostApiPeersPeerIdIngressPorts for application/json ContentType. -type PostApiPeersPeerIdIngressPortsJSONRequestBody = IngressPortAllocationRequest - -// PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody defines body for PutApiPeersPeerIdIngressPortsAllocationId for application/json ContentType. -type PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody = IngressPortAllocationRequest - // PostApiPeersPeerIdJobsJSONRequestBody defines body for PostApiPeersPeerIdJobs for application/json ContentType. type PostApiPeersPeerIdJobsJSONRequestBody = JobRequest diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index fd9dd6bbd..b2fbbef08 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -15,7 +15,7 @@ import ( // - NetworkMap is the *proto.NetworkMap shape the engine reads today via // update.GetNetworkMap() — built from the envelope's components by // running Calculate() locally + converting back through the shared -// proto helpers + merging the optional ProxyPatch. +// proto helpers. // - Components is the *types.NetworkMapComponents the engine retains so // future incremental delta updates have a base to apply changes // against. The client keeps it under its sync lock. @@ -26,8 +26,8 @@ type EnvelopeResult struct { // EnvelopeToNetworkMap is the full client-side pipeline: decode the // component envelope back to a typed NetworkMapComponents, run Calculate() -// locally to produce the typed NetworkMap, convert it to the wire form the -// engine consumes, and fold in any ProxyPatch the server attached. +// locally to produce the typed NetworkMap and convert it to the wire form the +// engine consumes. // // localPeerKey is the receiving peer's WG pub key (used to derive // includeIPv6 / useSourcePrefixes from the receiving peer's own record in @@ -107,74 +107,12 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } } - if typedNM.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(typedNM.ForwardingRules)) - for _, rule := range typedNM.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - protoNM.ForwardingRules = forwardingRules - } - - // Merge the proxy patch the server attached. Mirrors the legacy - // NetworkMap.Merge step that the server runs after Calculate(). - if full != nil && full.ProxyPatch != nil { - mergeProxyPatch(protoNM, full.ProxyPatch) - } - return &EnvelopeResult{ NetworkMap: protoNM, Components: components, }, nil } -// mergeProxyPatch folds a ProxyPatch's pre-expanded fragments into the -// proto.NetworkMap that Calculate() produced. Mirrors types.NetworkMap.Merge -// — same six collections, deduplicated where the legacy merge dedupes. -func mergeProxyPatch(nm *proto.NetworkMap, patch *proto.ProxyPatch) { - nm.RemotePeers = appendUniquePeers(nm.RemotePeers, patch.Peers) - nm.OfflinePeers = appendUniquePeers(nm.OfflinePeers, patch.OfflinePeers) - nm.FirewallRules = append(nm.FirewallRules, patch.FirewallRules...) - nm.Routes = append(nm.Routes, patch.Routes...) - nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, patch.RouteFirewallRules...) - nm.ForwardingRules = append(nm.ForwardingRules, patch.ForwardingRules...) - if len(nm.RemotePeers) > 0 { - nm.RemotePeersIsEmpty = false - } - if len(nm.FirewallRules) > 0 { - nm.FirewallRulesIsEmpty = false - } - if len(nm.RoutesFirewallRules) > 0 { - nm.RoutesFirewallRulesIsEmpty = false - } -} - -// appendUniquePeers dedupes by WgPubKey — mirrors legacy -// mergeUniquePeersByID's intent (legacy keyed off Peer.ID; in proto form the -// closest stable identifier is WgPubKey). -func appendUniquePeers(dst, extra []*proto.RemotePeerConfig) []*proto.RemotePeerConfig { - if len(extra) == 0 { - return dst - } - seen := make(map[string]struct{}, len(dst)) - for _, p := range dst { - if p == nil { - continue - } - seen[p.WgPubKey] = struct{}{} - } - for _, p := range extra { - if p == nil { - continue - } - if _, ok := seen[p.WgPubKey]; ok { - continue - } - seen[p.WgPubKey] = struct{}{} - dst = append(dst, p) - } - return dst -} - func trimKey(s string) string { if len(s) > 12 { return s[:12] diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 60cfc71fe..de0fdd84d 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -2739,8 +2739,11 @@ type NetworkMap struct { // RoutesFirewallRules represents a list of routes firewall rules to be applied to peer RoutesFirewallRules []*RouteFirewallRule `protobuf:"bytes,10,rep,name=routesFirewallRules,proto3" json:"routesFirewallRules,omitempty"` // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. - RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` - ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` + RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. + ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` // SSHAuth represents SSH authorization configuration SshAuth *SSHAuth `protobuf:"bytes,13,opt,name=sshAuth,proto3" json:"sshAuth,omitempty"` } @@ -2854,6 +2857,7 @@ func (x *NetworkMap) GetRoutesFirewallRulesIsEmpty() bool { return false } +// Deprecated: Do not use. func (x *NetworkMap) GetForwardingRules() []*ForwardingRule { if x != nil { return x.ForwardingRules @@ -4401,19 +4405,18 @@ func (x *RouteFirewallRule) GetRouteID() string { return "" } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ForwardingRule struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Protocol of the forwarding rule - Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node - DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` - // IP address of the translated address (remote peer) to send traffic to - TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` - // Translated port information, where the traffic should be forwarded to - TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` + Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` + DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` + TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` + TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` } func (x *ForwardingRule) Reset() { @@ -4907,8 +4910,8 @@ func (*NetworkMapEnvelope_Delta) isNetworkMapEnvelope_Payload() {} // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. type NetworkMapComponentsFull struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4973,11 +4976,9 @@ type NetworkMapComponentsFull struct { // proto.DNSConfig.ForwarderPort). Computed by the controller from peer // versions; clients fold it into their Calculate() DNS output. DnsForwarderPort int64 `protobuf:"varint,23,opt,name=dns_forwarder_port,json=dnsForwarderPort,proto3" json:"dns_forwarder_port,omitempty"` - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. ProxyPatch *ProxyPatch `protobuf:"bytes,24,opt,name=proxy_patch,json=proxyPatch,proto3" json:"proxy_patch,omitempty"` // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -5179,6 +5180,7 @@ func (x *NetworkMapComponentsFull) GetDnsForwarderPort() int64 { return 0 } +// Deprecated: Do not use. func (x *NetworkMapComponentsFull) GetProxyPatch() *ProxyPatch { if x != nil { return x.ProxyPatch @@ -5193,11 +5195,9 @@ func (x *NetworkMapComponentsFull) GetUserIdClaim() string { return "" } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ProxyPatch struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -7185,7 +7185,7 @@ var file_management_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, + 0x65, 0x22, 0xec, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, @@ -7224,444 +7224,445 @@ var file_management_proto_rawDesc = []byte{ 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x48, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, - 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, - 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, - 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, - 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, - 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, - 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, - 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, - 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, - 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, - 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, - 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, - 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, - 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, - 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, - 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, - 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, - 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, - 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, - 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, - 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, - 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, - 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, - 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, - 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, - 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, - 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, - 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, - 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, - 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, - 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, - 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, - 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, - 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, - 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, - 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, - 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, - 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, - 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, - 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, - 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, - 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, - 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, - 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, - 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, - 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, - 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, - 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, - 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, - 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, - 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, - 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, - 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, - 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, - 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, - 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, - 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, - 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, - 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, - 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, - 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, - 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, - 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, - 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, - 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, - 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, - 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, - 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, - 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, - 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, - 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, - 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, - 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, - 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, - 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, - 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, - 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, - 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, - 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, - 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, - 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, - 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, - 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, - 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, - 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, - 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, + 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, + 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, + 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, + 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, + 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, + 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, + 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, + 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, + 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, + 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, + 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, + 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, + 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, + 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, + 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, + 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, + 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, + 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, + 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, + 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, + 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, + 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, + 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, + 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, + 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, + 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, + 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, + 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, + 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, + 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, + 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, + 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, + 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, + 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, + 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, + 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, + 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, + 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, + 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, + 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf6, 0x01, 0x0a, 0x0e, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, + 0x3a, 0x02, 0x18, 0x01, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, + 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, + 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, + 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, + 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, + 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, + 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, + 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, + 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, + 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, + 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x96, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, - 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, - 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, - 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, - 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, - 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, + 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, + 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, + 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, + 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, + 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, + 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, + 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, + 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, + 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, + 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, + 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, + 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, - 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, - 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, - 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, - 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, - 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, - 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, - 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, + 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, + 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, + 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, + 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, + 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, + 0x72, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, + 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, - 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, - 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, + 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, + 0x08, 0x1a, 0x10, 0x33, 0x22, 0x8b, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, + 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, - 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, - 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, - 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, - 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, + 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, + 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, + 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, + 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x3a, 0x02, + 0x18, 0x01, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index c3d75af11..f7febfe9a 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -471,7 +471,8 @@ message NetworkMap { // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. bool routesFirewallRulesIsEmpty = 11; - repeated ForwardingRule forwardingRules = 12; + // Unused; the ingress port-forwarding feature was discontinued. + repeated ForwardingRule forwardingRules = 12 [deprecated = true]; // SSHAuth represents SSH authorization configuration SSHAuth sshAuth = 13; @@ -752,17 +753,13 @@ message RouteFirewallRule { string RouteID = 10; } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. message ForwardingRule { - // Protocol of the forwarding rule + option deprecated = true; + RuleProtocol protocol = 1; - - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node PortInfo destinationPort = 2; - - // IP address of the translated address (remote peer) to send traffic to bytes translatedAddress = 3; - - // Translated port information, where the traffic should be forwarded to PortInfo translatedPort = 4; } @@ -808,7 +805,7 @@ message StopExposeResponse {} // Component-based NetworkMap wire format (PeerCapabilityComponentNetworkMap). // // Peers that advertise this capability receive NetworkMap building blocks -// (peers + groups + policies + routes + dns + ssh + forwarding) and run the +// (peers + groups + policies + routes + dns + ssh) and run the // expansion (Calculate) locally instead of receiving a fully-expanded // NetworkMap from the server. // ===================================================================== @@ -826,8 +823,8 @@ message NetworkMapEnvelope { // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. message NetworkMapComponentsFull { uint64 serial = 1; @@ -911,12 +908,8 @@ message NetworkMapComponentsFull { // versions; clients fold it into their Calculate() DNS output. int64 dns_forwarder_port = 23; - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. - ProxyPatch proxy_patch = 24; + // Unused; the ingress port-forwarding feature was discontinued. + ProxyPatch proxy_patch = 24 [deprecated = true]; // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -929,12 +922,10 @@ message NetworkMapComponentsFull { reserved 26 to 50; } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. message ProxyPatch { + option deprecated = true; + repeated RemotePeerConfig peers = 1; repeated RemotePeerConfig offline_peers = 2; repeated FirewallRule firewall_rules = 3; diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 1269bac4c..ab61d7cb2 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -1,13 +1,8 @@ package types import ( - "net" - - "golang.org/x/exp/maps" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" - "github.com/netbirdio/netbird/shared/management/proto" ) const ( @@ -25,7 +20,6 @@ type NetworkMap struct { OfflinePeers []*nmdata.Peer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS @@ -33,101 +27,3 @@ type NetworkMap struct { // domain targets. ForceRoutingPeerDNSResolution bool } - -func (nm *NetworkMap) Merge(other *NetworkMap) { - nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = mergeUnique(nm.Routes, other.Routes) - nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) - nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution -} - -func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer { - result := make(map[string]*nmdata.Peer) - for _, peer := range peers1 { - result[peer.ID] = peer - } - for _, peer := range peers2 { - if _, ok := result[peer.ID]; !ok { - result[peer.ID] = peer - } - } - - return maps.Values(result) -} - -type ForwardingRule struct { - RuleProtocol string - DestinationPorts RulePortRange - TranslatedAddress net.IP - TranslatedPorts RulePortRange -} - -func (f *ForwardingRule) ToProto() *proto.ForwardingRule { - var protocol proto.RuleProtocol - switch f.RuleProtocol { - case "icmp": - protocol = proto.RuleProtocol_ICMP - case "tcp": - protocol = proto.RuleProtocol_TCP - case "udp": - protocol = proto.RuleProtocol_UDP - case "all": - protocol = proto.RuleProtocol_ALL - default: - protocol = proto.RuleProtocol_UNKNOWN - } - return &proto.ForwardingRule{ - Protocol: protocol, - DestinationPort: f.DestinationPorts.ToProto(), - TranslatedAddress: ipToBytes(f.TranslatedAddress), - TranslatedPort: f.TranslatedPorts.ToProto(), - } -} - -func (f *ForwardingRule) Equal(other *ForwardingRule) bool { - return f.RuleProtocol == other.RuleProtocol && - f.DestinationPorts.Equal(&other.DestinationPorts) && - f.TranslatedAddress.Equal(other.TranslatedAddress) && - f.TranslatedPorts.Equal(&other.TranslatedPorts) -} - -func ipToBytes(ip net.IP) []byte { - if ip4 := ip.To4(); ip4 != nil { - return ip4 - } - return ip.To16() -} - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - return result -} - -func containsEqual[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go deleted file mode 100644 index 631f38836..000000000 --- a/shared/management/types/network_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type mergeTestObject struct { - value int -} - -func (t mergeTestObject) Equal(other mergeTestObject) bool { - return t.value == other.value -} - -func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{{value: 2}, {value: 3}} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 3) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) - assert.Contains(t, result, mergeTestObject{value: 3}) -} - -func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { - arr1 := []mergeTestObject{} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Empty(t, result) -} - -func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 2) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) -} From 51a9d32cfcf053b652e4588297e6c6ef8708ffac Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:59:26 +0200 Subject: [PATCH 13/70] [client] Warn on missing translations and check placeholders (#8090) --- .github/workflows/ui-translations.yml | 5 +- client/ui/i18n/check-translations.mjs | 130 +++++++++++++++++++------- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml index 24b7c9de2..9ac524495 100644 --- a/.github/workflows/ui-translations.yml +++ b/.github/workflows/ui-translations.yml @@ -36,7 +36,8 @@ jobs: with: node-version: "22" - # English (en) is the source of truth for translation keys; every other - # locale declared in _index.json must carry the exact same key set. + # English (en) is the source of truth for translation keys. Locales declared + # in _index.json fail on orphaned keys or placeholder mismatches; missing + # keys only warn, since they fall back to English at runtime. - name: Check translation key parity run: node client/ui/i18n/check-translations.mjs diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs index bd076e0e0..a6b666337 100644 --- a/client/ui/i18n/check-translations.mjs +++ b/client/ui/i18n/check-translations.mjs @@ -1,13 +1,20 @@ #!/usr/bin/env node -// Validates that every shipped translation bundle carries exactly the same set -// of keys as the English source of truth. English (en) defines the keys; every -// other locale declared in _index.json must match it 1:1: +// Validates every shipped translation bundle against the English source of +// truth. English (en) defines the keys; for every other locale declared in +// _index.json: // -// - no missing keys — a missing key silently falls back to English at runtime -// (see i18n bundle fallback), so the gap never surfaces to users or CI -// without this check; -// - no orphaned keys — keys left behind after an English key is renamed or -// removed are dead weight and a sign the locale is drifting. +// - missing keys are reported as a warning, not a failure — a missing key +// falls back to English at runtime (see i18n bundle fallback), and Crowdin +// exports only translated strings (skip_untranslated_strings in +// crowdin.yml), so a locale is routinely behind English until translators +// catch up; +// - orphaned keys fail — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting; +// - empty messages fail — a present key with an empty, whitespace-only or +// missing message renders blank instead of falling back to English; +// - placeholder mismatches fail — a translation must use exactly the +// {placeholders} of its English string, otherwise a value silently never +// renders (or a literal "{name}" leaks into the UI). // // Pure Node, no dependencies, so it runs without installing the frontend // toolchain. @@ -23,17 +30,40 @@ const SOURCE = "en"; const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); const isCI = Boolean(process.env.GITHUB_ACTIONS); +// Matches the i18next interpolation configured in the frontend +// (prefix "{", suffix "}") and the Go bundle's applyPlaceholders. +const PLACEHOLDER = /\{([^{}\s]+)\}/g; + function readJSON(path) { return JSON.parse(readFileSync(path, "utf8")); } -function keysOf(langCode) { - return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +function messagesOf(langCode) { + const entries = readJSON(join(localesDir, langCode, "common.json")); + const messages = new Map(); + for (const [key, entry] of Object.entries(entries)) { + // null marks an unusable entry (missing or non-string message). + messages.set(key, typeof entry?.message === "string" ? entry.message : null); + } + return messages; } -// Emit a GitHub Actions annotation so failures render inline on the PR diff. -function annotate(file, message) { - if (isCI) console.log(`::error file=${file}::${message}`); +function placeholdersOf(message) { + // Code-point order: placeholder names are identifiers, not prose. + return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1]))].sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }); +} + +function formatPlaceholders(names) { + return names.length ? names.map((n) => `{${n}}`).join(", ") : "none"; +} + +// Emit a GitHub Actions annotation so findings render inline on the PR diff. +function annotate(level, file, message) { + if (isCI) console.log(`::${level} file=${file}::${message}`); } const index = readJSON(join(localesDir, "_index.json")); @@ -44,8 +74,13 @@ if (!declared.includes(SOURCE)) { process.exit(1); } -const sourceKeys = keysOf(SOURCE); -const sourceSet = new Set(sourceKeys); +const source = messagesOf(SOURCE); +const sourceKeys = [...source.keys()]; +const emptySource = sourceKeys.filter((k) => !source.get(k)?.trim()); +if (emptySource.length) { + console.error(`FATAL: ${SOURCE}/common.json has empty or missing messages: ${emptySource.join(", ")}`); + process.exit(1); +} console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); let failed = false; @@ -54,40 +89,67 @@ for (const code of declared) { if (code === SOURCE) continue; const file = `client/ui/i18n/locales/${code}/common.json`; - let keys; + let messages; try { - keys = keysOf(code); + messages = messagesOf(code); } catch (e) { failed = true; const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; console.error(`✗ ${code}: ${msg}`); - annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + annotate("error", "client/ui/i18n/locales/_index.json", `${code}: ${msg}`); continue; } - const set = new Set(keys); - const missing = sourceKeys.filter((k) => !set.has(k)); - const extra = keys.filter((k) => !sourceSet.has(k)); - - if (missing.length === 0 && extra.length === 0) { - console.log(`✓ ${code}: ${keys.length} keys`); - continue; + const missing = sourceKeys.filter((k) => !messages.has(k)); + const extra = [...messages.keys()].filter((k) => !source.has(k)); + const empty = []; + const badPlaceholders = []; + for (const [key, message] of messages) { + if (!source.has(key)) continue; + if (!message?.trim()) { + empty.push(key); + continue; + } + const want = placeholdersOf(source.get(key)); + const got = placeholdersOf(message); + if (want.length !== got.length || want.some((name, i) => name !== got[i])) { + badPlaceholders.push(`${key} (expected ${formatPlaceholders(want)}, got ${formatPlaceholders(got)})`); + } } - failed = true; - console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + const translated = sourceKeys.length - missing.length - empty.length; + const coverage = Math.floor((translated / sourceKeys.length) * 100); + const hasErrors = extra.length > 0 || empty.length > 0 || badPlaceholders.length > 0; + let mark = "✓"; + if (hasErrors) mark = "✗"; + else if (missing.length) mark = "⚠"; + const log = hasErrors ? console.error : console.log; + log(`${mark} ${code}: ${translated}/${sourceKeys.length} keys translated (${coverage}%)`); + if (missing.length) { - console.error(` missing ${missing.length}: ${missing.join(", ")}`); - annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + console.warn(` missing ${missing.length} (falls back to English): ${missing.join(", ")}`); + annotate("warning", file, `Missing ${missing.length} key(s) present in ${SOURCE}, shown in English: ${missing.join(", ")}`); } if (extra.length) { + failed = true; console.error(` extra ${extra.length}: ${extra.join(", ")}`); - annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + annotate("error", file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } + if (empty.length) { + failed = true; + console.error(` empty message ${empty.length} (renders blank): ${empty.join(", ")}`); + annotate("error", file, `Empty or missing message in ${empty.length} key(s), renders blank: ${empty.join(", ")}`); + } + if (badPlaceholders.length) { + failed = true; + console.error(` placeholder mismatch ${badPlaceholders.length}: ${badPlaceholders.join("; ")}`); + annotate("error", file, `Placeholders differ from ${SOURCE} in ${badPlaceholders.length} key(s): ${badPlaceholders.join("; ")}`); } } -// Locale directories present on disk but not declared in _index.json are never -// loaded by the app — surface them so dead translation files don't rot silently. +// Locale directories present on disk but not declared in _index.json are not +// offered in the language picker — surface them so dead translation files don't +// rot silently. const onDisk = readdirSync(localesDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); @@ -98,7 +160,7 @@ if (undeclared.length) { console.log(); if (failed) { - console.error("Translation check FAILED — every locale must match the English key set."); + console.error("Translation check FAILED — fix orphaned keys, empty messages and placeholder mismatches above."); process.exit(1); } -console.log("Translation check passed — all locales match the English key set."); +console.log("Translation check passed — no orphaned keys, empty messages or placeholder mismatches."); From d3d27dc08d40959fc0685c4979c4da67a643257d Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:21 +0200 Subject: [PATCH 14/70] [client] Only offer locales listed in _index.json (#8091) --- client/ui/frontend/src/lib/i18n.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index 87cc996dd..25f2b282a 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null { return null; } -// An empty persisted language code is the Go-side signal for first run. +// Only locales listed in _index.json are shipped. A bundle on disk without an +// _index.json row (e.g. a new Crowdin language not released yet) must not be +// auto-detected or loaded, matching the Go side, which rejects saving it. +async function shippedLanguages(): Promise { + const onDisk = Object.keys(resources); + try { + const listed = new Set((await I18n.Languages()).map((l) => l.code)); + return onDisk.filter((code) => listed.has(code)); + } catch (e) { + console.warn("load shipped languages failed, using all bundled locales", e); + return onDisk; + } +} + +// An empty persisted language code is the Go-side signal for first run. A +// persisted code that is no longer shipped is treated the same way. export async function initI18n(): Promise { - const available = Object.keys(resources); + const available = await shippedLanguages(); let language = "en"; let firstRun = false; try { const prefs = await Preferences.Get(); - if (prefs?.language) { + if (prefs?.language && available.includes(prefs.language)) { language = prefs.language; } else { firstRun = true; @@ -68,7 +83,7 @@ export async function initI18n(): Promise { fallbackLng: "en", defaultNS: "common", ns: ["common"], - resources, + resources: Object.fromEntries(available.map((code) => [code, resources[code]])), interpolation: { prefix: "{", suffix: "}", From 2b9c36dc4901d888f4f16c3feb45d223c4beacbc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 17:33:54 +0200 Subject: [PATCH 15/70] [client] Close the UI before InstallValidate in the MSI (#7395) * [client] Close the UI before InstallValidate in the MSI The WiX CloseApplication action runs deferred, right before InstallFiles. By then InstallValidate has already asked Restart Manager about the files in use. When msiexec runs as LocalSystem (third-party deployment tools, scheduled tasks) and netbird-ui.exe runs in the interactive user's session, Restart Manager reports a session mismatch, so the installer schedules the UI binary for replacement on the next reboot and returns 3010. Killing the UI afterwards is too late, the file stays on the old version until a real reboot happens. Replace the CloseApplication with an immediate WixQuietExec custom action running taskkill /F /IM netbird-ui.exe, scheduled before InstallValidate. The file is no longer held open when the in-use check runs, InstallFiles overwrites it directly and no reboot is scheduled. Return is ignored because taskkill exits non-zero when no UI is running. The action also runs on uninstall so removal does not require a reboot either. * Keep the deferred UI close as a fallback for the immediate taskkill The immediate taskkill runs with the token of whoever launched msiexec. A non-SYSTEM install cannot terminate a UI running in another user's session, so keep the deferred CloseApplication, which runs as LocalSystem, to stop it before InstallFiles. When taskkill already succeeded it finds no process. * Document why the UI kill runs before InstallValidate --- client/netbird.wxs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/netbird.wxs b/client/netbird.wxs index f30a7aa7e..156b4ff27 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -76,6 +76,14 @@ + + + + nblink (loopback, userspace peer) --tunnel--> proxy --http--> vllm +// +// The regular agent needs NET_ADMIN and a TUN device to put the endpoint on +// the network. nblink runs the same client in netstack mode and hands the +// endpoint to whatever can reach a loopback port, so the container is granted +// no capabilities and mounts no device — the whole point of the binary, and +// what fails first if netstack mode regresses. +// +// Three things are being proved together, and none of them holds on its own: +// +// - The forward reaches the endpoint, which means the endpoint's name +// resolved inside the process. Nothing on the container can resolve it: +// the synthesized zone is served by the in-process DNS, /etc/resolv.conf +// is never touched, and a name that leaked to the host resolver would be +// NXDOMAIN. +// - The forwarder's peer is a peer like any other. Its request is authorized +// against the policy that names its group, metered, and written to the +// access log under its own identity. +// - The loopback listener still refuses what a browser can aim at it, which +// is the one new attack surface a forward on localhost creates. +// +// One forwarder carries most of the cases, because each one costs a session: +// it serves three forwards, reads its setup key from a file, and keeps its +// identity on a volume so the last case can restart it. +func TestNBLinkReachesAgentNetwork(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM server") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + // allowed holds the forwarder's peer and is named by the policy. denied + // holds a second forwarder's peer and is named by nothing, so the proxy + // has no policy to authorize it under. + allowed, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-nblink-allowed"}) + require.NoError(t, err, "create allowed group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), allowed.Id) }) + + denied, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-nblink-denied"}) + require.NoError(t, err, "create denied group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), denied.Id) }) + + allowedKey := mintSetupKey(ctx, t, "e2e-nblink-allowed-key", allowed.Id) + deniedKey := mintSetupKey(ctx, t, "e2e-nblink-denied-key", denied.Id) + + dummyKey := "sk-nblink-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "nblink-vllm", + ProviderId: "vllm", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // A second provider on the mock's streaming listener, which answers every + // request as server-sent events. + streamProv, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "nblink-stream", + ProviderId: "anthropic_api", + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: streamedModel, InputPer1k: streamInRate, OutputPer1k: streamOutRate}, + }, + }) + require.NoError(t, err, "create streaming provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), streamProv.Id) }) + + // Caps far above what this test drives, so the limit never blocks but + // usage metering is switched on — that is what writes consumption rows. + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-nblink-allow", + Enabled: ptr(true), + SourceGroups: []string{allowed.Id}, + DestinationProviderIds: []string{prov.Id, streamProv.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-nblink-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + stateVolume := fmt.Sprintf("e2e-nblink-state-%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = harness.RemoveDockerVolume(context.Background(), stateVolume) }) + + // The upstream is the endpoint itself, over HTTPS. nblink verifies the + // certificate like any Go client, so it needs the proxy's self-signed cert + // — the curl helpers elsewhere in this suite sidestep that with -k. + forward := nblinkForward(harness.NBLinkPort, "https://"+settings.Endpoint) + nbOpts := []harness.NBLinkOption{ + harness.WithNBLinkForwards( + nblinkForward(nblinkSecondPort, "https://"+settings.Endpoint), + nblinkForward(nblinkDeadPort, nblinkDeadUpstream), + fmt.Sprintf("http://0.0.0.0:%d=https://%s", nblinkPublicPort, settings.Endpoint), + ), + harness.WithNBLinkEnv("NB_ALLOW_PUBLIC_BIND", "true"), + // The name other containers use for the public forward. + harness.WithNBLinkEnv("NB_ALLOWED_HOST", "nblink"), + harness.WithNBLinkSetupKeyFile(), + harness.WithNBLinkStateVolume(stateVolume), + } + nb, err := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), nbOpts...) + require.NoError(t, err, "start nblink") + // nb is replaced by the restart case, so the cleanup reads the variable + // rather than the first value. + t.Cleanup(func() { _ = nb.Terminate(context.Background()) }) + + diag := func() string { + return "\n=== nblink logs ===\n" + nb.Logs(context.Background()) + + "\n=== proxy logs ===\n" + px.Logs(context.Background()) + } + + before, _ := srv.ListAccessLogs(ctx) + sessionID := "e2e-session-nblink" + + // Retry to absorb the beat between the session coming up and the proxy + // peer being reachable; the forward itself is already bound by now. + code, body := chatUntil(ctx, t, nb, 200, 120*time.Second, sessionID) + require.Equal(t, 200, code, + "a chat through the nblink forward must be served; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", + "the body should be the upstream's OpenAI-compatible completion; got: %s", body) + + var peer api.Peer + t.Run("the peer registers under its hostname", func(t *testing.T) { + peers := peersWithHostname(ctx, t, nb.Hostname()) + require.Len(t, peers, 1, "exactly one peer must carry the forwarder's hostname %q", nb.Hostname()) + peer = peers[0] + assert.True(t, peerInGroup(peer, allowed.Id), + "the setup key read from the file must have joined the peer to its auto-group") + }) + + t.Run("the request is attributed to the forwarder's own peer", func(t *testing.T) { + require.NotEmpty(t, peer.Ip, "the peer lookup must have run first") + + var row *api.AgentNetworkAccessLog + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil || logs.TotalRecords <= before.TotalRecords { + return false + } + for i, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + row = &logs.Data[i] + return true + } + } + return false + }, 30*time.Second, 2*time.Second, + "session %q must reach the access log; a forward is not a way around accounting%s", sessionID, diag()) + require.NotNil(t, row.SourceIp, "the access log row must carry the caller's overlay address") + assert.Equal(t, peer.Ip, *row.SourceIp, + "the access log must name the forwarder's peer as the caller") + + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "the upstream's usage must be metered into a consumption row") + }) + + t.Run("every forward in one process serves over the same session", func(t *testing.T) { + scode, sbody, serr := nb.PostOn(ctx, nblinkSecondPort, harness.ChatPath, + harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong"), nil) + require.NoError(t, serr, "the probe must reach the second listener") + assert.Equal(t, 200, scode, "the second forward must be served too; body: %s%s", sbody, diag()) + assert.Contains(t, sbody, "chat.completion", "the second forward must carry the upstream completion back") + }) + + t.Run("an upstream nothing serves answers 502 without hanging", func(t *testing.T) { + start := time.Now() + dcode, dbody, derr := nb.PostOn(ctx, nblinkDeadPort, harness.ChatPath, + harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong"), nil) + elapsed := time.Since(start) + require.NoError(t, derr, "the probe must reach the third listener") + assert.Equal(t, 502, dcode, "a forward whose upstream cannot be dialled must answer 502; body: %s", dbody) + assert.Contains(t, dbody, nblinkUnreachableBody, "the 502 must be the forwarder's own answer") + // The forwarder bounds a dial at 30s; anything near curl's 90s limit + // means a request was left hanging. + assert.Less(t, elapsed, 40*time.Second, "an unreachable upstream must fail fast, took %s", elapsed) + }) + + t.Run("a streamed reply arrives as events and is metered", func(t *testing.T) { + streamSession := "e2e-session-nblink-stream" + body := fmt.Sprintf(`{"model":%q,"max_tokens":64,"stream":true,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, streamedModel) + scode, sbody, serr := nb.Post(ctx, "/v1/messages", body, + []string{"anthropic-version: 2023-06-01", "x-session-id: " + streamSession}) + require.NoError(t, serr, "the probe must reach the listener") + require.Equal(t, 200, scode, "a streamed request must be served; body: %s%s", sbody, diag()) + assert.Contains(t, sbody, "event: message_start", "the caller must receive the event stream itself") + assert.Contains(t, sbody, "message_stop", "the stream must arrive complete") + + row := findAccessLogBySession(t, ctx, streamSession) + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "the proxy must meter a stream carried by the forwarder like any other") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must be metered from the stream") + }) + + t.Run("only the public forward is reachable from the network", func(t *testing.T) { + chat := harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong") + + pcode, pbody, perr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, nil) + require.NoError(t, perr, "the public forward must accept a connection from another container") + assert.Equal(t, 200, pcode, "the public forward must serve a caller under a listed name; body: %s", pbody) + + // The shape a rebound page sends: its own name, with Origin and + // Sec-Fetch-Site agreeing that the request is same-origin. + rcode, rbody, rerr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, []string{ + "Host: attacker.example:8083", "Origin: http://attacker.example:8083", "Sec-Fetch-Site: same-origin", + }) + require.NoError(t, rerr) + assert.Equal(t, 421, rcode, "a public forward must refuse a name it was not given; body: %s", rbody) + assert.Contains(t, nb.Logs(ctx), fmt.Sprintf("forwarding http://0.0.0.0:%d", nblinkPublicPort), + "a 0.0.0.0 forward must bind the IPv4 wildcard, not the dual-stack one") + + // The Host check only protects loopback listeners, but the browser + // checks still hold on a public one. + ocode, _, oerr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, + []string{"Origin: https://attacker.example"}) + require.NoError(t, oerr) + assert.Equal(t, 403, ocode, "a cross-origin browser request must be refused on a public forward too") + + _, _, lerr := nb.PostFromNetwork(ctx, harness.NBLinkPort, harness.ChatPath, chat, nil) + assert.Error(t, lerr, "a loopback forward must refuse connections from other machines") + }) + + t.Run("the logs show no secrets and no side effects on the host", func(t *testing.T) { + // The suite runs at debug level, the most an operator can turn on. + logs := nb.Logs(ctx) + require.Contains(t, logs, "over the overlay", "the logs must have been read") + assert.NotContains(t, logs, allowedKey, "the setup key must not be logged") + assert.Contains(t, logs, "NAT port mapper disabled", "nblink must not ask the router for a port mapping") + assert.NotContains(t, logs, "/var/lib/netbird", "nblink must not touch an installed agent's state") + assert.NotContains(t, logs, "failed to login to Management Service", + "registering a new peer must not log an error") + }) + + t.Run("a peer no policy names cannot reach the endpoint", func(t *testing.T) { + other, oerr := harness.StartNBLink(ctx, srv, deniedKey, forward, px.CACertPath(), + harness.WithNBLinkName("nblink-denied")) + require.NoError(t, oerr, "start the second forwarder") + t.Cleanup(func() { _ = other.Terminate(context.Background()) }) + otherDiag := func() string { + return "\n=== denied nblink logs ===\n" + other.Logs(context.Background()) + + "\n=== proxy logs ===\n" + px.Logs(context.Background()) + } + + // Management gives the endpoint's DNS record, the proxy peer and the + // rule allowing 443 to it only to peers in an enabled policy's source + // groups. This peer gets none of them, so it is refused in the overlay + // itself and the request never reaches the proxy's router. The + // forwarder reports that as its own 502. + // + // Keep asking for as long as the allowed forwarder needed to be served, + // so a 502 cannot be blamed on a tunnel that had not settled yet. + deniedSession := "e2e-session-nblink-denied" + deadline := time.Now().Add(60 * time.Second) + var ocode int + var obody string + for time.Now().Before(deadline) { + ocode, obody, _ = other.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", deniedSession) + if ocode != 502 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 502, ocode, + "a forwarder whose peer is in no policy's source group must not get through; body: %s%s", obody, otherDiag()) + assert.Contains(t, obody, nblinkUnreachableBody, + "the refusal must be the forwarder failing to dial, not an answer from the proxy; body: %s", obody) + assert.NotContains(t, obody, "chat.completion", "the refusal must not carry an upstream completion") + + // The forwarder logs why the dial failed at debug level, which this + // suite runs at. Finding the line ties the 502 to this upstream rather + // than to some other failure inside the forwarder. + dialFailure := fmt.Sprintf("dial %s:443 over the overlay failed", settings.Endpoint) + logs := other.Logs(ctx) + require.Contains(t, logs, dialFailure, "the 502 must come from the overlay dial; logs:\n%s", logs) + for _, line := range strings.Split(logs, "\n") { + if strings.Contains(line, dialFailure) { + t.Logf("denied forwarder: %s", strings.TrimSpace(line)) + break + } + } + + // The allowed forwarder is still served at the same moment, so the + // refusal is about this peer and not about the endpoint being down. + acode, abody := chatUntil(ctx, t, nb, 200, 30*time.Second, "") + assert.Equal(t, 200, acode, "the allowed forwarder must still be served; body: %s", abody) + + rows, lerr := srv.ListAccessLogs(ctx) + require.NoError(t, lerr, "list access logs") + for _, r := range rows.Data { + assert.False(t, r.SessionId != nil && *r.SessionId == deniedSession, + "a request that never reached the proxy must leave no access log row") + } + }) + + t.Run("the loopback listener refuses browser-driven callers", func(t *testing.T) { + // A page the user visits can aim a request at a loopback listener in + // several shapes. Each must be refused before anything is proxied + // under the peer's identity, while an ordinary local caller is + // unaffected. + cases := []struct { + name string + headers []string + want int + }{ + { + name: "a foreign Host, which is DNS rebinding", + headers: []string{"Host: attacker.example"}, + want: 421, + }, + { + name: "a cross-origin fetch", + headers: []string{"Origin: https://attacker.example"}, + want: 403, + }, + { + name: "a page on another local port, which is cross-origin too", + // Same machine, different origin: localhost:3000 has no more + // claim on this listener than any other site. + headers: []string{"Origin: http://localhost:3000"}, + want: 403, + }, + { + name: "an embedded no-cors GET, which carries no Origin at all", + // The shape the Host and Origin checks both miss: a loopback + // Host and no Origin. Sec-Fetch-Site is the only signal left. + headers: []string{"Sec-Fetch-Site: cross-site"}, + want: 403, + }, + } + // Every case sends the request already proven to return 200, so the + // only difference between a refusal and a success is the header under + // test. + body := harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gcode, gbody, gerr := nb.Post(ctx, harness.ChatPath, body, tc.headers) + require.NoError(t, gerr, "the probe itself must reach the listener") + assert.Equal(t, tc.want, gcode, + "%s must be refused by the listener, not proxied; body: %s", tc.name, gbody) + assert.NotContains(t, gbody, "chat.completion", + "a refused request must never carry an upstream completion back") + }) + } + + // The control: the same request with none of those headers is served, + // so the cases above prove a guard rather than a broken forward. + gcode, gbody, gerr := nb.Post(ctx, harness.ChatPath, body, nil) + require.NoError(t, gerr) + assert.Equal(t, 200, gcode, + "a plain local caller must still be served; body: %s%s", gbody, diag()) + }) + + t.Run("an upstream whose certificate is not trusted is refused", func(t *testing.T) { + // No CA mounted: the proxy's self-signed certificate is unknown to the + // forwarder, which must refuse it rather than proxy in the clear. + untrusted, uerr := harness.StartNBLink(ctx, srv, allowedKey, forward, "", + harness.WithNBLinkName("nblink-untrusted")) + require.NoError(t, uerr, "start the forwarder without the proxy's CA") + t.Cleanup(func() { _ = untrusted.Terminate(context.Background()) }) + + // Until the tunnel settles the dial itself fails with the same 502, so + // wait for the handshake failure specifically. + var ucode int + var ubody string + require.Eventually(t, func() bool { + ucode, ubody, _ = untrusted.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", "") + return strings.Contains(untrusted.Logs(ctx), "x509") + }, 120*time.Second, 5*time.Second, "the forwarder must reach the TLS handshake and fail it; logs:\n%s", untrusted.Logs(ctx)) + assert.Equal(t, 502, ucode, "an unverified upstream must not be served; body: %s", ubody) + assert.NotContains(t, ubody, "chat.completion", "no completion may come back over an unverified connection") + }) + + t.Run("an arbitrary UID in group 0 keeps its identity on a volume", func(t *testing.T) { + // OpenShift's restricted SCC runs the image as a UID from the + // namespace range with group 0. The state directory is group 0 and + // group-writable, and a fresh volume inherits that. + const name = "nblink-openshift" + volume := fmt.Sprintf("e2e-nblink-openshift-%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = harness.RemoveDockerVolume(context.Background(), volume) }) + opts := []harness.NBLinkOption{ + harness.WithNBLinkName(name), + harness.WithNBLinkUser("1000770000:0"), + harness.WithNBLinkStateVolume(volume), + harness.WithNBLinkSetupKeyFile(), + } + + for range 2 { + fwdr, oerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), opts...) + require.NoError(t, oerr, "start the forwarder as an arbitrary UID") + // Registered before the assertions below so a failure between here + // and the explicit Terminate cannot leave a container holding the + // volume the cleanup above tries to remove. + t.Cleanup(func() { _ = fwdr.Terminate(context.Background()) }) + ocode, obody := chatUntil(ctx, t, fwdr, 200, 120*time.Second, "") + assert.Equal(t, 200, ocode, "the forwarder must serve as an arbitrary UID; body: %s", obody) + require.NoError(t, fwdr.Terminate(ctx), "stop the forwarder") + } + assert.Len(t, peersWithHostname(ctx, t, name), 1, + "an arbitrary UID must be able to persist the identity it registered") + }) + + t.Run("without a state dir every start is a new peer", func(t *testing.T) { + const name = "nblink-ephemeral" + for range 2 { + eph, eerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), harness.WithNBLinkName(name)) + require.NoError(t, eerr, "start the in-memory forwarder") + t.Cleanup(func() { _ = eph.Terminate(context.Background()) }) + require.NoError(t, eph.Terminate(ctx), "stop the in-memory forwarder") + } + peers := peersWithHostname(ctx, t, name) + assert.Len(t, peers, 2, "each in-memory start must register its own peer, as the README warns") + }) + + // Last, because it replaces the forwarder every case above used. + t.Run("a state dir keeps the same peer across restarts", func(t *testing.T) { + require.NotEmpty(t, peer.Id, "the peer lookup must have run first") + + // docker stop sends SIGTERM. The forwarder must drain and exit cleanly + // well inside the grace period rather than be killed at its end. + exitCode, took, serr := nb.Stop(ctx, 30*time.Second) + require.NoError(t, serr, "stop the first forwarder") + assert.Equal(t, 0, exitCode, "SIGTERM must be a clean exit") + assert.Less(t, took, 15*time.Second, "shutdown must not wait out the grace period") + assert.Contains(t, nb.Logs(ctx), "shutting down", "the forwarder must log the signal it handled") + require.NoError(t, nb.Terminate(ctx), "remove the first forwarder") + + restarted, rerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), nbOpts...) + require.NoError(t, rerr, "start the forwarder again on the same volume") + nb = restarted + + peers := peersWithHostname(ctx, t, nb.Hostname()) + require.Len(t, peers, 1, + "a restart on the same state dir must not register a second peer; got %d", len(peers)) + assert.Equal(t, peer.Id, peers[0].Id, "the restarted forwarder must come back as the same peer") + + rcode, rbody := chatUntil(ctx, t, nb, 200, 120*time.Second, "") + assert.Equal(t, 200, rcode, "the restarted forwarder must be served again; body: %s%s", rbody, diag()) + }) +} + +// TestNBLinkWithoutManagement covers a forwarder that can never bring its +// session up. It must fail rather than hang, and a signal while it is trying +// must stop it straight away. +func TestNBLinkWithoutManagement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + env := map[string]string{ + "NB_MANAGEMENT_URL": "https://management.invalid", + "NB_SETUP_KEY": "11111111-2222-3333-4444-555555555555", + "NB_FORWARD": "http://8080=https://grafana.netbird.cloud", + } + + t.Run("an unreachable management server fails the start", func(t *testing.T) { + start := time.Now() + res, err := harness.RunNBLinkIsolated(ctx, env) + took := time.Since(start) + require.NoError(t, err, "run the forwarder") + assert.NotEqual(t, 0, res.ExitCode, "a session that cannot come up must be an error; stderr: %s", res.Stderr) + assert.Contains(t, res.Stderr, "start client", "the error must say the session did not start") + assert.NotContains(t, res.Stderr, "over the overlay", "no forward may be announced without a session") + // startTimeout is 90s; past that something is waiting without a bound. + assert.Less(t, took, 120*time.Second, "the start must give up within its timeout") + }) + + t.Run("SIGTERM during startup exits at once", func(t *testing.T) { + exitCode, took, logs, err := harness.StopNBLinkDuringStartup(ctx, env, 3*time.Second, 30*time.Second) + require.NoError(t, err, "start and stop the forwarder") + assert.Equal(t, 0, exitCode, "a signal during startup is a clean exit; logs:\n%s", logs) + assert.Less(t, took, 5*time.Second, "the signal must not wait for the management dial to time out") + }) +} + +// TestNBLinkCheck runs the image with --check, which must validate the +// configuration and print it without starting a session. The container has no +// network, so a run that tried to reach management could not exit 0. +func TestNBLinkCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + setupKey := "11111111-2222-3333-4444-555555555555" + + t.Run("a valid configuration prints its forwards and exits 0", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_MANAGEMENT_URL": "https://management.invalid", + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": "http://8080=https://one.netbird.cloud,http://127.0.0.1:0=http://two.netbird.cloud:8000/v1", + }) + require.NoError(t, err, "run the check") + require.Equal(t, 0, res.ExitCode, "a valid configuration must pass; stderr: %s", res.Stderr) + assert.Contains(t, res.Stdout, "forwards: 2", "both comma-separated forwards must be parsed") + assert.Contains(t, res.Stdout, "127.0.0.1:8080 -> https://one.netbird.cloud", + "a port-only spec must bind loopback") + assert.Contains(t, res.Stdout, "127.0.0.1:0 -> http://two.netbird.cloud:8000/v1", + "the second forward must be printed as given") + assert.Contains(t, res.Stdout, "setup-key: true", "the check must report that a key is set") + assert.NotContains(t, res.Stdout+res.Stderr, setupKey, "the check must never print the key itself") + assert.NotContains(t, res.Stderr, "connecting to", "the check must not start a session") + }) + + t.Run("an unsupported forward exits non-zero and prints nothing", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": "http://8080=https://one.netbird.cloud,tcp://5432=db.netbird.cloud:5432", + }) + require.NoError(t, err, "run the check") + assert.NotEqual(t, 0, res.ExitCode, "a bad spec must fail the check") + assert.Empty(t, strings.TrimSpace(res.Stdout), "a failed check must not print a configuration") + assert.Contains(t, res.Stderr, "tcp forwarding is not supported yet", + "the error must name the spec that failed") + }) + + t.Run("a public bind without the opt-in exits non-zero", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_FORWARD": "http://0.0.0.0:8080=https://one.netbird.cloud", + }) + require.NoError(t, err, "run the check") + assert.NotEqual(t, 0, res.ExitCode, "binding beyond loopback must need --allow-public-bind") + assert.Contains(t, res.Stderr, "NB_ALLOW_PUBLIC_BIND", "the error must say how to opt in") + }) +} + +// nblinkForward builds a forward spec: a loopback listener on port, proxying +// to upstream over the overlay. +// +// The listener stays on loopback, so --allow-public-bind is never passed and +// the default an operator gets is what the suite exercises. +func nblinkForward(port int, upstream string) string { + return fmt.Sprintf("http://127.0.0.1:%d=%s", port, upstream) +} + +// peersWithHostname returns every peer registered under hostname. +func peersWithHostname(ctx context.Context, t *testing.T, hostname string) []api.Peer { + t.Helper() + + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + var matched []api.Peer + for _, p := range peers { + if p.Hostname == hostname { + matched = append(matched, p) + } + } + return matched +} + +func peerInGroup(p api.Peer, groupID string) bool { + for _, g := range p.Groups { + if g.Id == groupID { + return true + } + } + return false +} + +// mintSetupKey creates a reusable setup key that auto-joins its peer to one +// group, which is how a forwarder's peer lands in (or out of) a policy's +// source groups. +func mintSetupKey(ctx context.Context, t *testing.T, name, groupID string) string { + t.Helper() + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key +} + +// chatUntil retries a chat through the forwarder until it answers with want, +// returning the last status and body either way. +func chatUntil(ctx context.Context, t *testing.T, nb *harness.NBLink, want int, timeout time.Duration, sessionID string) (int, string) { + t.Helper() + + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + c, b, err := nb.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", sessionID) + if err == nil { + code, body = c, b + if code == want { + return code, body + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + return code, body +} diff --git a/e2e/harness/Dockerfile.nblink b/e2e/harness/Dockerfile.nblink new file mode 100644 index 000000000..dac3aceec --- /dev/null +++ b/e2e/harness/Dockerfile.nblink @@ -0,0 +1,37 @@ +# Multistage build for nblink, used in e2e tests. The release image +# (client/Dockerfile-nblink) packages a goreleaser artifact, so this compiles +# from source the way Dockerfile.client does for the agent, while keeping the +# release image's runtime shape: distroless, non-root, numeric UID. +# +# The runtime stage deliberately grants nothing. nblink's claim is that it +# needs no TUN device and no capabilities, so an image or a container request +# that quietly hands it NET_ADMIN would make the suite prove the opposite of +# what it is for. + +FROM golang:1.26.7-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/nblink ./client/link/cmd/nblink \ + && mkdir -p /out/state/nblink && chmod 770 /out/state/nblink + +FROM gcr.io/distroless/static-debian13:nonroot + +# Same state directory as the release image, so a volume mounted over it +# starts out owned by 65532:0 and the identity-persistence case runs against +# the ownership the release image ships. +COPY --from=builder --chown=65532:0 /out/state/ /var/lib/ + +# Numeric, as in the release image: the runtime may substitute its own UID and +# there is no passwd entry to look up. +USER 65532:0 + +# No NB_STATE_DIR by default. State stays in memory and each run registers a +# fresh peer; the persistence case sets it explicitly with a volume. +ENV NB_LOG_LEVEL=info + +COPY --from=builder /out/nblink /usr/local/bin/nblink +ENTRYPOINT ["/usr/local/bin/nblink"] diff --git a/e2e/harness/nblink.go b/e2e/harness/nblink.go new file mode 100644 index 000000000..1c5cc7ab0 --- /dev/null +++ b/e2e/harness/nblink.go @@ -0,0 +1,508 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "fmt" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + nblinkDockerfile = "e2e/harness/Dockerfile.nblink" + // defaultNBLinkImage is the local tag nblink is built under from + // nblinkDockerfile. Override with NB_E2E_NBLINK_IMAGE: a value with a "/" + // is pulled as a published image; a bare tag is built under that name. + defaultNBLinkImage = "netbird-nblink:e2e" + nblinkAlias = "nblink" + + // NBLinkPort is the loopback port every forwarder in this suite listens + // on. Tests reach it from a sidecar container sharing the forwarder's + // network namespace, so loopback is enough and --allow-public-bind stays + // off — the default an operator gets. + NBLinkPort = 8080 + + // nblinkCAPath is where the proxy's self-signed cert is mounted. nblink + // verifies its upstream like any Go HTTP client, so without this the + // forward fails the TLS handshake rather than reaching the endpoint. + nblinkCAPath = "/etc/nblink/upstream-ca.crt" + + // nblinkReadyLog is the line startForwards writes once the overlay session + // is up and every forward is bound. + nblinkReadyLog = "over the overlay" + + // nblinkSecretsDir holds the setup key for WithNBLinkSetupKeyFile, mounted + // read-only the way the README's container example mounts it. + nblinkSecretsDir = "/run/secrets" + nblinkKeyFile = "nb_setup_key" + nblinkKeyPath = nblinkSecretsDir + "/" + nblinkKeyFile + // keyWriterImage writes the key into its volume. The file is root-owned + // and readable by group 0 only, the ownership the README tells operators a + // key needs for UID 65532:0 to read it. + keyWriterImage = "alpine:3.22" + + // nblinkStateDir is the state directory both images create, owned by + // 65532:0, so a volume mounted over it inherits that ownership. + nblinkStateDir = "/var/lib/nblink" +) + +// ChatPath and ChatBody are the request the forwarder is driven with. They are +// exported so a test can send the very same request with one header changed +// and attribute the difference in the answer to that header alone. +const ChatPath = "/v1/chat/completions" + +// NBLink is a running nblink container: a NetBird peer in userspace mode +// forwarding a local port to an upstream reached inside the tunnel. +type NBLink struct { + container testcontainers.Container + keyVolume string + network string + name string + port int +} + +// nblinkOptions is what the NBLinkOption values assemble. +type nblinkOptions struct { + name string + port int + extra []string + keyAsFile bool + stateVolume string + env map[string]string + user string +} + +// NBLinkOption adjusts how StartNBLink runs the forwarder. +type NBLinkOption func(*nblinkOptions) + +// NBLinkCheckResult is what a --check run printed and how it exited. +type NBLinkCheckResult struct { + Stdout string + Stderr string + ExitCode int +} + +// StartNBLink builds the nblink image and runs it on the combined server's +// network, joining with the given setup key and serving one HTTP forward. +// +// forward is a complete spec (scheme://[host:]port=upstream). caCertPath is a +// PEM file on the host holding the upstream's certificate, mounted in and +// pointed at with SSL_CERT_FILE; pass "" when the upstream needs no extra +// trust. +// +// The container is granted no capabilities and no TUN device. That is the +// assertion, not an oversight: nblink that needed either would not be able to +// run where it claims to. +func StartNBLink(ctx context.Context, c *Combined, setupKey, forward, caCertPath string, opts ...NBLinkOption) (*NBLink, error) { + o := nblinkOptions{name: nblinkAlias, port: NBLinkPort} + for _, opt := range opts { + opt(&o) + } + + image, err := nblinkImage(ctx) + if err != nil { + return nil, err + } + + env := map[string]string{ + "NB_MANAGEMENT_URL": combinedExposedURL, + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": strings.Join(append([]string{forward}, o.extra...), ","), + "NB_HOSTNAME": o.name, + "NB_LOG_LEVEL": "debug", + // Match the agent and the proxy: the combined relay is WebSocket-only, + // so the embedded client must use WS to hold a stable relay link. + "NB_RELAY_TRANSPORT": "ws", + } + if caCertPath != "" { + env["SSL_CERT_FILE"] = nblinkCAPath + } + var keyVolume string + if o.keyAsFile { + // The root filesystem is read-only, so the key cannot be copied in; it + // is mounted, as it would be in production. + keyVolume = fmt.Sprintf("e2e-nblink-key-%s-%d", o.name, time.Now().UnixNano()) + if err := writeKeyVolume(ctx, keyVolume, setupKey); err != nil { + return nil, err + } + env["NB_SETUP_KEY"] = "file:" + nblinkKeyPath + } + if o.stateVolume != "" { + env["NB_STATE_DIR"] = nblinkStateDir + } + for k, v := range o.env { + env[k] = v + } + + req := testcontainers.ContainerRequest{ + Image: image, + // The embedded client reports the container hostname to management, so + // this is the name the peer is addressable by in the API. + Hostname: o.name, + User: o.user, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {o.name}}, + Env: env, + HostConfigModifier: func(hc *container.HostConfig) { + hardenNBLink(hc) + if caCertPath != "" { + hc.Binds = append(hc.Binds, caCertPath+":"+nblinkCAPath+":ro") + } + if keyVolume != "" { + hc.Mounts = append(hc.Mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: keyVolume, + Target: nblinkSecretsDir, + ReadOnly: true, + }) + } + if o.stateVolume != "" { + hc.Mounts = append(hc.Mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: o.stateVolume, + Target: nblinkStateDir, + }) + } + }, + // The session has 90s of its own to come up, so the wait has to outlast + // that or a slow handshake reads as a missing log line. + WaitingFor: wait.ForLog(nblinkReadyLog).WithStartupTimeout(150 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + if ctr != nil { + _ = ctr.Terminate(context.Background()) + } + if keyVolume != "" { + _ = RemoveDockerVolume(context.Background(), keyVolume) + } + return nil, fmt.Errorf("start nblink container: %w", err) + } + return &NBLink{container: ctr, keyVolume: keyVolume, network: c.network.Name, name: o.name, port: o.port}, nil +} + +// Hostname returns the container hostname the embedded client reports to +// management — the name the registered peer appears under in the peers API. +func (n *NBLink) Hostname() string { + return n.name +} + +// Post issues a JSON POST to the forwarder's primary listener and returns the +// HTTP status and response body. +func (n *NBLink) Post(ctx context.Context, path, body string, extraHeaders []string) (int, string, error) { + return n.do(ctx, n.port, http.MethodPost, path, body, extraHeaders) +} + +// PostOn issues a JSON POST to the forwarder's listener on port, for a forward +// added with WithNBLinkForwards. +func (n *NBLink) PostOn(ctx context.Context, port int, path, body string, extraHeaders []string) (int, string, error) { + return n.do(ctx, port, http.MethodPost, path, body, extraHeaders) +} + +// Get issues a GET to the forwarder's primary listener. +func (n *NBLink) Get(ctx context.Context, path string, extraHeaders []string) (int, string, error) { + return n.do(ctx, n.port, http.MethodGet, path, "", extraHeaders) +} + +// Chat issues an OpenAI-shaped chat completion to the forwarder, which proxies +// it to the agent-network endpoint over the tunnel. A non-empty sessionID is +// sent as the x-session-id header the proxy records. +func (n *NBLink) Chat(ctx context.Context, model, prompt, sessionID string) (int, string, error) { + return n.Post(ctx, ChatPath, ChatBody(model, prompt), withSessionID(nil, sessionID)) +} + +// do runs curl in a throwaway container sharing the forwarder's network +// namespace, so the request arrives on its loopback listener exactly as a +// process running beside it would send one. +// +// Nothing here pins or rewrites Host: the loopback listener refuses a Host +// that is not its own, and a test that papered over that would stop covering +// it. Callers that want a foreign Host pass it in extraHeaders on purpose. +func (n *NBLink) do(ctx context.Context, port int, method, path, body string, extraHeaders []string) (int, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path) + return curlStatus(ctx, []string{"--network", "container:" + n.container.GetContainerID()}, url, method, body, extraHeaders) +} + +// PostFromNetwork issues a JSON POST to the forwarder by its network alias from +// a separate container on the same network, the way another machine would +// reach a publicly bound forward. A connection that is refused comes back as +// an error, not a status. +func (n *NBLink) PostFromNetwork(ctx context.Context, port int, path, body string, extraHeaders []string) (int, string, error) { + url := fmt.Sprintf("http://%s:%d%s", n.name, port, path) + return curlStatus(ctx, []string{"--network", n.network}, url, http.MethodPost, body, extraHeaders) +} + +// Stop sends the forwarder SIGTERM, the way docker stop and an orchestrator +// do, and reports how it exited and how long it took. The container is left +// stopped for Logs; Terminate removes it. +func (n *NBLink) Stop(ctx context.Context, grace time.Duration) (int, time.Duration, error) { + start := time.Now() + if err := n.container.Stop(ctx, &grace); err != nil { + return 0, 0, fmt.Errorf("stop nblink: %w", err) + } + took := time.Since(start) + state, err := n.container.State(ctx) + if err != nil { + return 0, took, fmt.Errorf("inspect nblink: %w", err) + } + return state.ExitCode, took, nil +} + +// Logs returns the nblink container logs, for diagnostics on failure. +func (n *NBLink) Logs(ctx context.Context) string { + return containerLogs(ctx, n.container) +} + +// Terminate stops the nblink container. +func (n *NBLink) Terminate(ctx context.Context) error { + if n.container == nil { + return nil + } + err := n.container.Terminate(ctx) + if n.keyVolume != "" { + // Not ctx: a cancelled or expired ctx is the likeliest reason the + // terminate above failed, and the volume has to go either way. + err = errors.Join(err, RemoveDockerVolume(context.Background(), n.keyVolume)) + } + return err +} + +// WithNBLinkName names the forwarder, setting both its network alias and its +// container hostname. The hostname is what the peer registers under, so it is +// the name the peer appears beneath in the peers API. +// +// Required to run a second forwarder against the same server: the default name +// is shared and two containers cannot hold one alias on a network. +func WithNBLinkName(name string) NBLinkOption { + return func(o *nblinkOptions) { o.name = name } +} + +// WithNBLinkForwards adds forwards to the one StartNBLink is given. They are +// passed in the same comma-separated NB_FORWARD, so one process serves all of +// them over one session. +func WithNBLinkForwards(specs ...string) NBLinkOption { + return func(o *nblinkOptions) { o.extra = append(o.extra, specs...) } +} + +// WithNBLinkSetupKeyFile delivers the setup key as a file in the container and +// points NB_SETUP_KEY at it with the file: prefix, instead of putting the key +// itself in the environment. +func WithNBLinkSetupKeyFile() NBLinkOption { + return func(o *nblinkOptions) { o.keyAsFile = true } +} + +// WithNBLinkStateVolume mounts the named Docker volume as the state directory +// and sets NB_STATE_DIR, so the peer identity outlives the container. The +// volume is created on first use; RemoveDockerVolume deletes it. +func WithNBLinkStateVolume(volume string) NBLinkOption { + return func(o *nblinkOptions) { o.stateVolume = volume } +} + +// WithNBLinkUser runs the forwarder as uid:gid instead of the image's user, +// the way OpenShift assigns an arbitrary UID in group 0. +func WithNBLinkUser(user string) NBLinkOption { + return func(o *nblinkOptions) { o.user = user } +} + +// WithNBLinkEnv sets an extra environment variable on the forwarder, for +// settings such as NB_ALLOW_PUBLIC_BIND that have no option of their own. +func WithNBLinkEnv(key, value string) NBLinkOption { + return func(o *nblinkOptions) { + if o.env == nil { + o.env = map[string]string{} + } + o.env[key] = value + } +} + +// ChatBody builds an OpenAI-shaped chat completion request body. +func ChatBody(model, prompt string) string { + return fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) +} + +// RunNBLinkCheck runs the nblink image with --check and the given environment. +// The container gets no network at all, so a run that tried to reach +// management would fail rather than quietly succeed. +func RunNBLinkCheck(ctx context.Context, env map[string]string) (NBLinkCheckResult, error) { + return RunNBLinkIsolated(ctx, env, "--check") +} + +// RunNBLinkIsolated runs the nblink image to completion with no network and +// the given environment and arguments, the way a forwarder behaves when +// management cannot be reached. +func RunNBLinkIsolated(ctx context.Context, env map[string]string, extraArgs ...string) (NBLinkCheckResult, error) { + image, err := nblinkImage(ctx) + if err != nil { + return NBLinkCheckResult{}, err + } + + args := append(isolatedRunArgs("--rm"), envArgs(env)...) + args = append(args, image) + args = append(args, extraArgs...) + + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + res := NBLinkCheckResult{} + err = cmd.Run() + res.Stdout, res.Stderr = stdout.String(), stderr.String() + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, nil + } + if err != nil { + return res, fmt.Errorf("run nblink: %w", err) + } + return res, nil +} + +// StopNBLinkDuringStartup starts the nblink image with no network, so the +// session can never come up, sends it SIGTERM after wait, and reports how it +// exited, how long the stop took, and what it logged. +func StopNBLinkDuringStartup(ctx context.Context, env map[string]string, wait, grace time.Duration) (int, time.Duration, string, error) { + image, err := nblinkImage(ctx) + if err != nil { + return 0, 0, "", err + } + + args := append(isolatedRunArgs("-d"), envArgs(env)...) + out, err := exec.CommandContext(ctx, "docker", append(args, image)...).Output() + if err != nil { + return 0, 0, "", fmt.Errorf("start nblink: %w", err) + } + id := strings.TrimSpace(string(out)) + defer func() { _ = exec.Command("docker", "rm", "-f", id).Run() }() + + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + return 0, 0, "", ctx.Err() + } + + start := time.Now() + stop := exec.CommandContext(ctx, "docker", "stop", "-t", fmt.Sprint(int(grace.Seconds())), id) + if out, err := stop.CombinedOutput(); err != nil { + return 0, 0, "", fmt.Errorf("stop nblink: %w: %s", err, out) + } + took := time.Since(start) + + state, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.ExitCode}}", id).Output() + if err != nil { + return 0, took, "", fmt.Errorf("inspect nblink: %w", err) + } + exitCode := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(string(state)), "%d", &exitCode) + logs, err := exec.CommandContext(ctx, "docker", "logs", id).CombinedOutput() + if err != nil { + return exitCode, took, "", fmt.Errorf("read nblink logs: %w", err) + } + return exitCode, took, string(logs), nil +} + +// RemoveDockerVolume deletes a volume created by WithNBLinkStateVolume. +func RemoveDockerVolume(ctx context.Context, volume string) error { + if out, err := exec.CommandContext(ctx, "docker", "volume", "rm", "-f", volume).CombinedOutput(); err != nil { + return fmt.Errorf("remove volume %s: %w: %s", volume, err, out) + } + return nil +} + +// writeKeyVolume creates a volume holding the setup key as a root-owned file +// of mode 0440. The key goes in on stdin so it never appears in a process +// listing. +func writeKeyVolume(ctx context.Context, volume, key string) error { + if out, err := exec.CommandContext(ctx, "docker", "volume", "create", volume).CombinedOutput(); err != nil { + return fmt.Errorf("create key volume: %w: %s", err, out) + } + script := fmt.Sprintf("cat > /k/%[1]s && chown 0:0 /k/%[1]s && chmod 440 /k/%[1]s", nblinkKeyFile) + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-i", "-v", volume+":/k", keyWriterImage, "sh", "-c", script) + cmd.Stdin = strings.NewReader(key + "\n") + if out, err := cmd.CombinedOutput(); err != nil { + _ = RemoveDockerVolume(context.Background(), volume) + return fmt.Errorf("write key volume: %w: %s", err, out) + } + return nil +} + +// hardenNBLink takes away what Docker grants a container by default: every +// capability, a writable root filesystem, and the ability to gain privileges. +// nblink claims to need none of them, and this is where that claim is held to. +// State, when kept, goes to a mounted volume. +func hardenNBLink(hc *container.HostConfig) { + hc.CapDrop = []string{"ALL"} + hc.ReadonlyRootfs = true + hc.SecurityOpt = append(hc.SecurityOpt, "no-new-privileges") +} + +func nblinkImage(ctx context.Context) (string, error) { + root, err := repoRoot(ctx) + if err != nil { + return "", err + } + return resolveImage(ctx, root, "NB_E2E_NBLINK_IMAGE", defaultNBLinkImage, nblinkDockerfile) +} + +// curlStatus runs curl in a throwaway container with the given docker run +// network arguments and returns the status and body. +func curlStatus(ctx context.Context, network []string, url, method, body string, extraHeaders []string) (int, string, error) { + args := append([]string{"run", "--rm"}, network...) + args = append(args, + curlImage, + "-s", "--connect-timeout", "5", "--max-time", "90", + "-o", "/dev/stderr", "-w", "%{http_code}", + "-X", method, url, + "-H", "Content-Type: application/json", + ) + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + if body != "" { + args = append(args, "--data", body) + } + + cmd := exec.CommandContext(ctx, "docker", args...) + // -w writes the status to stdout, -o /dev/stderr the body to stderr, so + // the two come back separately. + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, stderr.String(), fmt.Errorf("curl through nblink: %w", err) + } + + code := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &code) + return code, stderr.String(), nil +} + +// isolatedRunArgs is docker run with no network and the same hardening +// StartNBLink applies. +func isolatedRunArgs(mode string) []string { + return []string{"run", mode, "--network", "none", "--cap-drop", "ALL", "--read-only", "--security-opt", "no-new-privileges"} +} + +func envArgs(env map[string]string) []string { + var args []string + for k, v := range env { + args = append(args, "-e", k+"="+v) + } + return args +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 6a4251f5e..185aafa8b 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -242,6 +242,13 @@ func (p *Proxy) DebugClients(ctx context.Context) ([]ProxyDebugClient, error) { return resp.Clients, nil } +// CACertPath returns the host path of the self-signed certificate the proxy +// serves for the agent-network endpoint. A client that verifies TLS properly — +// nblink does, where the curl helpers pass -k — needs it in its trust store. +func (p *Proxy) CACertPath() string { + return filepath.Join(p.workDir, "tls.crt") +} + // Logs returns the proxy container logs, for diagnostics on failure. func (p *Proxy) Logs(ctx context.Context) string { return containerLogs(ctx, p.container) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index c007020f5..6aa9855d4 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -621,7 +621,13 @@ func (c *GrpcClient) login(req *proto.LoginRequest) (*proto.LoginResponse, error err = backoff.Retry(operation, nbgrpc.Backoff(c.ctx)) if err != nil { - log.Errorf("failed to login to Management Service: %v", err) + // PermissionDenied is how management asks an unknown peer to register, + // which the caller does next, so it is not an error worth reporting. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("login to Management Service refused: %v", err) + } else { + log.Errorf("failed to login to Management Service: %v", err) + } return nil, err } From f6cfa775f87a6fb04beba787377326c0abdb8693 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:37 +0200 Subject: [PATCH 47/70] [management] setting to return error on name collision during AddPeer (#8154) --- management/server/account.go | 11 + management/server/activity/codes.go | 11 +- .../handlers/accounts/accounts_handler.go | 4 + .../accounts/accounts_handler_test.go | 9 + management/server/peer.go | 101 +++++++-- management/server/peer_test.go | 211 ++++++++++++++++++ management/server/store/sql_store_account.go | 6 + .../server/store/sql_store_account_test.go | 2 +- management/server/store/sql_store_peer.go | 5 +- management/server/types/settings.go | 5 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 3 + 12 files changed, 344 insertions(+), 28 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 946b9ad1c..0a8ec9a59 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -459,6 +459,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID) am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleMetricsPushSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handlePeerHostnameCollisionSettings(ctx, oldSettings, newSettings, userID, accountID) if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } @@ -644,6 +645,16 @@ func (am *DefaultAccountManager) handleMetricsPushSettings(ctx context.Context, } } +func (am *DefaultAccountManager) handlePeerHostnameCollisionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.PeerHostnameCollisionRejected != newSettings.PeerHostnameCollisionRejected { + if newSettings.PeerHostnameCollisionRejected { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionDisabled, nil) + } + } +} + func (am *DefaultAccountManager) handlePeerLoginExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled { event := activity.AccountPeerLoginExpirationEnabled diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index dca043027..11c309ef4 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -287,6 +287,11 @@ const ( // CustomDomainValidationExpired indicates that an unvalidated domain registration expired. CustomDomainValidationExpired Activity = 143 + // AccountPeerHostnameCollisionRejectionEnabled indicates that a user enabled rejecting peer hostname collisions for the account + AccountPeerHostnameCollisionRejectionEnabled Activity = 144 + // AccountPeerHostnameCollisionRejectionDisabled indicates that a user disabled rejecting peer hostname collisions for the account + AccountPeerHostnameCollisionRejectionDisabled Activity = 145 + AccountDeleted Activity = 99999 ) @@ -461,8 +466,10 @@ var activityMap = map[Activity]Code{ AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"}, AgentNetworkSettingsDeleted: {"Agent Network settings deleted", "agent_network.settings.delete"}, - AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, - AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, + AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + AccountPeerHostnameCollisionRejectionEnabled: {"Account peer hostname collision rejection enabled", "account.setting.peer.hostname.collision.rejection.enable"}, + AccountPeerHostnameCollisionRejectionDisabled: {"Account peer hostname collision rejection disabled", "account.setting.peer.hostname.collision.rejection.disable"}, DomainAdded: {"Domain added", "domain.add"}, DomainDeleted: {"Domain deleted", "domain.delete"}, diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index e5d75007f..797815685 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -286,6 +286,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.MetricsPushEnabled != nil { returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled } + if req.Settings.PeerHostnameCollisionRejected != nil { + returnSettings.PeerHostnameCollisionRejected = *req.Settings.PeerHostnameCollisionRejected + } if req.Settings.AgentNetworkOnly != nil { returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly } @@ -432,6 +435,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A AutoUpdateAlways: &settings.AutoUpdateAlways, Ipv6EnabledGroups: &settings.IPv6EnabledGroups, MetricsPushEnabled: &settings.MetricsPushEnabled, + PeerHostnameCollisionRejected: &settings.PeerHostnameCollisionRejected, AgentNetworkOnly: &settings.AgentNetworkOnly, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 1ccac2b04..588258003 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -159,6 +160,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -188,6 +190,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -217,6 +220,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -246,6 +250,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -275,6 +280,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -304,6 +310,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(true), DashboardFeatures: &api.AccountDashboardFeatures{ AgentNetwork: br(true), @@ -345,6 +352,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), DashboardFeatures: &api.AccountDashboardFeatures{ AgentNetwork: br(true), @@ -377,6 +385,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), diff --git a/management/server/peer.go b/management/server/peer.go index f3a5ebf46..c60737535 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" b64 "encoding/base64" + "errors" "fmt" "net" "net/netip" @@ -14,6 +15,7 @@ import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" + "gorm.io/gorm" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/idp" @@ -288,19 +290,34 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user var newLabel string newLabel, err = nbdns.GetParsedDomainLabel(update.Name) - if err != nil { - newLabel = "" - } else { - _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) - if err == nil { - newLabel = "" - } - } - - if newLabel == "" { - newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name) + if settings.PeerHostnameCollisionRejected { if err != nil { - return fmt.Errorf("failed to get free DNS label: %w", err) + return status.Errorf(status.InvalidArgument, "invalid peer name: %v", err) + } + if newLabel != peer.DNSLabel { + taken, err := peerLabelTaken(ctx, transaction, accountID, newLabel) + if err != nil { + return err + } + if taken { + return errPeerHostnameInUse(update.Name) + } + } + } else { + if err != nil { + newLabel = "" + } else { + _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) + if err == nil { + newLabel = "" + } + } + + if newLabel == "" { + newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name) + if err != nil { + return fmt.Errorf("failed to get free DNS label: %w", err) + } } } peer.Name = update.Name @@ -336,6 +353,9 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user return transaction.SavePeer(ctx, accountID, peer) }) if err != nil { + if peerLabelChanged && isUniqueConstraintError(err) { + return nil, errPeerHostnameInUse(peer.Name) + } return nil, err } @@ -832,6 +852,14 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err) } + hostLabel, err := nbdns.GetParsedDomainLabel(peer.Meta.Hostname) + if err != nil { + return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid peer hostname: %v", err) + } + rejectLabelCollision := settings.PeerHostnameCollisionRejected + labelTaken := false + + var insertErr error maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { netPrefix, err := netip.ParsePrefix(network.Net.String()) @@ -843,17 +871,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err) } - var freeLabel string - if ephemeral || attempt > 1 { + freeLabel := hostLabel + if !rejectLabelCollision && (ephemeral || labelTaken) { freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname) if err != nil { return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } - } else { - freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname) - if err != nil { - return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) - } } newPeer.DNSLabel = freeLabel newPeer.IP = freeIP @@ -884,7 +907,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + insertErr = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { err = transaction.AddPeerToAccount(ctx, newPeer) if err != nil { return err @@ -942,16 +965,28 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe log.WithContext(ctx).Debugf("Peer %s added to account %s", newPeer.ID, accountID) return nil }) - if err == nil { + if insertErr == nil { break } - if isUniqueConstraintError(err) { - log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, err) + if isUniqueConstraintError(insertErr) { + if freeLabel == hostLabel { + labelTaken, err = peerLabelTaken(ctx, am.Store, accountID, hostLabel) + if err != nil { + return nil, nil, nil, false, err + } + if labelTaken && rejectLabelCollision { + return nil, nil, nil, false, errPeerHostnameInUse(peer.Meta.Hostname) + } + } + log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, insertErr) continue } - return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", insertErr) + } + if insertErr != nil { + return nil, nil, nil, false, fmt.Errorf("add peer after %d attempts: %w", maxAttempts, insertErr) } if newPeer == nil { return nil, nil, nil, false, fmt.Errorf("new peer is nil") @@ -985,6 +1020,24 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return newPeer, network, postureChecks, enableSSH, nil } +// peerLabelTaken reports whether a peer of the account already uses the DNS label. +func peerLabelTaken(ctx context.Context, s store.Store, accountID, label string) (bool, error) { + _, err := s.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, label) + switch { + case err == nil: + return true, nil + case errors.Is(err, gorm.ErrRecordNotFound): + return false, nil + default: + return false, fmt.Errorf("check peer DNS label: %w", err) + } +} + +// errPeerHostnameInUse is a PermissionDenied error because the client treats only that gRPC code as a permanent login failure instead of retrying. +func errPeerHostnameInUse(hostname string) error { + return status.Errorf(status.PermissionDenied, "peer with hostname %q is already registered", hostname) +} + func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { if !ip.Is4() { return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index fa3be9a58..93f38f25e 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -3050,3 +3050,214 @@ func TestResolvePeerLocation(t *testing.T) { }) } } + +func TestAddPeer_HostnameCollisionRejected(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "key", types.SetupKeyReusable, time.Hour, nil, 100, "owner", false, false) + require.NoError(t, err) + ephemeralKey, err := manager.CreateSetupKey(context.Background(), account.Id, "ephemeral", types.SetupKeyReusable, time.Hour, nil, 100, "owner", true, false) + require.NoError(t, err) + + addPeer := func(hostname, key string) (*nbpeer.Peer, error) { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer := &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + } + added, _, _, _, err := manager.AddPeer(context.Background(), "", key, "", peer, false) + return added, err + } + + first, err := addPeer("my-host", setupKey.Key) + require.NoError(t, err) + assert.Equal(t, "my-host", first.DNSLabel) + + _, err = addPeer("My-Host", setupKey.Key) + require.Error(t, err) + e, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + other, err := addPeer("other-host", setupKey.Key) + require.NoError(t, err) + assert.Equal(t, "other-host", other.DNSLabel) + + _, err = addPeer("my-host", ephemeralKey.Key) + require.Error(t, err) + e, ok = status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + ephemeral, err := addPeer("ephemeral-host", ephemeralKey.Key) + require.NoError(t, err) + assert.Equal(t, "ephemeral-host", ephemeral.DNSLabel) + + account.Settings.PeerHostnameCollisionRejected = false + require.NoError(t, manager.Store.SaveAccountSettings(context.Background(), account.Id, account.Settings)) + + suffixed, err := addPeer("my-host", setupKey.Key) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(suffixed.DNSLabel, "my-host-")) +} + +func TestUpdatePeer_HostnameCollisionRejected(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + addPeer := func(hostname string) *nbpeer.Peer { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + added, _, _, _, err := manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + require.NoError(t, err) + return added + } + first := addPeer("web-server") + second := addPeer("old-name") + + update := second.Copy() + update.Name = "Web-Server" + _, err = manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.Error(t, err) + e, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + update = second.Copy() + update.Name = "api-server.example.com" + updated, err := manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.NoError(t, err) + assert.Equal(t, "api-server", updated.DNSLabel) + + update = first.Copy() + update.Name = "WEB-SERVER" + updated, err = manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.NoError(t, err, "renaming to a different spelling of the own label must not collide with itself") + assert.Equal(t, "web-server", updated.DNSLabel) +} + +func TestAddPeer_HostnameCollisionRejectedConcurrent(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "key", types.SetupKeyReusable, time.Hour, nil, 100, "owner", false, false) + require.NoError(t, err) + + const attempts = 20 + start := make(chan struct{}) + errs := make(chan error, attempts) + var wg sync.WaitGroup + for i := 0; i < attempts; i++ { + wg.Add(1) + go func() { + defer wg.Done() + wgKey, err := wgtypes.GenerateKey() + if err != nil { + errs <- err + return + } + <-start + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "same-host", OS: "linux"}, + }, false) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + + succeeded := 0 + for err := range errs { + if err == nil { + succeeded++ + continue + } + e, ok := status.FromError(err) + require.True(t, ok, "unexpected error: %v", err) + assert.Equal(t, status.PermissionDenied, e.Type(), "unexpected error: %v", err) + } + assert.Equal(t, 1, succeeded) + + peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") + require.NoError(t, err) + require.Len(t, peers, 1) + assert.Equal(t, "same-host", peers[0].DNSLabel) +} + +func TestAddPeer_IPCollisionKeepsLabel(t *testing.T) { + for _, rejectCollision := range []bool{false, true} { + t.Run(fmt.Sprintf("collision rejected %t", rejectCollision), func(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = rejectCollision + _, smallNet, err := net.ParseCIDR("100.64.0.0/26") + require.NoError(t, err) + account.Network.Net = *smallNet + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + const totalPeers = 20 + for i := 0; i < totalPeers; i++ { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + hostname := fmt.Sprintf("host-%d", i) + added, _, _, _, err := manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + require.NoError(t, err) + assert.Equal(t, hostname, added.DNSLabel, "an IP collision must not change the DNS label") + } + }) + } +} + +func TestAddPeer_ExhaustedIPAllocationFails(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + _, tinyNet, err := net.ParseCIDR("100.64.0.0/30") + require.NoError(t, err) + account.Network.Net = *tinyNet + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + addPeer := func(hostname string) error { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + return err + } + require.NoError(t, addPeer("host-1")) + require.NoError(t, addPeer("host-2")) + + err = addPeer("host-3") + require.Error(t, err, "no free IP left, registration must fail") + + peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") + require.NoError(t, err) + assert.Len(t, peers, 2) +} diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index 43fe09861..3780de77d 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -746,6 +746,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_peer_hostname_collision_rejected, settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings @@ -771,6 +772,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLazyConnectionEnabled sql.NullBool sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool + sPeerHostnameCollisionRejected sql.NullBool sAgentNetworkOnly sql.NullBool sDashboardFeatures sql.NullString autoUpdateVersion sql.NullString @@ -800,6 +802,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sPeerHostnameCollisionRejected, &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, @@ -870,6 +873,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sMetricsPushEnabled.Valid { account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool } + if sPeerHostnameCollisionRejected.Valid { + account.Settings.PeerHostnameCollisionRejected = sPeerHostnameCollisionRejected.Bool + } if sAgentNetworkOnly.Valid { account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool } diff --git a/management/server/store/sql_store_account_test.go b/management/server/store/sql_store_account_test.go index ec5976982..ba904bbe4 100644 --- a/management/server/store/sql_store_account_test.go +++ b/management/server/store/sql_store_account_test.go @@ -288,7 +288,7 @@ func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { settings := types.Settings{} numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) assert.NoError(t, err) - assert.Equal(t, 27, numOfExportedFields) + assert.Equal(t, 28, numOfExportedFields) account.Settings = &settings err = store.SaveAccount(context.Background(), account) diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index 9950069fd..ec5e1993c 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -715,12 +715,15 @@ func (s *SqlStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStr Where("account_id = ? AND dns_label = ?", accountID, hostname). Limit(1). Scan(&peerID) + if result.Error != nil { + return "", result.Error + } if peerID == "" { return "", gorm.ErrRecordNotFound } - return peerID, result.Error + return peerID, nil } // GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 815c93ce6..8aa1c7e4c 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -76,6 +76,10 @@ type Settings struct { // MetricsPushEnabled globally enables or disables client metrics push for the account MetricsPushEnabled bool `gorm:"default:false"` + // PeerHostnameCollisionRejected rejects registering or renaming a peer to a hostname + // already used by another peer of the account instead of suffixing it + PeerHostnameCollisionRejected bool `gorm:"default:false"` + // AgentNetworkOnly limits the dashboard to the Agent Network surface for this account. // Set for accounts created via netbird.ai signups; users can disable it later. AgentNetworkOnly bool `gorm:"default:false"` @@ -123,6 +127,7 @@ func (s *Settings) Copy() *Settings { AutoUpdateAlways: s.AutoUpdateAlways, IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), MetricsPushEnabled: s.MetricsPushEnabled, + PeerHostnameCollisionRejected: s.PeerHostnameCollisionRejected, AgentNetworkOnly: s.AgentNetworkOnly, EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 68de0d1f5..8b97d5f47 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -380,6 +380,10 @@ components: description: Enables or disables client metrics push for all peers in the account type: boolean example: false + peer_hostname_collision_rejected: + description: When enabled, registering or renaming a peer to a hostname already used by another peer in the account fails instead of receiving an IP-based suffix. Applies to ephemeral peers as well. + type: boolean + example: false agent_network_only: description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a1f4bc530..1595553ed 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1693,6 +1693,9 @@ type AccountSettings struct { // PeerExposeGroups Limits which peer groups are allowed to expose services. If empty, all peers are allowed when peer expose is enabled. PeerExposeGroups []string `json:"peer_expose_groups"` + // PeerHostnameCollisionRejected When enabled, registering or renaming a peer to a hostname already used by another peer in the account fails instead of receiving an IP-based suffix. Applies to ephemeral peers as well. + PeerHostnameCollisionRejected *bool `json:"peer_hostname_collision_rejected,omitempty"` + // PeerInactivityExpiration Period of time of inactivity after which peer session expires (seconds). PeerInactivityExpiration int `json:"peer_inactivity_expiration"` From ef2292d822c4d61df9d1867fc3fa554daccbc77b Mon Sep 17 00:00:00 2001 From: Bruno Mercier Costa <94469565+emrcbrn@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:27:13 +0200 Subject: [PATCH 48/70] [client] Fix Windows UI exiting on first launch (#8138) * [client] Fix Windows UI exiting on first launch Bump the Wails fork to pick up netbirdio/wails#6, which stops SetBackgroundColour from dereferencing a WebView2 controller that does not exist yet. On first launch the theme service re-tints the Welcome window while it is still being created, and losing that race made the UI exit with no window. * [client] Point the Wails fork at the merged netbirdio/wails#6 Replace the temporary pin on emrcbrn/wails with the merge commit of netbirdio/wails#6 on the fork's integration branch. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fa7047bd8..dcbc7d9d7 100644 --- a/go.mod +++ b/go.mod @@ -381,7 +381,7 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 -replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d tool ( github.com/goreleaser/chglog/cmd/chglog diff --git a/go.sum b/go.sum index 55b3edf85..64197c736 100644 --- a/go.sum +++ b/go.sum @@ -539,8 +539,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 h1:Qv2jYeucRkuqkkM9aAVFcO9avmSfPEoB+gxKKKuI4vA= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU= github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= From a6e154a9b367fc40532b04c3b33733cf283d0fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20H=C3=A4rtter?= <108095150+janekhaertter@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:02:52 +0200 Subject: [PATCH 49/70] [misc] Extend the copyright year to 2026 (#8169) The Windows installer shows LICENSE on its license page, where the 2022 copyright line reads as outdated. Extend the range to 2022-2026 in LICENSE, its REUSE copy, and the installer's LegalCopyright, keeping 2022 as the year of first publication. --- LICENSE | 2 +- LICENSES/BSD-3-Clause.txt | 2 +- client/installer.nsis | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index cea6f8f0b..4d0551de8 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,7 @@ Those directories are licensed under the GNU Affero General Public License versi BSD 3-Clause License -Copyright (c) 2022 NetBird GmbH & AUTHORS +Copyright (c) 2022-2026 NetBird GmbH & AUTHORS Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt index 7cba76dfd..5e9a2539c 100644 --- a/LICENSES/BSD-3-Clause.txt +++ b/LICENSES/BSD-3-Clause.txt @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2022 NetBird GmbH & AUTHORS +Copyright (c) 2022-2026 NetBird GmbH & AUTHORS Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/client/installer.nsis b/client/installer.nsis index eb2d7d5bd..304d38864 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -2,7 +2,7 @@ !define COMP_NAME "Netbird" !define WEB_SITE "Netbird.io" !define VERSION $%APPVER% -!define COPYRIGHT "Netbird Authors, 2022" +!define COPYRIGHT "Netbird Authors, 2022-2026" !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" From f0182dd645f5bdad07b7335b7302edbc33b58ce0 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:09:45 +0200 Subject: [PATCH 50/70] [signal] Add a release-wired UBI image variant (#7458) * [signal] Add an explicit non-root UBI image variant Keep the existing signal image unchanged while making a separate UBI image available for local certification-readiness checks. Collect the linked Go dependency license terms as portable build inputs, and use SIGINT for the existing graceful stop handler. * [signal] Wire the UBI variant into GoReleaser Build the additive UBI signal variant through the existing release pipeline instead of relying on local Docker commands. Stage its licenses in the per-image build context and restrict the variant to validated AMD64 without changing existing image tags. * [signal] Align the UBI image with the other UBI variants Main now ships UBI images for the client, combined server and proxy with a shared license collector and a common shape, so the signal variant should look the same to reviewers and to Red Hat certification. Signal also listens on port 80 by default, which an arbitrary non-root UID cannot bind on OpenShift or Podman. Use release_files/collect-licenses.sh instead of a signal-only copy, build for amd64 and arm64 like the other UBI entries, and run as 1000:0 with a group-writable /var/lib/netbird that also holds Let's Encrypt data. Default NB_PORT to the legacy gRPC port 10000 so the image starts unprivileged and serves a single listener. * [infrastructure] Certify the signal UBI image on release The signal UBI image is published with every release, but only images listed in the certification workflow reach the Red Hat catalog, so it would otherwise need a manual run each time. Add signal to the dispatch options and the component list, using the same -ubi tag suffix as .goreleaser.yaml. The run reads its Partner Connect ID from REDHAT_CERT_ID_SIGNAL. * [signal] Answer Let's Encrypt challenges on the UBI image's port The challenge listener defaults to :443, which a non-root UID cannot bind on OpenShift or Podman, so Let's Encrypt could not work in the UBI image. Now that the listener address is configurable, default it to empty so the TLS listener on port 10000 answers the challenges itself when public port 443 is forwarded to it. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 +++++++++++++++++++++++++ signal/Dockerfile.ubi | 38 ++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 signal/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index 7c26c79fb..c6cd62bca 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -33,6 +33,7 @@ on: - client-rootless - reverse-proxy - netbird-server + - signal version: description: "Released version, e.g. v0.80.0" type: string @@ -68,6 +69,7 @@ jobs: "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" + "signal ghcr.io/netbirdio/signal -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ebb4a8525..1e7acbaa7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -528,6 +528,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: signal-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: signal/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l signal/LICENSE "{{ .ContextDir }}/licenses" ./signal amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: management disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/signal/Dockerfile.ubi b/signal/Dockerfile.ubi new file mode 100644 index 000000000..f0a6552da --- /dev/null +++ b/signal/Dockerfile.ubi @@ -0,0 +1,38 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-signal" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Signal" \ + description="NetBird Signal brokers the connection handshakes between peers in NetBird networks." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created Let's Encrypt keys retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_LETSENCRYPT_DATA_DIR="/var/lib/netbird" +# Unprivileged ports: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root, so serve on the legacy +# gRPC port instead of 80/443. With Let's Encrypt, the empty listen address +# answers challenges on that port when public 443 is forwarded to it. +# 9090 is the metrics endpoint. +ENV NB_PORT="10000" +ENV NB_LETSENCRYPT_LISTEN_ADDRESS="" +EXPOSE 10000 9090 +# The signal server only handles SIGINT for a graceful stop. +STOPSIGNAL SIGINT +ENTRYPOINT ["/go/bin/netbird-signal", "run"] +CMD ["--log-file", "console"] From 763da1b9853c0de7e97054e69862531b16e50979 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:09:45 +0200 Subject: [PATCH 51/70] [relay] Add a release-wired UBI image variant (#7457) * [relay] Add a release-wired UBI image variant * [relay] Align the UBI image with the shared UBI pattern Main now ships UBI variants of the client, combined server and proxy that share one license collector and one image layout, and the Red Hat certification workflow expects every certified image to carry both amd64 and arm64. The relay variant predated that, kept its own copy of the license script, built amd64 only, and ran as UID 65532 on the privileged default port :443, so it would not start under OpenShift or rootless Podman. Use release_files/collect-licenses.sh and build amd64 and arm64 like the other UBI images. Run as 1000:0 with a group-0 writable /var/lib/netbird for Let's Encrypt data, and default the listener to :8443 so an arbitrary non-root UID can bind it; the relay's Let's Encrypt flow uses TLS-ALPN on that same listener, so no separate challenge port is needed. * [infrastructure] Certify the relay UBI image on release Releases certify the UBI images through redhat-certify.yml, which only lists the client, proxy and combined server, so a released relay UBI image would never reach the Red Hat catalog. Add relay to the component list and the manual dispatch options. Its Partner Connect ID comes from REDHAT_CERT_ID_RELAY, which must exist before this merges: the resolve step fails the whole run when any component's variable is missing. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 ++++++++++++++++++++++++++++ relay/Dockerfile.ubi | 33 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 relay/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index c6cd62bca..763da745d 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -34,6 +34,7 @@ on: - reverse-proxy - netbird-server - signal + - relay version: description: "Released version, e.g. v0.80.0" type: string @@ -70,6 +71,7 @@ jobs: "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" "signal ghcr.io/netbirdio/signal -ubi" + "relay ghcr.io/netbirdio/relay -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1e7acbaa7..785bc452a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -506,6 +506,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: relay-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: relay/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l relay/LICENSE "{{ .ContextDir }}/licenses" ./relay amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: signal disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/relay/Dockerfile.ubi b/relay/Dockerfile.ubi new file mode 100644 index 000000000..86b0cbc01 --- /dev/null +++ b/relay/Dockerfile.ubi @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-relay" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Relay" \ + description="NetBird Relay carries traffic between peers when a direct connection is unavailable." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created Let's Encrypt keys retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_LOG_FILE=console +# Unprivileged port: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root. 8443/udp carries QUIC and +# 9000 is the health probe. +ENV NB_LISTEN_ADDRESS=":8443" +EXPOSE 8443 8443/udp +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-relay"] From e4ddd9a3fc7055c4f4880f8e3275769c820cb6e1 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:44:02 +0200 Subject: [PATCH 52/70] [management] Add a release-wired UBI image variant (#8167) * [management] Add a release-wired UBI image variant OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy, combined server and rootless client already ship -ubi variants; this adds the same for management, published as -ubi and ubi-latest for amd64 and arm64. The default --port of 80 cannot bind without root, so the image defaults to 8080. For Let's Encrypt, public 443 forwards to --port and --letsencrypt-listen-address is empty (#7706), so the main listener answers the TLS-ALPN challenges. * [management] Certify the management UBI image Adds management to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_MANAGEMENT repository variable. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 ++++++++++++++++++++++++++++ management/Dockerfile.ubi | 33 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 management/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index 763da745d..96d50de9f 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -33,6 +33,7 @@ on: - client-rootless - reverse-proxy - netbird-server + - management - signal - relay version: @@ -70,6 +71,7 @@ jobs: "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" + "management ghcr.io/netbirdio/management -ubi" "signal ghcr.io/netbirdio/signal -ubi" "relay ghcr.io/netbirdio/relay -ubi" ) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 785bc452a..de5f7419c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -620,6 +620,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: management-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: management/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l management/LICENSE "{{ .ContextDir }}/licenses" ./management amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=1 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: upload disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/management/Dockerfile.ubi b/management/Dockerfile.ubi new file mode 100644 index 000000000..628d1a086 --- /dev/null +++ b/management/Dockerfile.ubi @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-management" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Management" \ + description="NetBird Management is the control plane of a NetBird network and serves its REST and gRPC APIs." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created keys and databases retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird /etc/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +# Runtimes such as OpenShift and Podman reserve ports below 1024 for root, so +# --port defaults to 8080 here. With --letsencrypt-domain, forward public 443 +# to --port and set --letsencrypt-listen-address to empty, so the main listener +# answers the TLS-ALPN challenges. 33073 is the legacy gRPC port. +EXPOSE 8080 33073 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-mgmt", "management"] +CMD ["--port", "8080", "--log-file", "console"] From 8c70f9ec71e2a3b4ed01e8776a6a78b1f5a8118c Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:13:03 +0200 Subject: [PATCH 53/70] [management] fix peer ip allocation test (#8173) --- management/server/peer_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 93f38f25e..f6a7cc4c4 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -3252,12 +3252,12 @@ func TestAddPeer_ExhaustedIPAllocationFails(t *testing.T) { return err } require.NoError(t, addPeer("host-1")) - require.NoError(t, addPeer("host-2")) - err = addPeer("host-3") + err = addPeer("host-2") require.Error(t, err, "no free IP left, registration must fail") peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) - assert.Len(t, peers, 2) + require.Len(t, peers, 1) + assert.Equal(t, netip.MustParseAddr("100.64.0.1"), peers[0].IP) } From a3949581600790a7ac1640be43f824150b11dc83 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:36:04 +0200 Subject: [PATCH 54/70] [management] do in place expand of loaded snapshots (#8177) --- management/server/affectedpeers/resolver.go | 6 +- .../server/affectedpeers/resolver_test.go | 6 ++ management/server/group.go | 27 +++-- management/server/group_test.go | 98 +++++++++++++++++++ management/server/peer.go | 54 ++++------ management/server/user.go | 12 ++- 6 files changed, 148 insertions(+), 55 deletions(-) diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index 895e4fd36..f6946639d 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -267,10 +267,10 @@ func (c Change) isEmpty() bool { } // Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, -// no store access. Run after the producing tx commits. Logs the full walk at -// trace level for diagnosing a miscalculation. +// no store access. Run after the producing tx commits. A nil Snapshot expands to +// nothing. Logs the full walk at trace level for diagnosing a miscalculation. func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string { - if c.isEmpty() { + if snap == nil || c.isEmpty() { return nil } r := newResolver(ctx, snap, accountID, c) diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go index b0c430c3c..d7858b82c 100644 --- a/management/server/affectedpeers/resolver_test.go +++ b/management/server/affectedpeers/resolver_test.go @@ -1,6 +1,7 @@ package affectedpeers import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -77,6 +78,11 @@ func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) { assert.Empty(t, peers, "domain resource type should not produce direct peer IDs") } +func TestExpand_NilSnapshot(t *testing.T) { + var snap *Snapshot + assert.Nil(t, snap.Expand(context.Background(), "acc", Change{ChangedGroupIDs: []string{"g"}})) +} + func TestChangeIsEmpty(t *testing.T) { assert.True(t, Change{}.isEmpty()) assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty()) diff --git a/management/server/group.go b/management/server/group.go index 8d91df3ab..d0d6d2628 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -6,12 +6,12 @@ import ( "fmt" "slices" - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/rs/xid" log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/affectedpeers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -249,8 +249,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var globalErr error createdCount := 0 @@ -281,7 +280,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err }) if err != nil { - log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) + log.WithContext(ctx).Errorf("failed to create group %s: %v", newGroup.ID, err) if createdCount == 0 { return err } @@ -290,15 +289,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us continue } createdCount++ - snaps = append(snaps, snap) - changes = append(changes, change) + affected.add(snap.Expand(ctx, accountID, change)) } for _, storeEvent := range eventsToStore { storeEvent() } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return globalErr } @@ -317,8 +315,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var globalErr error for _, newGroup := range groups { @@ -333,15 +330,14 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us continue } eventsToStore = append(eventsToStore, events...) - snaps = append(snaps, snap) - changes = append(changes, change) + affected.add(snap.Expand(ctx, accountID, change)) } for _, storeEvent := range eventsToStore { storeEvent() } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return globalErr } @@ -538,7 +534,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } - go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap, ipv6Snap}, []affectedpeers.Change{change, ipv6Change}) + affected := make(affectedPeerSet) + affected.add(snap.Expand(ctx, accountID, change)) + affected.add(ipv6Snap.Expand(ctx, accountID, ipv6Change)) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return allErrors } diff --git a/management/server/group_test.go b/management/server/group_test.go index f0ccd385c..61ba56c44 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -1386,3 +1386,101 @@ func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) { assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership") }) } + +func TestGroupsBatchAccountPeersUpdate(t *testing.T) { + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + + for _, group := range []*types.Group{ + {ID: "groupA", Name: "GroupA", Peers: []string{peer1.ID, peer2.ID}}, + {ID: "groupB", Name: "GroupB", Peers: []string{peer3.ID}}, + } { + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + } + + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + step(t, "creating unlinked groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "groupC", Name: "GroupC", Peers: []string{peer1.ID}}, + {ID: "groupD", Name: "GroupD"}, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + step(t, "updating a batch with a linked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "groupD", Name: "GroupD renamed"}, + {ID: "groupB", Name: "GroupB renamed"}, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + step(t, "updating a batch where one item fails", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "missing", Name: "Missing"}, + {ID: "groupB", Name: "GroupB"}, + }) + require.Error(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + }) +} diff --git a/management/server/peer.go b/management/server/peer.go index c60737535..1e3f72a3c 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1596,30 +1596,20 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account // ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed // transaction) into the affected peers and dispatches the network-map refresh. // Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk -// never holds the write lock, over the consistent in-tx snapshot. Exported so the -// networks sub-package managers (which hold only account.Manager) share it. +// never holds the write lock, over the consistent in-tx snapshot. The walk runs +// before the dispatch goroutine starts, so the snapshot is released on return. +// Exported so the networks sub-package managers (which hold only account.Manager) +// share it. func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { - go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change}) + go am.dispatchAffectedPeerIDs(ctx, accountID, snap.Expand(ctx, accountID, change)) } -// dispatchAffected expands one or more (snapshot, change) pairs — collected across -// one or several transactions — unions their affected peers, and dispatches a -// single network-map refresh. Each snapshot must already be loaded inside its -// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned -// in a goroutine that outlives the request, so it detaches from the request -// context's cancellation up front. -func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) { +// dispatchAffectedPeerIDs dispatches a single network-map refresh for already +// expanded affected peer IDs. It may run in a goroutine that outlives the +// request, so it detaches from the request context's cancellation up front. +func (am *DefaultAccountManager) dispatchAffectedPeerIDs(ctx context.Context, accountID string, affectedPeerIDs []string) { ctx = context.WithoutCancel(ctx) - var lists [][]string - for i, snap := range snaps { - if snap == nil { - continue - } - lists = append(lists, snap.Expand(ctx, accountID, changes[i])) - } - - affectedPeerIDs := unionStrings(lists...) if len(affectedPeerIDs) == 0 { log.WithContext(ctx).Tracef("no affected peers for account %s", accountID) return @@ -1629,21 +1619,19 @@ func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID _ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs) } -// unionStrings concatenates the given string lists into one deduplicated slice, -// preserving first-occurrence order. -func unionStrings(lists ...[]string) []string { - seen := make(map[string]struct{}) - var out []string - for _, list := range lists { - for _, id := range list { - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } +// affectedPeerSet collects the affected peer IDs of several snapshot expansions, +// deduplicating as they are added so it stays bounded by the account's peers +// however many items a batch has. +type affectedPeerSet map[string]struct{} + +func (s affectedPeerSet) add(peerIDs []string) { + for _, id := range peerIDs { + s[id] = struct{}{} } - return out +} + +func (s affectedPeerSet) ids() []string { + return maps.Keys(s) } // affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's diff --git a/management/server/user.go b/management/server/user.go index 5f29f4df7..e64320666 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -593,8 +593,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, err } - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var peersToExpire []*nbpeer.Peer var addUserEvents []func() var usersToSave = make([]*types.User, 0, len(updates)) @@ -629,6 +628,8 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, status.Errorf(status.InvalidArgument, "provided user update is nil") } + var itemSnap *affectedpeers.Snapshot + var itemChange affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { change, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate( ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings, @@ -647,8 +648,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return err } - snaps = append(snaps, snap) - changes = append(changes, change) + itemSnap, itemChange = snap, change usersToSave = append(usersToSave, updatedUser) addUserEvents = append(addUserEvents, userEvents...) peersToExpire = append(peersToExpire, userPeersToExpire...) @@ -662,7 +662,9 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, } globalErr = errors.Join(globalErr, err) // continue when updating multiple users + continue } + affected.add(itemSnap.Expand(ctx, accountID, itemChange)) } var updatedUsersInfo = make([]*types.UserInfo, 0, len(usersToSave)) @@ -693,7 +695,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { return nil, fmt.Errorf("failed to increment network serial: %w", err) } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) } return updatedUsersInfo, globalErr From 70ddb2a39380605c8b0d928f15736489a14d86c0 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:54:30 +0200 Subject: [PATCH 55/70] [client] Bound the Signal stream registration wait and track its state based on stream health (#8175) * [client] Move the Signal registration wait into its own helper Pure move, no behavior change. The wait for the registration header and the check on its value leave connect() for awaitRegistration(), so the next commit can bound that wait in one place. * [client] Split the Signal test server helper from its listener setup Pure move, no behavior change. serveTestSignalExchange serves any SignalExchangeServer on a local listener; startTestSignalServer keeps serving the real server through it. Lets a test wrap the real server. * [client] Bound the wait for the Signal registration header After an outage the Signal client could wait forever for the registration header of a new stream. Keepalive cannot end that wait when whatever answers the transport (a proxy in front of Signal, or Signal itself) accepts the stream and never answers it, and the receive watchdog only starts once registration succeeds. Signal then stayed down until the daemon was restarted, while Management and Relay recovered. Cancel the stream when the header does not arrive within 10 seconds, so connect() fails and the existing retry loop opens a new stream. A timer is used instead of a context deadline because the stream context must outlive registration. * [client] Report Signal unhealthy while no stream is registered IsHealthy judged Signal by the gRPC connection state alone and marked it connected whenever the connection was Ready or Idle. With the connection up but no stream registered, the client cannot receive offers or answers, yet netbird status showed Signal: Connected, hiding a stuck registration for as long as it lasted. Require a registered stream before reporting healthy. When there is none, return false without touching the recorder: the disconnect that dropped the stream already reported its reason. * [client] Name the server that reconnected in the SR watcher log Signal and Relay shared one reconnect callback, which logged "reconnected to Signal or Relay server" for either. A Relay reconnect while the Signal stream was down read as Signal having recovered, which misled diagnosis of a stuck Signal registration. Register one callback per server so the log names the one that actually reconnected. Which listeners get notified, and when, is unchanged. * [client] Trim comments in the Signal registration and health code Comment-only change, no behavior change. Drop comments that restate the code and keep only the reason a timer is used instead of a context deadline. * [client] Check Signal stream registration before the connection state IsHealthy returned true for a Connecting or Shutdown channel before the registration check ran, so a client with no registered stream could still be reported healthy while the channel was reconnecting. Check the stream first; the connection-state cases now only apply to a client that has a registered stream. * [client] Log a plain connect in the SR watcher The Signal and Relay callbacks also fire on the first connection, not only after a disconnect, so "reconnected" was wrong at startup. Log "connected to server" for both cases. * [client] Say what the SR watcher does when it logs The line was read as a per-server connect log, which Signal and Relay already emit themselves. What it marks is the peer guards being told to reset their reconnection backoff, which happens only once both are ready. Log that, naming the server whose event triggered it. --- client/internal/peer/guard/sr_watcher.go | 16 ++- shared/signal/client/grpc.go | 41 +++++-- shared/signal/client/health_test.go | 130 ++++++++++++++++++++++ shared/signal/client/registration_test.go | 86 ++++++++++++++ shared/signal/client/watchdog_test.go | 13 ++- 5 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 shared/signal/client/health_test.go create mode 100644 shared/signal/client/registration_test.go diff --git a/client/internal/peer/guard/sr_watcher.go b/client/internal/peer/guard/sr_watcher.go index 0befd7438..8eac1a858 100644 --- a/client/internal/peer/guard/sr_watcher.go +++ b/client/internal/peer/guard/sr_watcher.go @@ -54,8 +54,8 @@ func (w *SRWatcher) Start(disableICEMonitor bool) { iceMonitor := NewICEMonitor(w.iFaceDiscover, w.iceConfig, GetICEMonitorPeriod()) go iceMonitor.Start(ctx, w.onICEChanged) } - w.signalClient.SetOnReconnectedListener(w.onReconnected) - w.relayManager.SetOnReconnectedListener(w.onReconnected) + w.signalClient.SetOnReconnectedListener(w.onSignalReconnected) + w.relayManager.SetOnReconnectedListener(w.onRelayReconnected) } @@ -96,7 +96,15 @@ func (w *SRWatcher) onICEChanged() { w.notify() } -func (w *SRWatcher) onReconnected() { +func (w *SRWatcher) onSignalReconnected() { + w.onReconnected("Signal") +} + +func (w *SRWatcher) onRelayReconnected() { + w.onReconnected("Relay") +} + +func (w *SRWatcher) onReconnected(server string) { if !w.signalClient.Ready() { return } @@ -104,7 +112,7 @@ func (w *SRWatcher) onReconnected() { return } - log.Infof("reconnected to Signal or Relay server") + log.Infof("Signal and Relay ready (triggered by %s), resetting peer reconnection backoff", server) w.notify() } diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index 92be57b30..e489078a9 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -38,6 +38,8 @@ const ( receiveProbeTimeout = 10 * time.Second // receiveWatchdogInterval is how often the watchdog evaluates the stream. receiveWatchdogInterval = 10 * time.Second + // defaultRegistrationTimeout bounds the wait for a new stream's registration header. + defaultRegistrationTimeout = 10 * time.Second ) // errReceiveStreamStalled is reported when the receive stream is transport-alive @@ -92,6 +94,8 @@ type GrpcClient struct { // new stream feeds the same worker, and only triggers a reconnect storm. receiveHandoffBlocked atomic.Bool watchdogWg sync.WaitGroup + + registrationTimeout time.Duration } // Option configures optional GrpcClient behavior. @@ -112,6 +116,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo mux: sync.Mutex{}, status: StreamDisconnected, connStateCallbackLock: sync.RWMutex{}, + registrationTimeout: defaultRegistrationTimeout, } for _, opt := range opts { opt(c) @@ -230,7 +235,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management) streamCtx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connect(streamCtx, c.key.PublicKey().String()) + stream, err := c.connect(streamCtx, cancelStream, c.key.PublicKey().String()) if err != nil { log.Warnf("disconnected from the Signal Exchange due to an error: %v", err) return err @@ -308,7 +313,7 @@ func (c *GrpcClient) notifyStreamConnected() { } } -func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExchange_ConnectStreamClient, error) { +func (c *GrpcClient) connect(ctx context.Context, cancel context.CancelFunc, key string) (proto.SignalExchange_ConnectStreamClient, error) { c.stream = nil // add key fingerprint to the request header to be identified on the server side @@ -319,19 +324,30 @@ func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExcha if err != nil { return nil, err } - // blocks - header, err := c.stream.Header() - if err != nil { + if err := awaitRegistration(stream, c.registrationTimeout, cancel); err != nil { return nil, err } - registered := header.Get(proto.HeaderRegistered) - if len(registered) == 0 { - return nil, fmt.Errorf("didn't receive a registration header from the Signal server whille connecting to the streams") - } return stream, nil } +func awaitRegistration(stream proto.SignalExchange_ConnectStreamClient, timeout time.Duration, cancel context.CancelFunc) error { + // A timer rather than a context deadline: the stream context must outlive registration. + timer := time.AfterFunc(timeout, cancel) + header, err := stream.Header() + if !timer.Stop() { + return fmt.Errorf("no registration header from the Signal server within %s", timeout) + } + if err != nil { + return err + } + registered := header.Get(proto.HeaderRegistered) + if len(registered) == 0 { + return fmt.Errorf("didn't receive a registration header from the Signal server whille connecting to the streams") + } + return nil +} + // Ready indicates whether the client is okay and Ready to be used // for now it just checks whether gRPC connection to the service is in state Ready func (c *GrpcClient) Ready() bool { @@ -343,6 +359,13 @@ func (c *GrpcClient) Ready() bool { // recorder accordingly. It does not actively probe: the watchdog // (watchReceiveStream) owns probing the receive path and reconnecting. func (c *GrpcClient) IsHealthy() bool { + c.mux.Lock() + registered := c.status == StreamConnected + c.mux.Unlock() + if !registered { + return false + } + switch c.signalConn.GetState() { case connectivity.TransientFailure: return false diff --git a/shared/signal/client/health_test.go b/shared/signal/client/health_test.go new file mode 100644 index 000000000..d62ee2cc2 --- /dev/null +++ b/shared/signal/client/health_test.go @@ -0,0 +1,130 @@ +package client + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" +) + +type recordingNotifier struct { + mu sync.Mutex + connected bool +} + +func (n *recordingNotifier) MarkSignalDisconnected(error) { + n.mu.Lock() + defer n.mu.Unlock() + n.connected = false +} + +func (n *recordingNotifier) MarkSignalConnected() { + n.mu.Lock() + defer n.mu.Unlock() + n.connected = true +} + +func (n *recordingNotifier) isConnected() bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.connected +} + +// TestIsHealthyRequiresRegisteredStream covers the status reporting half of a +// stuck registration: the gRPC connection to Signal is up but no stream is +// registered, so no peer can reach this client. The health check used to look +// at the connection only, reporting Signal as connected throughout. +func TestIsHealthyRequiresRegisteredStream(t *testing.T) { + addr := startTestSignalServer(t) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + notifier := &recordingNotifier{} + client.SetConnStateListener(notifier) + + assert.False(t, client.IsHealthy(), "no registered stream must not be reported healthy") + assert.False(t, notifier.isConnected(), "no registered stream must not mark Signal connected") + + // Stop Receive before Close runs (cleanups are LIFO), so the two never overlap. + receiveDone := make(chan struct{}) + go func() { + defer close(receiveDone) + _ = client.Receive(ctx, func(*sigProto.Message) error { return nil }) + }() + t.Cleanup(func() { + cancel() + <-receiveDone + }) + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected(ctx) + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("signal stream did not connect within timeout") + } + + assert.True(t, client.IsHealthy(), "a registered stream on a usable connection must be healthy") + assert.True(t, notifier.isConnected(), "a registered stream must mark Signal connected") +} + +// TestIsHealthyConnectingWithoutStream covers the window while the connection is +// still being established: a TCP listener that accepts but never speaks HTTP/2 +// keeps the channel in Connecting, and with no registered stream the client +// cannot receive anything yet, so it must not be reported healthy. +func TestIsHealthyConnectingWithoutStream(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + var mu sync.Mutex + var accepted []net.Conn + t.Cleanup(func() { + _ = lis.Close() + mu.Lock() + defer mu.Unlock() + for _, c := range accepted { + _ = c.Close() + } + }) + go func() { + for { + c, err := lis.Accept() + if err != nil { + return + } + mu.Lock() + accepted = append(accepted, c) + mu.Unlock() + } + }() + + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + conn.Connect() + require.Eventually(t, func() bool { return conn.GetState() == connectivity.Connecting }, 5*time.Second, 10*time.Millisecond, + "channel should be stuck connecting to a listener that never answers") + + client := &GrpcClient{signalConn: conn, status: StreamDisconnected} + assert.False(t, client.IsHealthy(), "a connecting channel with no registered stream must not be reported healthy") +} diff --git a/shared/signal/client/registration_test.go b/shared/signal/client/registration_test.go new file mode 100644 index 000000000..7703f843d --- /dev/null +++ b/shared/signal/client/registration_test.go @@ -0,0 +1,86 @@ +package client + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +// stallingSignalServer holds the first ConnectStream open without ever sending +// the registration header, while the transport keeps answering keepalive pings. +// Later streams go to the real server. This is what a responder in front of or +// inside Signal looks like when it accepts a stream and never answers it. +type stallingSignalServer struct { + *server.Server + stalled atomic.Bool + held chan struct{} +} + +func (s *stallingSignalServer) ConnectStream(stream sigProto.SignalExchange_ConnectStreamServer) error { + if s.stalled.CompareAndSwap(false, true) { + close(s.held) + <-stream.Context().Done() + return stream.Context().Err() + } + return s.Server.ConnectStream(stream) +} + +// TestReceiveRetriesWhenRegistrationHeaderNeverArrives reproduces a client stuck +// forever waiting for the registration header of a stream the server side never +// answers: the receive watchdog only starts after registration, so nothing ended +// the wait and Signal never came back until the daemon was restarted. The client +// must give up on the silent stream and register on a new one. +func TestReceiveRetriesWhenRegistrationHeaderNeverArrives(t *testing.T) { + realServer, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + stalling := &stallingSignalServer{Server: realServer, held: make(chan struct{})} + addr := serveTestSignalExchange(t, stalling) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + client.registrationTimeout = 200 * time.Millisecond + + // Stop Receive before Close runs (cleanups are LIFO), so the two never overlap. + receiveDone := make(chan struct{}) + go func() { + defer close(receiveDone) + _ = client.Receive(ctx, func(*sigProto.Message) error { return nil }) + }() + t.Cleanup(func() { + cancel() + <-receiveDone + }) + + select { + case <-stalling.held: + case <-time.After(5 * time.Second): + t.Fatal("first stream did not reach the stalling server") + } + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected(ctx) + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("client never registered after the first stream went unanswered") + } + require.True(t, client.StreamConnected(), "client should report a registered stream") +} diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go index a8bbafa29..ab0ab512e 100644 --- a/shared/signal/client/watchdog_test.go +++ b/shared/signal/client/watchdog_test.go @@ -19,13 +19,20 @@ import ( func startTestSignalServer(t *testing.T) string { t.Helper() + srv, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + + return serveTestSignalExchange(t, srv) +} + +func serveTestSignalExchange(t *testing.T, impl sigProto.SignalExchangeServer) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) s := grpc.NewServer() - srv, err := server.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - sigProto.RegisterSignalExchangeServer(s, srv) + sigProto.RegisterSignalExchangeServer(s, impl) go func() { _ = s.Serve(lis) From 10e34090fa3f0a61f2366ce518955d3b9e168f03 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 13 Oct 2026 05:21:37 -0400 Subject: [PATCH 56/70] [client] Ensure onDisconnected is called on connection teardown (#8178) Conn.Close previously called onDisconnected only when evalStatus reported StatusConnected. When a connection drops or closes from a connecting or disconnecting state, the callback was skipped. This left registered listeners such as Rosenpass unaware of the teardown, keeping peer workers active and leaking sockets. Invoke onDisconnected unconditionally on Conn.Close if registered. --- client/internal/peer/conn.go | 2 +- client/internal/peer/conn_test.go | 43 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 17823e043..b8a32b0cb 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -341,7 +341,7 @@ func (conn *Conn) Close(signalToRemote bool) { conn.Log.Errorf("failed to remove wg endpoint: %v", err) } - if conn.evalStatus() == StatusConnected && conn.onDisconnected != nil { + if conn.onDisconnected != nil { conn.onDisconnected(conn.config.WgConfig.RemoteKey) } diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 8f3d6216d..b481c95ed 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -3,19 +3,26 @@ package peer import ( "context" "fmt" + "net" + "net/netip" "os" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/configurer" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgproxy" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/internal/peer/worker" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/util" ) @@ -418,3 +425,39 @@ func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) { "priority %s: isRelayed and the %q metric tag must agree", priority, tag) } } + +type stubWGIface struct{} + +func (s *stubWGIface) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { + return nil +} +func (s *stubWGIface) RemovePeer(peerKey string) error { return nil } +func (s *stubWGIface) GetStats() (map[string]configurer.WGStats, error) { + return map[string]configurer.WGStats{}, nil +} +func (s *stubWGIface) GetProxy() wgproxy.Proxy { return nil } +func (s *stubWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (s *stubWGIface) RemoveEndpointAddress(key string) error { return nil } + +func TestConn_Close_CallsOnDisconnected(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + conn.endpointUpdater = NewEndpointUpdater(conn.Log, conn.config.WgConfig, false) + conn.config.WgConfig.WgInterface = &stubWGIface{} + conn.endpointUpdater.wgConfig.WgInterface = &stubWGIface{} + conn.workerRelay = &WorkerRelay{} + ctx, cancel := context.WithCancel(context.Background()) + conn.ctx = ctx + conn.ctxCancel = cancel + conn.opened = true + conn.statusICE = worker.NewAtomicStatus() + conn.statusRelay = worker.NewAtomicStatus() + conn.statusRecorder = NewRecorder("https://mgm") + + // The connection is closed while in a disconnected or connecting state + // (e.g. peer dropped, ICE failed, or management disconnect). + conn.Close(false) + + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "Close must notify onDisconnected so Rosenpass cleans up its peer worker") +} From 49ec0e26999abec532c2de28b63f80c2e04c95c5 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 13 Oct 2026 13:04:10 +0300 Subject: [PATCH 57/70] [management] Emit activity events for batch group ops (#8180) --- management/server/group.go | 10 ++++----- management/server/group_test.go | 39 +++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/management/server/group.go b/management/server/group.go index d0d6d2628..3cd31f152 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -256,6 +256,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us for _, newGroup := range groups { change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} var snap *affectedpeers.Snapshot + var events []func() err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { return err @@ -263,6 +264,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us newGroup.AccountID = accountID newGroup.PublicID = xid.New().String() + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) if err = transaction.CreateGroup(ctx, newGroup); err != nil { return err @@ -273,9 +275,6 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err } - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - eventsToStore = append(eventsToStore, events...) - snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) @@ -290,6 +289,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } createdCount++ affected.add(snap.Expand(ctx, accountID, change)) + eventsToStore = append(eventsToStore, events...) } for _, storeEvent := range eventsToStore { @@ -358,6 +358,8 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI } newGroup.PublicID = oldGroup.PublicID + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -372,8 +374,6 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI return err } - events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) diff --git a/management/server/group_test.go b/management/server/group_test.go index 61ba56c44..9bcebfd34 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -20,6 +20,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" @@ -77,6 +78,42 @@ func TestDefaultAccountManager_CreateGroup(t *testing.T) { } } +func TestDefaultAccountManager_BatchGroupChangesStoreEvents(t *testing.T) { + t.Setenv("NB_EVENT_ACTIVITY_LOG_ENABLED", "true") + + am, _, err := createManager(t) + require.NoError(t, err) + + _, account, err := initTestGroupAccount(am) + require.NoError(t, err) + + group := &types.Group{ID: "grp-batch", Name: "Batch group", Issued: types.GroupIssuedAPI} + err = am.CreateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{group}) + require.NoError(t, err) + + renamed := &types.Group{ID: group.ID, Name: "Batch group renamed", Issued: types.GroupIssuedAPI} + err = am.UpdateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{renamed}) + require.NoError(t, err) + + hasEvent := func(code activity.Activity) bool { + events, err := am.eventStore.Get(context.Background(), account.Id, 0, 100, true) + if err != nil { + return false + } + for _, event := range events { + if event.Activity == code && event.TargetID == group.ID { + return true + } + } + return false + } + + assert.Eventually(t, func() bool { return hasEvent(activity.GroupCreated) }, time.Second, 10*time.Millisecond, + "CreateGroups should store a group created event") + assert.Eventually(t, func() bool { return hasEvent(activity.GroupUpdated) }, time.Second, 10*time.Millisecond, + "UpdateGroups should store a group updated event on rename") +} + func TestDefaultAccountManager_DeleteGroup(t *testing.T) { am, _, err := createManager(t) if err != nil { @@ -1085,7 +1122,6 @@ func Test_AddPeerToGroup(t *testing.T) { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return } - }(i) } startTime := time.Now() @@ -1142,7 +1178,6 @@ func Test_AddPeerToAll(t *testing.T) { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return } - }(i) } startTime := time.Now() From f62949605e2b758bd5874e46432a932fdf5201cc Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:13:25 +0900 Subject: [PATCH 58/70] [client] Keep HA routing peer selection latency-aware and hold switches that reset connections (#7689) --- client/internal/peer/status.go | 173 +++- client/internal/peer/status_test.go | 178 +++++ client/internal/peer/worker_ice.go | 161 +++- client/internal/peer/worker_ice_test.go | 168 ++++ client/internal/routemanager/client/client.go | 755 ++++++++++++++---- .../routemanager/client/client_bench_test.go | 8 +- .../routemanager/client/client_test.go | 652 +++++++++++++++ client/internal/routemanager/client/env.go | 77 ++ .../client/relayed_switch_test.go | 358 +++++++++ 9 files changed, 2334 insertions(+), 196 deletions(-) create mode 100644 client/internal/peer/worker_ice_test.go create mode 100644 client/internal/routemanager/client/env.go create mode 100644 client/internal/routemanager/client/relayed_switch_test.go diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 6c44178e1..b695e3b22 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -28,6 +28,11 @@ import ( const eventQueueSize = 10 +// routerLatencyNotifyThreshold is the latency or noise change that notifies the route +// watchers subscribed to a peer. Latency is resampled continuously, so without +// a threshold every sample would wake every watcher of every routing peer. +const routerLatencyNotifyThreshold = 5 * time.Millisecond + type ResolvedDomainInfo struct { Prefixes []netip.Prefix ParentDomain domain.Domain @@ -41,11 +46,21 @@ type EventListener interface { OnEvent(event *proto.SystemEvent) } +// LatencySample is a peer's smoothed round trip time together with the +// uncertainty of that estimate, which is what the jitter of the path leaves +// behind after smoothing. Comparing two peers is only meaningful when their +// difference is larger than this. +type LatencySample struct { + Latency time.Duration + Noise time.Duration +} + // RouterState status for router peers. This contains relevant fields for route manager type RouterState struct { - Status ConnStatus - Relayed bool - Latency time.Duration + Status ConnStatus + Relayed bool + Latency time.Duration + LatencyNoise time.Duration } // State contains the latest state of a peer @@ -67,9 +82,11 @@ type State struct { BytesTx int64 BytesRx int64 Latency time.Duration - RosenpassEnabled bool - SSHHostKey []byte - routes map[string]struct{} + // LatencyNoise is the uncertainty of Latency on a jittery path + LatencyNoise time.Duration + RosenpassEnabled bool + SSHHostKey []byte + routes map[string]struct{} } // AddRoute add a single route to routes map @@ -103,6 +120,13 @@ func (s *State) GetRoutes() map[string]struct{} { return maps.Clone(s.routes) } +// clearLatency drops the latency estimate, which is only measured over a direct +// connection and says nothing about the path once that connection is gone. +func (s *State) clearLatency() { + s.Latency = 0 + s.LatencyNoise = 0 +} + // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { IP string @@ -163,11 +187,25 @@ type FullStatus struct { Events []*proto.SystemEvent } +// routerSnapshot is a router-state map stamped with a sequence number taken +// under the status lock. Snapshots are built under the lock but delivered +// outside it, so two dispatchers can race on the way to a subscriber; the +// stamp lets delivery drop the older snapshot instead of letting it overwrite +// a newer one. +type routerSnapshot struct { + seq uint64 + states map[string]RouterState +} + type StatusChangeSubscription struct { peerID string id string eventsChan chan map[string]RouterState ctx context.Context + + // sendMu serializes deliveries and guards lastSeq + sendMu sync.Mutex + lastSeq uint64 } func newStatusChangeSubscription(ctx context.Context, peerID string) *StatusChangeSubscription { @@ -184,17 +222,54 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { return s.eventsChan } +// deliver forwards a snapshot to the subscriber, dropping it when a newer one +// has already been delivered. A blocking deliver waits for buffer space, since +// peer state transitions must not be lost. A non-blocking one gives up when the +// subscriber is backlogged or another delivery is in flight; it is meant for +// latency refreshes, which are superseded by the next sample anyway and are +// also re-read from the recorder on the route watchers' periodic re-evaluation. +// Only a snapshot that reached the subscriber supersedes older ones. +func (s *StatusChangeSubscription) deliver(snapshot *routerSnapshot, block bool) { + if block { + s.sendMu.Lock() + } else if !s.sendMu.TryLock() { + return + } + defer s.sendMu.Unlock() + + if snapshot.seq <= s.lastSeq { + return + } + + if block { + select { + case s.eventsChan <- snapshot.states: + s.lastSeq = snapshot.seq + case <-s.ctx.Done(): + } + return + } + + select { + case s.eventsChan <- snapshot.states: + s.lastSeq = snapshot.seq + default: + } +} + // Status holds a state of peers, signal, management connections and relays. // mux is an RWMutex so hot read paths (notably PeerStateByIP, called for // every private-service request) don't contend against each other. // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.RWMutex - muxRelays sync.RWMutex - peers map[string]State - ipToKey map[string]string - activeRoutePeers map[route.HAUniqueID]string - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + mux sync.RWMutex + muxRelays sync.RWMutex + peers map[string]State + ipToKey map[string]string + activeRoutePeers map[route.HAUniqueID]string + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + // routerSeq stamps router-state snapshots, see routerSnapshot + routerSeq uint64 signalState bool signalError error managementState bool @@ -386,6 +461,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint peerState.RelayServerAddress = receivedState.RelayServerAddress peerState.RosenpassEnabled = receivedState.RosenpassEnabled + peerState.clearLatency() } d.peers[receivedState.PubKey] = peerState @@ -402,7 +478,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -522,7 +598,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -559,7 +635,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -595,7 +671,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -620,6 +696,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { peerState.RemoteIceCandidateType = receivedState.RemoteIceCandidateType peerState.LocalIceCandidateEndpoint = receivedState.LocalIceCandidateEndpoint peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint + peerState.clearLatency() d.peers[receivedState.PubKey] = peerState @@ -634,7 +711,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -714,7 +791,7 @@ func (d *Status) FinishPeerListModifications() { // snapshot per-peer router state to deliver after the lock is released type routerDispatch struct { peerID string - snapshot map[string]RouterState + snapshot *routerSnapshot } dispatches := make([]routerDispatch, 0, len(d.peers)) for key := range d.peers { @@ -728,7 +805,7 @@ func (d *Status) FinishPeerListModifications() { d.notifier.peerListChanged(numPeers) for _, rd := range dispatches { - d.dispatchRouterPeers(rd.peerID, rd.snapshot) + d.dispatchRouterPeers(rd.peerID, rd.snapshot, true) } d.notifyStateChange() } @@ -1057,22 +1134,42 @@ func (d *Status) GetManagementState() ManagementState { } } -func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error { - if latency <= 0 { +func (d *Status) UpdateLatency(pubKey string, sample LatencySample) error { + if sample.Latency <= 0 { return nil } d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[pubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } - peerState.Latency = latency + + previous, previousNoise := peerState.Latency, peerState.LatencyNoise + peerState.Latency = sample.Latency + peerState.LatencyNoise = sample.Noise d.peers[pubKey] = peerState + + // the noise sets the switch margin, so it counts on its own + notifyRouter := absDuration(sample.Latency-previous) >= routerLatencyNotifyThreshold || + absDuration(sample.Noise-previousNoise) >= routerLatencyNotifyThreshold + routerSnapshot := d.snapshotRouterPeersLocked(pubKey, notifyRouter) + + d.mux.Unlock() + + d.dispatchRouterPeers(pubKey, routerSnapshot, false) + return nil } +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} + // IsLoginRequired determines if a peer's login has expired. func (d *Status) IsLoginRequired() bool { d.mux.RLock() @@ -1229,7 +1326,7 @@ func (d *Status) RemoveConnectionListener() { // Caller MUST hold d.mux. Returns nil when there are no subscribers for peerID // or when notify is false. The snapshot is consumed later by dispatchRouterPeers // outside the lock so the channel send cannot stall any d.mux holder. -func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[string]RouterState { +func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) *routerSnapshot { if !notify { return nil } @@ -1244,22 +1341,29 @@ func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[strin continue } routerPeers[pid] = RouterState{ - Status: s.ConnStatus, - Relayed: s.Relayed, - Latency: s.Latency, + Status: s.ConnStatus, + Relayed: s.Relayed, + Latency: s.Latency, + LatencyNoise: s.LatencyNoise, } } - return routerPeers + + d.routerSeq++ + + return &routerSnapshot{seq: d.routerSeq, states: routerPeers} } // dispatchRouterPeers delivers a previously snapshotted router-state map to // the peer's subscribers. Caller MUST NOT hold d.mux. The method takes a // fresh, short read of d.changeNotify under the lock to grab subscriber // channels, then sends outside the lock so a slow consumer cannot block other -// d.mux holders. The send itself stays blocking (only short-circuited by the -// subscriber's context) so peer state transitions are not silently dropped. -func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]RouterState) { - if routerPeers == nil { +// d.mux holders. With block set, the send waits for buffer space (only +// short-circuited by the subscriber's context) so peer state transitions are +// not silently dropped; without it, a backlogged subscriber loses the snapshot +// instead of stalling the caller. Out-of-order deliveries are dropped either +// way, see StatusChangeSubscription.deliver. +func (d *Status) dispatchRouterPeers(peerID string, snapshot *routerSnapshot, block bool) { + if snapshot == nil { return } @@ -1274,10 +1378,7 @@ func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]Route d.mux.Unlock() for _, sub := range subs { - select { - case sub.eventsChan <- routerPeers: - case <-sub.ctx.Done(): - } + sub.deliver(snapshot, block) } } diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index b3f01b217..2d910ccf5 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -44,6 +44,184 @@ func TestGetPeer(t *testing.T) { assert.Error(t, err, "should return error when peer doesn't exist") } +func TestUpdateLatencyNotifiesRouteWatchers(t *testing.T) { + key := "abc" + status := NewRecorder("https://mgm") + require.NoError(t, status.AddPeer(key, "abc.netbird", "100.108.254.1", "")) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + sub := status.SubscribeToPeerStateChanges(ctx, key) + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 40 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 40*time.Millisecond, states[key].Latency, "the notified state should carry the new latency") + default: + t.Fatal("expected a router state notification for the first latency sample") + } + + // below routerLatencyNotifyThreshold: recorded, but not worth waking the watchers + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 42 * time.Millisecond})) + select { + case <-sub.Events(): + t.Fatal("a latency change below the threshold should not notify") + default: + } + + peerState, err := status.GetPeer(key) + require.NoError(t, err) + assert.Equal(t, 42*time.Millisecond, peerState.Latency, "the latency should be recorded even without a notification") + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 100*time.Millisecond, states[key].Latency, "a significant latency change should notify") + default: + t.Fatal("expected a router state notification for a significant latency change") + } + + // the noise feeds the switch margin, so a large change in it matters on its + // own even when the latency stays put + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond, Noise: 20 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 20*time.Millisecond, states[key].LatencyNoise, "a significant noise change should notify") + default: + t.Fatal("expected a router state notification for a significant noise change") + } + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond, Noise: 21 * time.Millisecond})) + select { + case <-sub.Events(): + t.Fatal("a noise change below the threshold should not notify") + default: + } +} + +// TestSubscriptionDeliverOrdering covers the sequence stamping: snapshots are +// built under the status lock but delivered outside it, so a dispatcher that +// is descheduled between the two can deliver an outdated snapshot after a +// newer one. The older snapshot must be dropped, not delivered. +func TestSubscriptionDeliverOrdering(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + newer := &routerSnapshot{seq: 2, states: map[string]RouterState{"peer": {Status: StatusConnecting}}} + older := &routerSnapshot{seq: 1, states: map[string]RouterState{"peer": {Status: StatusConnected}}} + + sub.deliver(newer, true) + sub.deliver(older, true) + + select { + case states := <-sub.Events(): + assert.Equal(t, StatusConnecting, states["peer"].Status, "the newer snapshot should be delivered") + default: + t.Fatal("expected the newer snapshot to be delivered") + } + + select { + case <-sub.Events(): + t.Fatal("the outdated snapshot must be dropped, not delivered after the newer one") + default: + } +} + +// TestSubscriptionDeliverNonBlocking covers the latency dispatch path: a +// backlogged subscriber must not block the sender, and the dropped snapshot is +// recovered from the recorder by the route watchers' periodic re-evaluation. +func TestSubscriptionDeliverNonBlocking(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + // fill the subscription buffer with no consumer draining it + seq := uint64(1) + for { + before := len(sub.eventsChan) + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + seq++ + if len(sub.eventsChan) == before { + break + } + } + + done := make(chan struct{}) + go func() { + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("a non-blocking deliver must return with the buffer full") + } +} + +// A snapshot the subscriber never received must not count as delivered: a +// state transition dispatched just before it, but delivered just after, would +// otherwise be dropped as outdated and never reach the subscriber. +func TestSubscriptionDroppedDeliveryDoesNotSupersede(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + for seq := uint64(1); seq <= uint64(cap(sub.eventsChan)); seq++ { + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + } + require.Len(t, sub.eventsChan, cap(sub.eventsChan), "the buffer must be full") + + // a latency refresh stamped after the transition wins the race to the + // subscriber and is dropped on the full buffer + transition := &routerSnapshot{seq: 100, states: map[string]RouterState{"peer": {Status: StatusConnecting}}} + latency := &routerSnapshot{seq: 101, states: map[string]RouterState{"peer": {Status: StatusConnected}}} + sub.deliver(latency, false) + + for range cap(sub.eventsChan) { + <-sub.eventsChan + } + sub.deliver(transition, true) + + select { + case states := <-sub.Events(): + assert.Equal(t, StatusConnecting, states["peer"].Status, "the transition must be delivered") + default: + t.Fatal("a transition must not be dropped because a later snapshot was dropped") + } +} + +// Latency is only measured over a direct connection. Once it is gone the last +// sample describes a path the traffic no longer takes, and must not be compared +// against other routing peers as if it were current. +func TestLatencyClearedWithDirectConnection(t *testing.T) { + const key = "abc" + + newStatus := func(t *testing.T) *Status { + t.Helper() + status := NewRecorder("https://mgm") + require.NoError(t, status.AddPeer(key, "peer-a.netbird.local", "10.10.10.10", "")) + require.NoError(t, status.UpdatePeerICEState(State{PubKey: key, ConnStatus: StatusConnected})) + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 20 * time.Millisecond, Noise: time.Millisecond})) + return status + } + + assertCleared := func(t *testing.T, status *Status) { + t.Helper() + state, err := status.GetPeer(key) + require.NoError(t, err) + assert.Zero(t, state.Latency, "the latency of the lost direct path must be cleared") + assert.Zero(t, state.LatencyNoise, "the noise of the lost direct path must be cleared") + } + + t.Run("ice disconnected, relayed", func(t *testing.T) { + status := newStatus(t) + require.NoError(t, status.UpdatePeerICEStateToDisconnected(State{PubKey: key, ConnStatus: StatusConnected, Relayed: true})) + assertCleared(t, status) + }) + + t.Run("connection closed", func(t *testing.T) { + status := newStatus(t) + require.NoError(t, status.UpdatePeerState(State{PubKey: key, ConnStatus: StatusIdle})) + assertCleared(t, status) + }) +} + func TestUpdatePeerState(t *testing.T) { key := "abc" ip := "10.10.10.10" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 5979e9bdc..257164bbd 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -3,6 +3,7 @@ package peer import ( "context" "fmt" + "math" "net" "strconv" "sync" @@ -20,6 +21,26 @@ import ( "github.com/netbirdio/netbird/route" ) +const ( + // latencySampleInterval is how often the round trip time of the selected + // candidate pair is read. The ICE agent refreshes it on every consent + // binding request, so sampling is cheap and needs no extra traffic. + latencySampleInterval = 4 * time.Second + + // latencyEWMAAlpha is the weight of a new sample in the exponentially + // weighted moving average. At latencySampleInterval this yields a time + // constant of roughly 27 seconds: a sustained change is reflected within + // tens of seconds, while the residual movement on a jittery path stays + // below the switch margin that route selection applies, so noise alone + // cannot move a route. Raising it widens that residual movement. + latencyEWMAAlpha = 0.15 +) + +// latencySmoothedNoiseFactor converts the spread of the raw samples into the +// spread of the smoothed value: for an exponentially weighted moving average +// with weight a, the variance of the output is a/(2-a) of the input variance. +var latencySmoothedNoiseFactor = math.Sqrt(latencyEWMAAlpha / (2 - latencyEWMAAlpha)) + type ICEConnInfo struct { RemoteConn net.Conn RosenpassPubKey []byte @@ -137,7 +158,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.log.Debugf("recreate ICE agent: %s / %s", w.sessionID, *remoteOfferAnswer.SessionID) } dialerCtx, dialerCancel := context.WithCancel(w.ctx) - agent, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes) + agent, sampleNow, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes) if err != nil { w.log.Errorf("failed to recreate ICE Agent: %s", err) return @@ -153,7 +174,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { // Capture the cancel func at spawn time: connect reads it from the argument // instead of the field, which a newer OnNewOffer may already have replaced. - go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer) + go w.connect(dialerCtx, dialerCancel, agent, sampleNow, remoteOfferAnswer) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -217,29 +238,33 @@ func (w *WorkerICE) Close() { w.abandonNegotiation() } -func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { +// reCreateAgent creates a new ICE agent with its callbacks registered. The +// returned channel wakes that agent's latency sampler when its selected +// candidate pair changes. +func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, chan struct{}, error) { w.portForwardAttempted = false agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd) if err != nil { - return nil, fmt.Errorf("create agent: %w", err) + return nil, nil, fmt.Errorf("create agent: %w", err) } if err := agent.OnCandidate(w.onICECandidate); err != nil { - return nil, err + return nil, nil, err } if err := agent.OnConnectionStateChange(w.onConnectionStateChange(agent, dialerCancel)); err != nil { - return nil, err + return nil, nil, err } - if err := agent.OnSelectedCandidatePairChange(func(c1, c2 ice.Candidate) { - w.onICESelectedCandidatePair(agent, c1, c2) - }); err != nil { - return nil, err + // one channel per agent, so the sampler of a replaced agent cannot take a + // wakeup meant for the new one + sampleNow := make(chan struct{}, 1) + if err := agent.OnSelectedCandidatePairChange(w.onICESelectedCandidatePair(sampleNow)); err != nil { + return nil, nil, err } - return agent, nil + return agent, sampleNow, nil } func (w *WorkerICE) SessionID() ICESessionID { @@ -252,7 +277,7 @@ func (w *WorkerICE) SessionID() ICESessionID { // will block until connection succeeded // but it won't release if ICE Agent went into Disconnected or Failed state, // so we have to cancel it with the provided context once agent detected a broken connection -func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { +func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, sampleNow <-chan struct{}, remoteOfferAnswer *OfferAnswer) { w.log.Debugf("gather candidates") if err := agent.GatherCandidates(); err != nil { w.log.Warnf("failed to gather candidates: %s", err) @@ -343,6 +368,9 @@ func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc w.lastSuccess = time.Now() w.muxAgent.Unlock() + // ctx is the agent's dialer context, so sampling stops when the agent is closed + go w.sampleLatency(ctx, agent, sampleNow) + // todo: the potential problem is a race between the onConnectionStateChange // and the delivery below: after this unlock, a newer offer can replace // w.agent before onICEConnectionIsReady runs, delivering this (now stale) @@ -532,21 +560,63 @@ func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mappi return candidate, nil } -func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) { - w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(), - w.config.Key) +func (w *WorkerICE) onICESelectedCandidatePair(sampleNow chan<- struct{}) func(c1, c2 ice.Candidate) { + return func(c1, c2 ice.Candidate) { + w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(), + w.config.Key) + // refresh the recorded latency right away: it still describes the previous + // path, and route selection may run before the next sampling tick + select { + case sampleNow <- struct{}{}: + default: + } + } +} + +// sampleLatency records the smoothed round trip time of the agent's selected +// candidate pair until the agent is closed. Route selection compares the +// recorded latencies of high availability peers, so it has to keep following +// the path instead of freezing at the value observed when the pair was chosen. +func (w *WorkerICE) sampleLatency(ctx context.Context, agent *icemaker.ThreadSafeAgent, sampleNow <-chan struct{}) { + var sampler latencySampler + + ticker := time.NewTicker(latencySampleInterval) + defer ticker.Stop() + + for { + w.updateLatency(ctx, agent, &sampler) + + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-sampleNow: + } + } +} + +func (w *WorkerICE) updateLatency(ctx context.Context, agent *icemaker.ThreadSafeAgent, sampler *latencySampler) { pairStat, ok := agent.GetSelectedCandidatePairStats() if !ok { - w.log.Warnf("failed to get selected candidate pair stats") + w.log.Debugf("failed to get selected candidate pair stats") return } - duration := time.Duration(pairStat.CurrentRoundTripTime * float64(time.Second)) - if err := w.statusRecorder.UpdateLatency(w.config.Key, duration); err != nil { - w.log.Debugf("failed to update latency for peer: %s", err) + sample := sampler.observe(pairStat) + if sample.Latency <= 0 { return } + + // the agent may have been closed while sampling, and its connection's + // latency cleared: a late sample must not bring the stale value back + if ctx.Err() != nil { + return + } + + if err := w.statusRecorder.UpdateLatency(w.config.Key, sample); err != nil { + w.log.Debugf("failed to update latency for peer: %s", err) + } } func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) { @@ -612,6 +682,59 @@ func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAge } } +// latencySampler smooths the round trip time samples of a single ICE agent and +// tracks how much they scatter, so consumers can tell a real difference between +// two paths from the noise of a jittery one. +type latencySampler struct { + smoothed time.Duration + deviation time.Duration + localID string + remoteID string +} + +// observe folds a candidate pair stat into the moving average and returns the +// smoothed latency with the uncertainty of that estimate. A different candidate +// pair starts over: the path changed, so the previous samples describe a route +// that no longer carries the traffic. +func (s *latencySampler) observe(stat ice.CandidatePairStats) LatencySample { + rtt := time.Duration(stat.CurrentRoundTripTime * float64(time.Second)) + if rtt <= 0 { + return LatencySample{} + } + + if s.smoothed <= 0 || s.localID != stat.LocalCandidateID || s.remoteID != stat.RemoteCandidateID { + s.localID = stat.LocalCandidateID + s.remoteID = stat.RemoteCandidateID + s.smoothed = rtt + // A fresh path is described by a single raw sample, which is exactly + // when the estimate deserves the least trust: a lucky outlier reported + // with no uncertainty could move a route on its own. Seed the deviation + // at half the observed value so the reported noise stays conservative + // until real samples accumulate, and keep a larger deviation carried + // over from the previous path, since the link's jitter usually outlives + // a candidate pair change. + if seed := rtt / 2; s.deviation < seed { + s.deviation = seed + } + return s.sample() + } + + // mean absolute deviation of the samples around the average, the same + // estimator TCP keeps next to its smoothed round trip time + delta := absDuration(rtt - s.smoothed) + s.deviation = time.Duration(latencyEWMAAlpha*float64(delta) + (1-latencyEWMAAlpha)*float64(s.deviation)) + s.smoothed = time.Duration(latencyEWMAAlpha*float64(rtt) + (1-latencyEWMAAlpha)*float64(s.smoothed)) + + return s.sample() +} + +func (s *latencySampler) sample() LatencySample { + return LatencySample{ + Latency: s.smoothed, + Noise: time.Duration(latencySmoothedNoiseFactor * float64(s.deviation)), + } +} + func shouldAddExtraCandidate(candidate ice.Candidate) bool { if candidate.Type() != ice.CandidateTypeServerReflexive { return false diff --git a/client/internal/peer/worker_ice_test.go b/client/internal/peer/worker_ice_test.go new file mode 100644 index 000000000..3089841c2 --- /dev/null +++ b/client/internal/peer/worker_ice_test.go @@ -0,0 +1,168 @@ +package peer + +import ( + "testing" + "time" + + "github.com/pion/ice/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pairStat(local, remote string, rtt time.Duration) ice.CandidatePairStats { + return ice.CandidatePairStats{ + LocalCandidateID: local, + RemoteCandidateID: remote, + CurrentRoundTripTime: rtt.Seconds(), + } +} + +func TestLatencySamplerObserve(t *testing.T) { + t.Run("first sample is taken as is", func(t *testing.T) { + var s latencySampler + + got := s.observe(pairStat("local", "remote", 40*time.Millisecond)) + assert.Equal(t, 40*time.Millisecond, got.Latency, "the first sample has nothing to average with") + }) + + t.Run("zero round trip time is not recorded", func(t *testing.T) { + var s latencySampler + + require.Zero(t, s.observe(pairStat("local", "remote", 0)).Latency, "a pair without an RTT sample yields no latency") + + // the first real sample still seeds the average + assert.Equal(t, 20*time.Millisecond, s.observe(pairStat("local", "remote", 20*time.Millisecond)).Latency) + }) + + t.Run("single spike is damped", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + spiked := s.observe(pairStat("local", "remote", 220*time.Millisecond)).Latency + + // a single sample only contributes its weight of the 200ms delta + assert.Equal(t, 50*time.Millisecond, spiked, "a single spike must not be reported in full") + + // and it decays again once the path is back to normal + for range 10 { + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + } + assert.Less(t, s.observe(pairStat("local", "remote", 20*time.Millisecond)).Latency, 30*time.Millisecond, + "the average should return towards the steady state") + }) + + t.Run("sustained change is followed", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + + // the route selection switch margin, see routemanager/client + const switchMargin = 20 * time.Millisecond + + // at a 4s sample interval, 3 samples are about 12 seconds + var latency time.Duration + for range 3 { + latency = s.observe(pairStat("local", "remote", 80*time.Millisecond)).Latency + } + assert.Greater(t, latency-20*time.Millisecond, switchMargin, + "a sustained 60ms degradation must clear the switch margin within seconds") + + // and 10 more samples, about 40 seconds in total, get most of the way there + for range 10 { + latency = s.observe(pairStat("local", "remote", 80*time.Millisecond)).Latency + } + assert.Greater(t, latency, 70*time.Millisecond, "a sustained degradation must be tracked within tens of seconds") + }) + + // Smoothing narrows the scatter of a jittery path but leaves a residue, and + // that residue is what route selection has to compare its margins against. + // The reported noise has to describe it, so it has to grow with the jitter + // and cover the movement of the smoothed value. + t.Run("reported noise reflects the jitter of the path", func(t *testing.T) { + // 100ms +/- 40ms, the same shape the netem jitter test uses + jittery := []int{ + 104, 138, 61, 96, 141, 72, 118, 65, 133, 88, + 59, 127, 92, 136, 68, 111, 143, 74, 99, 121, + } + steady := []int{ + 100, 101, 99, 100, 102, 99, 100, 101, 100, 99, 101, 100, 99, 100, 101, 100, 99, 101, 100, 100, + 101, 99, 100, 102, 99, 100, 101, 100, 99, 101, 100, 99, 100, 101, 100, 99, 101, 100, 100, 101, + } + + spreadAndNoise := func(raw []int) (spread, noise time.Duration) { + var s latencySampler + var minSmoothed, maxSmoothed time.Duration + + for i, ms := range raw { + got := s.observe(pairStat("local", "remote", time.Duration(ms)*time.Millisecond)) + noise = got.Noise + + // skip the warmup, the first sample is taken as is + if i < 5 { + continue + } + if minSmoothed == 0 || got.Latency < minSmoothed { + minSmoothed = got.Latency + } + if got.Latency > maxSmoothed { + maxSmoothed = got.Latency + } + } + + return maxSmoothed - minSmoothed, noise + } + + jitterySpread, jitteryNoise := spreadAndNoise(jittery) + steadySpread, steadyNoise := spreadAndNoise(steady) + + assert.Greater(t, jitteryNoise, steadyNoise, "a jittery path must report more noise than a steady one") + assert.Less(t, steadyNoise, time.Millisecond, "a steady path must report almost no noise, so the fixed margins decide") + + // the noise is a deviation, so a few of them have to cover the movement + // of the smoothed value, which is what the switch margin relies on + assert.Greater(t, 4*jitteryNoise, jitterySpread, + "reported noise %v must account for the smoothed spread %v", jitteryNoise, jitterySpread) + assert.Greater(t, 4*steadyNoise+time.Millisecond, steadySpread, + "reported noise %v must account for the smoothed spread %v", steadyNoise, steadySpread) + }) + + t.Run("new candidate pair discards the previous average", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 200*time.Millisecond)) + got := s.observe(pairStat("local2", "remote2", 20*time.Millisecond)).Latency + + assert.Equal(t, 20*time.Millisecond, got, "samples of the previous path must not be carried over") + }) + + // A single raw sample is when the estimate deserves the least trust: with + // zero reported noise, a lucky outlier could clear the switch margins and + // move a route on its own. + t.Run("a fresh path reports conservative noise", func(t *testing.T) { + var s latencySampler + + first := s.observe(pairStat("local", "remote", 100*time.Millisecond)) + assert.Greater(t, first.Noise, 10*time.Millisecond, "a single-sample estimate must not be reported as noise-free") + + // steady samples shrink the uncertainty again + var got LatencySample + for range 40 { + got = s.observe(pairStat("local", "remote", 100*time.Millisecond)) + } + assert.Less(t, got.Noise, 2*time.Millisecond, "steady samples should earn the estimate its trust back") + }) + + t.Run("a pair change keeps the larger deviation of a jittery link", func(t *testing.T) { + var s latencySampler + + // build up a deviation well above the reseed floor of the new path + for _, ms := range []int{100, 180, 60, 170, 50, 190, 70, 160} { + s.observe(pairStat("local", "remote", time.Duration(ms)*time.Millisecond)) + } + before := s.deviation + require.Greater(t, before, 25*time.Millisecond, "the jittery path should have accumulated deviation") + + s.observe(pairStat("local2", "remote2", 50*time.Millisecond)) + assert.GreaterOrEqual(t, s.deviation, before, "a pair change must not shrink the known jitter of the link") + }) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index 973cf1ab8..b882c4d4a 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,8 +3,10 @@ package client import ( "context" "fmt" + "math" "net/netip" "reflect" + "sync" "time" log "github.com/sirupsen/logrus" @@ -26,6 +28,48 @@ const ( handlerTypeStatic ) +const ( + // latencySwitchMinGain is the absolute latency improvement an alternative + // routing peer must show before an otherwise equivalent current peer is + // replaced. An absolute margin alone would be meaningless on slow paths, + // hence latencySwitchGainPercent on top. + latencySwitchMinGain = 20 * time.Millisecond + + // latencySwitchGainPercent is the relative latency improvement required in + // addition to latencySwitchMinGain. A relative margin alone would churn on + // fast paths, where a few milliseconds are a large fraction of the latency. + latencySwitchGainPercent = 20 + + // latencyNoiseSigmas is how many standard deviations of the measurement + // noise a latency gain has to exceed. Smoothing narrows the scatter of a + // jittery path but does not remove it, so on such a path two equivalent + // peers keep producing differences of tens of milliseconds. Requiring the + // gain to stand out from that scatter is what separates a peer that is + // really closer from one that only looks closer right now. + latencyNoiseSigmas = 4 + + // latencySwitchMinDwell is how long a routing peer is kept before latency + // can move the choice again. Changes in connection status, metric or relay + // state ignore it so failover is never delayed. + latencySwitchMinDwell = 30 * time.Second + + // routeReevalInterval is how often routes are re-evaluated without an + // incoming peer state change. A switch deferred by latencySwitchMinDwell, + // or a latency that drifts in steps too small to notify, would otherwise + // wait for an unrelated event to be reconsidered. + routeReevalInterval = latencySwitchMinDwell + + // unknownLatency ranks peers without a latency sample below any peer with a + // real measurement, without excluding them from selection. It doubles as the + // upper bound for samples, so a pathological value cannot dominate, and sits + // well above any usable path so that slow paths keep their order. + unknownLatency = 10 * time.Second + + // holdReportInterval is how long a routing peer held back by a switch rule + // is reported at debug level only, after being reported at info level once. + holdReportInterval = 10 * time.Minute +) + type reason int const ( @@ -36,10 +80,133 @@ const ( reasonHA ) +// switchReason describes why a routing peer replaced the current one. +type switchReason string + +const ( + switchReasonNone switchReason = "" + switchReasonUnavailable switchReason = "current routing peer unavailable" + switchReasonConnected switchReason = "connected peer preferred over idle" + switchReasonMetric switchReason = "lower route metric" + switchReasonDirect switchReason = "direct connection preferred over relayed" + switchReasonLatency switchReason = "lower latency" + switchReasonRouteChanged switchReason = "route assigned to a different routing peer" +) + +// holdKind is the rule that keeps the current routing peer over a better one. +type holdKind int + +const ( + holdLatencyMargin holdKind = iota + holdLatencyDwell + holdRelayedDelay +) + type routerPeerStatus struct { status peer.ConnStatus relayed bool latency time.Duration + // noise is the uncertainty of latency, see peer.LatencySample + noise time.Duration +} + +func (s routerPeerStatus) isDirect() bool { + return s.status == peer.StatusConnected && !s.relayed +} + +// routeCandidate is a route considered for selection, together with the +// selection criteria derived from the state of its routing peer. +type routeCandidate struct { + id route.ID + peer string + metric int + connected bool + relayed bool + latency time.Duration + noise time.Duration +} + +func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { + latency := status.latency + if latency <= 0 || latency > unknownLatency { + latency = unknownLatency + } + + return routeCandidate{ + id: r.ID, + peer: r.Peer, + metric: r.Metric, + connected: status.status == peer.StatusConnected, + relayed: status.relayed, + latency: latency, + noise: status.noise, + } +} + +// tierCompare orders c against o on the categorical criteria: connection status, +// then route metric, then direct versus relayed. It returns a positive number +// when c outranks o, a negative one when o outranks c, and zero when the two are +// equivalent. Latency is excluded on purpose: it is the tie-break applied by +// betterThan and the only criterion the switch margins in shouldSwitchRoute +// apply to. +func (c routeCandidate) tierCompare(o routeCandidate) int { + if c.connected != o.connected { + if c.connected { + return 1 + } + return -1 + } + if c.metric != o.metric { + return o.metric - c.metric + } + if c.relayed != o.relayed { + if c.relayed { + return -1 + } + return 1 + } + + return 0 +} + +func (c routeCandidate) isDirect() bool { + return c.connected && !c.relayed +} + +func (c routeCandidate) tierBetterThan(o routeCandidate) bool { + return c.tierCompare(o) > 0 +} + +// winsOnlyOnRelay reports whether c outranks o solely by being direct while o +// is relayed, with connection status and metric equal. +func (c routeCandidate) winsOnlyOnRelay(o routeCandidate) bool { + return c.connected == o.connected && c.metric == o.metric && o.relayed && !c.relayed +} + +// switchReasonOver names the criterion on which c replaces the current o. +func (c routeCandidate) switchReasonOver(o routeCandidate) switchReason { + switch { + case c.connected != o.connected: + return switchReasonConnected + case c.metric != o.metric: + return switchReasonMetric + case c.relayed != o.relayed: + return switchReasonDirect + default: + return switchReasonLatency + } +} + +func (c routeCandidate) betterThan(o routeCandidate) bool { + if tier := c.tierCompare(o); tier != 0 { + return tier > 0 + } + + return c.latency < o.latency +} + +func (c routeCandidate) String() string { + return fmt.Sprintf("connected: %t, metric: %d, relayed: %t, latency: %v +/- %v", c.connected, c.metric, c.relayed, c.latency, c.noise) } type RoutesUpdate struct { @@ -69,18 +236,72 @@ type WatcherConfig struct { // Once stopped, it cannot be reused. // The methods are not thread-safe and should be synchronized externally. type Watcher struct { - ctx context.Context - cancel context.CancelFunc - statusRecorder *peer.Status - wgInterface iface.WGIface - routes map[route.ID]*route.Route - routeUpdate chan RoutesUpdate - peerStateUpdate chan map[string]peer.RouterState + ctx context.Context + cancel context.CancelFunc + statusRecorder *peer.Status + wgInterface iface.WGIface + routes map[route.ID]*route.Route + routeUpdate chan RoutesUpdate + // peerStateUpdate holds at most one pending routing peer state notification, + // see watchPeerStatusChanges + peerStateUpdate chan struct{} routePeersNotifiers map[string]chan struct{} // map of peer key to channel for peer state changes currentChosen *route.Route currentChosenStatus *routerPeerStatus - handler RouteHandler - updateSerial uint64 + // lastSwitch is when currentChosen was last replaced by a latency-driven + // switch, used to enforce latencySwitchMinDwell. Categorical switches + // (failover, metric, relay state) leave it untouched: the dwell exists to + // stop latency ping-pong, and arming it on a failover would delay the + // return to a recovered better peer. + lastSwitch time.Time + // latencySwitch marks the evaluation's switch decision as latency-driven; + // set by shouldSwitchRoute, consumed when the switch is applied + latencySwitch bool + // switchReason describes why the evaluation replaced the current routing + // peer; set by getBestRouteFromStatuses, consumed when the switch is applied + switchReason switchReason + // relayedSwitch is how long a current routing peer that dropped from direct + // to relayed is kept over an otherwise equivalent direct one. The zero value + // switches at once. + relayedSwitch relayedSwitchPolicy + // trackedCurrent, trackedPeer and trackedDirect are the current route, its + // routing peer and whether that peer was connected directly as of the + // previous evaluation, used to tell a peer that dropped to relay while + // carrying the route from one that was never direct + trackedCurrent route.ID + trackedPeer string + trackedDirect bool + // degradedSince is when the current routing peer dropped from direct to + // relayed, zero if it is direct or was already relayed when chosen + degradedSince time.Time + // relayedHold fires when a relayed switch being held back becomes due, so it + // is applied on time rather than on the next periodic re-evaluation + relayedHold *time.Timer + // heldCandidate and heldKind identify the alternative last held back by the + // switch rules and the rule holding it, and heldReported is when that was + // reported at info level. Routes are re-evaluated on every latency change, + // so a hold is reported once per episode instead of on every evaluation. + heldCandidate route.ID + heldKind holdKind + heldReported time.Time + // reportedUnassigned tracks that the current no-routing-peer episode has + // been reported, so the periodic re-evaluation does not repeat it + reportedUnassigned bool + // done is closed when the Start loop exits, so Stop can wait for it before + // removing the routes the loop might still be updating + done chan struct{} + // lifecycleMu orders Start against Stop: Start runs in its own goroutine + // and may begin after Stop, or never, so Stop only waits for done when the + // loop has actually started + lifecycleMu sync.Mutex + started bool + stopped bool + handler RouteHandler + updateSerial uint64 +} + +func newPeerStateUpdate() chan struct{} { + return make(chan struct{}, 1) } func NewWatcher(config WatcherConfig) *Watcher { @@ -94,9 +315,11 @@ func NewWatcher(config WatcherConfig) *Watcher { routes: make(map[route.ID]*route.Route), routePeersNotifiers: make(map[string]chan struct{}), routeUpdate: make(chan RoutesUpdate), - peerStateUpdate: make(chan map[string]peer.RouterState), + peerStateUpdate: newPeerStateUpdate(), + done: make(chan struct{}), handler: config.Handler, currentChosenStatus: nil, + relayedSwitch: relayedSwitch(), } return client } @@ -113,23 +336,7 @@ func (w *Watcher) getRouterPeerStatuses() map[route.ID]routerPeerStatus { status: peerStatus.ConnStatus, relayed: peerStatus.Relayed, latency: peerStatus.Latency, - } - } - return routePeerStatuses -} - -func (w *Watcher) convertRouterPeerStatuses(states map[string]peer.RouterState) map[route.ID]routerPeerStatus { - routePeerStatuses := make(map[route.ID]routerPeerStatus) - for _, r := range w.routes { - peerStatus, ok := states[r.Peer] - if !ok { - log.Warnf("couldn't fetch peer state: %v", r.Peer) - continue - } - routePeerStatuses[r.ID] = routerPeerStatus{ - status: peerStatus.Status, - relayed: peerStatus.Relayed, - latency: peerStatus.Latency, + noise: peerStatus.LatencyNoise, } } return routePeerStatuses @@ -139,122 +346,277 @@ func (w *Watcher) convertRouterPeerStatuses(states map[string]peer.RouterState) // within a Watcher, taking into account peer connection status, route metrics, and // preference for non-relayed and direct connections. // -// It follows these prioritization rules: +// Candidates are ranked by the categorical criteria first, in this order: // * Connection status: Both connected and idle peers are considered, but connected peers always take precedence. -// * Idle peer penalty: Idle peers receive a significant score penalty to ensure any connected peer is preferred. // * Metric: Routes with lower metrics (better) are prioritized. // * Non-relayed: Routes without relays are preferred. -// * Latency: Routes with lower latency are prioritized. -// * Allowed IPs: Idle peers can still receive allowed IPs to enable lazy connection triggering. -// * we compare the current score + 10ms to the chosen score to avoid flapping between routes -// * Stability: In case of equal scores, the currently active route (if any) is maintained. +// Peers that are equal on all of them are then ranked by latency, lowest first. +// +// Idle peers can still receive allowed IPs to enable lazy connection triggering. +// Connecting peers are skipped: they have no endpoint to assign allowed IPs to. +// +// Replacing the current route is subject to shouldSwitchRoute, so latency alone +// only moves the route when the gain is worth the interruption. In case of a +// tie, the currently active route (if any) is maintained. // // It returns the ID of the selected optimal route. func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]routerPeerStatus) (route.ID, routerPeerStatus) { - var chosen route.ID - chosenScore := float64(0) - currScore := float64(0) - var currID route.ID if w.currentChosen != nil { currID = w.currentChosen.ID } - var chosenStatus routerPeerStatus + var best, current routeCandidate + var haveBest, haveCurrent bool for _, r := range w.routes { - tempScore := float64(0) peerStatus, found := routePeerStatuses[r.ID] - // connecting status equals disconnected: no wireguard endpoint to assign allowed IPs to + // connecting status equals disconnected: no endpoint to assign allowed IPs to if !found || peerStatus.status == peer.StatusConnecting { continue } - if r.Metric < route.MaxMetric { - metricDiff := route.MaxMetric - r.Metric - tempScore = float64(metricDiff) * 10 - } - - // in some temporal cases, latency can be 0, so we set it to 999ms to not block but try to avoid this route - latency := 999 * time.Millisecond - if peerStatus.latency != 0 { - latency = peerStatus.latency - } else if !peerStatus.relayed && peerStatus.status != peer.StatusIdle { - log.Tracef("peer %s has 0 latency: [%v]", r.Peer, w.handler) - } - - // avoid negative tempScore on the higher latency calculation - if latency > 1*time.Second { - latency = 999 * time.Millisecond - } - - // higher latency is worse score - tempScore += 1 - latency.Seconds() - - // apply significant penalty for idle peers to ensure connected peers always take precedence - if peerStatus.status == peer.StatusConnected { - tempScore += 100_000 - } - - if !peerStatus.relayed { - tempScore++ - } - - if tempScore > chosenScore || (tempScore == chosenScore && chosen == "") { - chosen = r.ID - chosenStatus = peerStatus - chosenScore = tempScore - } - - if chosen == "" && currID == "" { - chosen = r.ID - chosenStatus = peerStatus - chosenScore = tempScore - } - + candidate := newRouteCandidate(r, peerStatus) if r.ID == currID { - currScore = tempScore + current, haveCurrent = candidate, true + } + if !haveBest || candidate.betterThan(best) { + best, haveBest = candidate, true } } - chosenID := chosen - if chosen == "" { - chosenID = "" - } - currentID := currID - if currID == "" { - currentID = "" + // Candidates that tie must not displace the current peer: the routes are + // held in a map, so the winner of a tie would otherwise depend on iteration + // order and change from one evaluation to the next. + if haveCurrent && !best.betterThan(current) { + best = current } - log.Debugf("chosen route: %s, chosen score: %f, current route: %s, current score: %f", chosenID, chosenScore, currentID, currScore) + w.trackRelayed(current, haveCurrent) + + if !haveBest { + w.reportUnassigned() + w.heldCandidate = "" + + return "", routerPeerStatus{} + } + w.reportedUnassigned = false + + if haveCurrent && current.id != best.id { + if log.IsLevelEnabled(log.DebugLevel) { + log.Debugf("best routing peer for [%v] is %s (%s), current is %s (%s)", + w.handler, best.peer, best, current.peer, current) + } + + if !w.shouldSwitchRoute(current, best) { + return current.id, routePeerStatuses[current.id] + } + } switch { - case chosen == "": - var peers []string - for _, r := range w.routes { - peers = append(peers, r.Peer) - } - - log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers) - case chosen != currID: - // we compare the current score + 10ms to the chosen score to avoid flapping between routes - if currScore != 0 && currScore+0.01 > chosenScore { - log.Debugf("keeping current routing peer %s for [%v]: the score difference with latency is less than 0.01(10ms): current: %f, new: %f", - w.currentChosen.Peer, w.handler, currScore, chosenScore) - return currID, chosenStatus - } - var p string - if rt := w.routes[chosen]; rt != nil { - p = rt.Peer - } - log.Infof("New chosen route is %s with peer %s with score %f for network [%v]", chosen, p, chosenScore, w.handler) + case haveCurrent && current.id == best.id: + case haveCurrent: + w.switchReason = best.switchReasonOver(current) + log.Warnf("switching routing peer for network [%v] from %s to %s: %s (%s)", + w.handler, current.peer, best.peer, w.switchReason, best) + case w.currentChosen != nil: + w.switchReason = switchReasonUnavailable + log.Warnf("switching routing peer for network [%v] from %s to %s: %s (%s)", + w.handler, w.currentChosen.Peer, best.peer, w.switchReason, best) + default: + log.Infof("new chosen route is %s with peer %s for network [%v]: %s", + best.id, best.peer, w.handler, best) } - return chosen, chosenStatus + return best.id, routePeerStatuses[best.id] } -func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, peerStateUpdate chan map[string]peer.RouterState, closer chan struct{}) { - subscription := w.statusRecorder.SubscribeToPeerStateChanges(ctx, peerKey) +// reportUnassigned reports that no routing peer is available for the network, +// once per episode: the periodic re-evaluation would otherwise repeat it for as +// long as the routing peers stay unavailable. +func (w *Watcher) reportUnassigned() { + if w.reportedUnassigned { + return + } + w.reportedUnassigned = true + + var peers []string + for _, r := range w.routes { + peers = append(peers, r.Peer) + } + log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers) +} + +// trackRelayed records when the current routing peer dropped from a direct to a +// relayed connection while carrying the route, which starts the relayedSwitch +// delay. A peer that was never direct, because it was relayed when chosen or +// came up over relay from idle, is not degraded: holding on to it would only +// keep the route on the slower path. +func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { + if !haveCurrent { + w.trackedCurrent, w.trackedPeer, w.trackedDirect, w.degradedSince = "", "", false, time.Time{} + return + } + + switch { + case current.id != w.trackedCurrent, current.peer != w.trackedPeer, !current.relayed: + w.degradedSince = time.Time{} + case w.trackedDirect: + w.degradedSince = time.Now() + // a new degradation, reported as such even if an earlier one held + // back the same candidate + w.heldCandidate = "" + } + w.trackedCurrent, w.trackedPeer, w.trackedDirect = current.id, current.peer, current.isDirect() +} + +// shouldSwitchRoute reports whether the current routing peer should be replaced +// by the candidate, which outranks it. +// +// A candidate that wins on a categorical criterion (it is connected while the +// current peer is not, has a better metric, or is direct while the current peer +// is relayed) takes over immediately. The exception is a current peer that only +// just dropped from direct to relayed, see relayedSwitchDue. +// +// A candidate that only has lower latency has to beat the current peer by the +// switch margin, and the current peer has to have been in place for +// latencySwitchMinDwell. Every switch interrupts the traffic using the route, so +// a gain that is marginal, or that the measurement noise of the two paths can +// account for on its own, is not worth acting on. +// +// An approved latency-driven switch sets w.latencySwitch, which arms the dwell +// time once the switch is applied. +func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { + if candidate.winsOnlyOnRelay(current) { + return w.relayedSwitchDue(current, candidate) + } + if candidate.tierBetterThan(current) { + return true + } + + gain := current.latency - candidate.latency + if margin := switchMargin(current, candidate); gain < margin { + if level := w.holdLevel(candidate, holdLatencyMargin); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: latency gain %v on %v is below the %v switch margin (%s)", + current.peer, w.handler, candidate.peer, gain, current.latency, margin, candidate) + } + return false + } + + if dwelled := time.Since(w.lastSwitch); dwelled < latencySwitchMinDwell { + if level := w.holdLevel(candidate, holdLatencyDwell); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: chosen %v ago, below the %v minimum dwell time", + current.peer, w.handler, candidate.peer, dwelled.Round(time.Second), latencySwitchMinDwell) + } + return false + } + + w.latencySwitch = true + + return true +} + +// relayedSwitchDue reports whether a relayed current routing peer should give +// way to a candidate that is better only by being direct. +// +// A peer that dropped from direct to relayed while carrying the route is kept +// for the relayedSwitch delay: the relay still carries the traffic, staying +// keeps every flow through this routing peer alive, and a direct connection that +// dropped often recovers within seconds. A peer that was already relayed when +// chosen gives way at once, so the route is not stuck on the slower path when a +// direct one becomes available. A switch that is not due yet arms relayedHold so +// it is applied as soon as it becomes due. +func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { + if w.degradedSince.IsZero() { + return true + } + + policy := w.relayedSwitch + if !policy.never { + remaining := policy.delay - time.Since(w.degradedSince) + if remaining <= 0 { + return true + } + w.armRelayedHold(remaining) + } + + if level := w.holdLevel(candidate, holdRelayedDelay); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping relayed routing peer %s for [%v] over direct %s: dropped to relay %v ago, switch delay %s", + current.peer, w.handler, candidate.peer, time.Since(w.degradedSince).Round(time.Second), policy) + } + + return false +} + +func (w *Watcher) armRelayedHold(d time.Duration) { + if w.relayedHold == nil { + w.relayedHold = time.NewTimer(d) + return + } + w.relayedHold.Reset(d) +} + +// relayedHoldC is the channel of relayedHold, nil while it was never armed so +// that selecting on it blocks. +func (w *Watcher) relayedHoldC() <-chan time.Time { + if w.relayedHold == nil { + return nil + } + return w.relayedHold.C +} + +// holdLevel is the level to report a decision to keep the current routing peer +// at. The first evaluation that holds a given candidate back under a given rule +// is reported at info level, because that is what someone wondering why a route +// did not move needs to see. The repeats stay at debug level for +// holdReportInterval, including when two near-equal peers keep trading places. +func (w *Watcher) holdLevel(candidate routeCandidate, kind holdKind) log.Level { + if w.heldCandidate == candidate.id && w.heldKind == kind && time.Since(w.heldReported) < holdReportInterval { + return log.DebugLevel + } + + w.heldCandidate, w.heldKind, w.heldReported = candidate.id, kind, time.Now() + + return log.InfoLevel +} + +// switchMargin is how much lower the candidate's latency has to be before the +// route moves: at least latencySwitchMinGain, at least latencySwitchGainPercent +// of the current latency so the margin scales with slow paths, and more than the +// combined measurement noise of the two paths. The noise term is what keeps a +// jittery path from moving the route on its own; it vanishes on a stable path, +// where the fixed margins decide. +func switchMargin(current, candidate routeCandidate) time.Duration { + margin := latencySwitchMinGain + + if relative := current.latency * latencySwitchGainPercent / 100; relative > margin { + margin = relative + } + + // the two paths vary independently, so their noise adds in quadrature + noise := time.Duration(latencyNoiseSigmas * math.Hypot(float64(current.noise), float64(candidate.noise))) + if noise > margin { + margin = noise + } + + return margin +} + +// watchPeerStatusChanges forwards the state changes of a routing peer to the +// watcher as a notification to re-evaluate. The watcher reads the current peer +// states when it gets to it, so a notification still pending covers any number +// of later changes, and the forwarder never waits for a busy watcher: that +// would stop it draining the subscription and in turn block the peer's updates. +func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, peerStateUpdate chan<- struct{}, closer chan struct{}) { + // the subscription gets its own context so a delivery blocked on it is + // released as soon as this forwarder exits, not only when the watcher stops + subCtx, cancel := context.WithCancel(ctx) + defer cancel() + + subscription := w.statusRecorder.SubscribeToPeerStateChanges(subCtx, peerKey) defer w.statusRecorder.UnsubscribePeerStateChanges(subscription) for { @@ -263,14 +625,11 @@ func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, pe return case <-closer: return - case routerStates := <-subscription.Events(): + case <-subscription.Events(): select { - case peerStateUpdate <- routerStates: + case peerStateUpdate <- struct{}{}: log.Debugf("triggered route state update for Peer: %s", peerKey) - case <-ctx.Done(): - return - case <-closer: - return + default: } } } @@ -337,6 +696,8 @@ func (w *Watcher) shouldSkipRecalculation(newChosenID route.ID, newStatus router } func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID]routerPeerStatus) error { + w.latencySwitch = false + w.switchReason = switchReasonNone newChosenID, newStatus := w.getBestRouteFromStatuses(routerPeerStatuses) // If no route is chosen, remove the route from the peer @@ -348,6 +709,9 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] if err := w.removeAllowedIPs(w.currentChosen, rsn); err != nil { return fmt.Errorf("remove obsolete: %w", err) } + if rsn == reasonPeerUpdate { + w.routingPeerLostEvent(w.currentChosen) + } w.currentChosen = nil w.currentChosenStatus = nil @@ -360,6 +724,17 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] return nil } + previous := w.currentChosen + newChosenRoute := w.routes[newChosenID] + // a route update can hand the same route to a different routing peer + peerReplaced := previous != nil && previous.ID == newChosenID && previous.Peer != newChosenRoute.Peer + switched := previous == nil || previous.ID != newChosenID || peerReplaced + if peerReplaced { + w.switchReason = switchReasonRouteChanged + log.Warnf("switching routing peer for network [%v] from %s to %s: %s", + w.handler, previous.Peer, newChosenRoute.Peer, w.switchReason) + } + // If the chosen route was assigned to a different peer, remove the allowed IPs first if isNew := w.currentChosen == nil; !isNew { if err := w.removeAllowedIPs(w.currentChosen, reasonHA); err != nil { @@ -367,7 +742,6 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] } } - newChosenRoute := w.routes[newChosenID] if err := w.addAllowedIPs(newChosenRoute); err != nil { return fmt.Errorf("add new: %w", err) } @@ -377,20 +751,86 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] w.currentChosen = newChosenRoute w.currentChosenStatus = &newStatus + if !switched { + return nil + } + + // start tracking the new routing peer from the state it was chosen in, so a + // drop to relay seen by the next evaluation counts as a degradation + w.trackedCurrent, w.trackedPeer = newChosenID, newChosenRoute.Peer + w.trackedDirect, w.degradedSince = newStatus.isDirect(), time.Time{} + if w.latencySwitch { + w.lastSwitch = time.Now() + } + if previous != nil { + w.routingPeerSwitchEvent(previous, newChosenRoute) + } return nil } -func (w *Watcher) connectEvent(route *route.Route) { - var defaultRoute bool - for _, r := range w.routes { - if r.Network.Bits() == 0 { - defaultRoute = true - break - } +// routingPeerSwitchEvent publishes a replaced routing peer as a system event, +// so it shows in the status output and debug bundles whatever the log level. +// Moving a masqueraded route resets the connections that used it. For default +// routes this adds the reason to the events of disconnectEvent and +// connectEvent, and carries no user message so it is not shown as a +// notification a second time. +func (w *Watcher) routingPeerSwitchEvent(previous, next *route.Route) { + if w.statusRecorder == nil { + return } - if !defaultRoute { + reason := w.switchReason + if reason == switchReasonNone { + reason = switchReasonUnavailable + } + + w.statusRecorder.PublishEvent( + proto.SystemEvent_WARNING, + proto.SystemEvent_NETWORK, + "Routing peer changed", + "", + map[string]string{ + "network": w.handler.String(), + "id": string(next.NetID), + "previous_peer": previous.Peer, + "peer": next.Peer, + "reason": string(reason), + }, + ) +} + +// routingPeerLostEvent publishes the loss of the last available routing peer of +// a network as a system event. Default routes are left to disconnectEvent. +func (w *Watcher) routingPeerLostEvent(previous *route.Route) { + if w.statusRecorder == nil || w.hasDefaultRoute() { + return + } + + w.statusRecorder.PublishEvent( + proto.SystemEvent_WARNING, + proto.SystemEvent_NETWORK, + "No routing peer available", + "", + map[string]string{ + "network": w.handler.String(), + "id": string(previous.NetID), + "previous_peer": previous.Peer, + }, + ) +} + +func (w *Watcher) hasDefaultRoute() bool { + for _, r := range w.routes { + if r.Network.Bits() == 0 { + return true + } + } + return false +} + +func (w *Watcher) connectEvent(route *route.Route) { + if !w.hasDefaultRoute() { return } @@ -411,15 +851,7 @@ func (w *Watcher) connectEvent(route *route.Route) { } func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) { - var defaultRoute bool - for _, r := range w.routes { - if r.Network.Bits() == 0 { - defaultRoute = true - break - } - } - - if !defaultRoute { + if !w.hasDefaultRoute() { return } @@ -505,13 +937,41 @@ func (w *Watcher) classifyUpdate(update RoutesUpdate) bool { // Start is the main point of reacting on client network routing events. // All the processing related to the client network should be done here. Thread-safe. func (w *Watcher) Start() { + w.lifecycleMu.Lock() + if w.stopped || w.started { + w.lifecycleMu.Unlock() + return + } + w.started = true + w.lifecycleMu.Unlock() + + defer close(w.done) + + reeval := time.NewTicker(routeReevalInterval) + defer reeval.Stop() + defer func() { + if w.relayedHold != nil { + w.relayedHold.Stop() + } + }() + for { select { case <-w.ctx.Done(): return - case routersStates := <-w.peerStateUpdate: - routerPeerStatuses := w.convertRouterPeerStatuses(routersStates) - if err := w.recalculateRoutes(reasonPeerUpdate, routerPeerStatuses); err != nil { + case <-reeval.C: + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { + log.Errorf("Failed to re-evaluate routes for network [%v]: %v", w.handler, err) + } + case <-w.relayedHoldC(): + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { + log.Errorf("Failed to re-evaluate routes for network [%v]: %v", w.handler, err) + } + case <-w.peerStateUpdate: + // The notification only says that a routing peer changed. The states it + // carries can be older than ones already applied, since every routing + // peer has its own subscription, so the current states are read instead. + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { log.Errorf("Failed to recalculate routes for network [%v]: %v", w.handler, err) } case update := <-w.routeUpdate: @@ -550,7 +1010,22 @@ func (w *Watcher) handleRouteUpdate(update RoutesUpdate) { func (w *Watcher) Stop() { log.Debugf("Stopping watcher for network [%v]", w.handler) + w.lifecycleMu.Lock() + if w.stopped { + w.lifecycleMu.Unlock() + return + } + w.stopped = true + started := w.started + w.lifecycleMu.Unlock() + w.cancel() + // wait for the Start loop to exit: it may be inside a recalculation, and + // cleaning up concurrently would race on currentChosen and could leave the + // allowed IPs of a route chosen after this point behind + if started { + <-w.done + } if w.currentChosen == nil { return diff --git a/client/internal/routemanager/client/client_bench_test.go b/client/internal/routemanager/client/client_bench_test.go index 668aec427..b788015db 100644 --- a/client/internal/routemanager/client/client_bench_test.go +++ b/client/internal/routemanager/client/client_bench_test.go @@ -23,6 +23,12 @@ var benchmarkTiers = []benchmarkTier{ {"Small", 100, 50, 4}, {"Medium", 1000, 200, 16}, {"Large", 5000, 500, 32}, + // the tiers below are deliberately past anything realistic: a watcher holds + // the routes of a single network, while these give one watcher every route + // in the account at once + {"ManyPeers", 20000, 500, 32}, + {"ManyRoutes", 1000, 5000, 16}, + {"ManyBoth", 20000, 5000, 32}, } type mockRouteHandler struct { @@ -136,7 +142,7 @@ func BenchmarkRecalculateRoutes(b *testing.B) { routes: routes, routePeersNotifiers: make(map[string]chan struct{}), routeUpdate: make(chan RoutesUpdate), - peerStateUpdate: make(chan map[string]peer.RouterState), + peerStateUpdate: newPeerStateUpdate(), handler: &mockRouteHandler{network: "benchmark"}, currentChosenStatus: nil, } diff --git a/client/internal/routemanager/client/client_test.go b/client/internal/routemanager/client/client_test.go index 850f6691f..764a2f74d 100644 --- a/client/internal/routemanager/client/client_test.go +++ b/client/internal/routemanager/client/client_test.go @@ -1,11 +1,17 @@ package client import ( + "context" "fmt" "net/netip" + "sync" "testing" "time" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/routemanager/common" "github.com/netbirdio/netbird/client/internal/routemanager/static" @@ -316,6 +322,145 @@ func TestGetBestrouteFromStatuses(t *testing.T) { currentRoute: "route1", expectedRouteID: "route2", }, + { + // 40ms gain clears latencySwitchMinGain but not the 20% of 300ms + name: "latency gain below the relative margin should not change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 260 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route1", + }, + { + name: "latency gain above both margins should change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 200 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, + { + // 7ms gain is 58% of the current latency but below latencySwitchMinGain + name: "latency gain below the absolute margin should not change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 12 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 5 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route1", + }, + { + name: "current relayed peer should be replaced by a direct peer with worse latency", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + relayed: true, + latency: 10 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + relayed: false, + latency: 200 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, + { + name: "better metric should replace the current peer with better latency", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 10 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: 5000, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, { name: "current chosen route doesn't exist anymore", statuses: map[route.ID]routerPeerStatus{ @@ -828,3 +973,510 @@ func TestGetBestrouteFromStatuses(t *testing.T) { }) } } + +// newTestWatcher builds a watcher over two routes of equal metric, with route1 +// as the currently chosen one. +func newTestWatcher(t *testing.T, lastSwitch time.Time) *Watcher { + t.Helper() + + routes := map[route.ID]*route.Route{ + "route1": {ID: "route1", Metric: route.MaxMetric, Peer: "peer1"}, + "route2": {ID: "route2", Metric: route.MaxMetric, Peer: "peer2"}, + } + + params := common.HandlerParams{ + Route: &route.Route{Network: netip.MustParsePrefix("192.168.0.0/24")}, + } + + return &Watcher{ + handler: static.NewRoute(params), + routes: routes, + currentChosen: routes["route1"], + lastSwitch: lastSwitch, + } +} + +func TestLatencySwitchDwellTime(t *testing.T) { + // a gain large enough to clear both switch margins + statuses := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + } + + t.Run("latency switch is held during the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now()) + + chosen, _ := w.getBestRouteFromStatuses(statuses) + assert.Equal(t, route.ID("route1"), chosen, "route should be kept while the dwell time has not elapsed") + }) + + t.Run("latency switch is allowed after the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now().Add(-latencySwitchMinDwell-time.Second)) + + chosen, _ := w.getBestRouteFromStatuses(statuses) + assert.Equal(t, route.ID("route2"), chosen, "route should switch once the dwell time has elapsed") + }) + + t.Run("categorical improvement ignores the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now()) + + // route1 is no longer connected, so the dwell time must not delay failover + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle, latency: 10 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "failover must not be delayed by the dwell time") + }) +} + +// TestLatencySwitchNoiseMargin covers the noise aware margin: on a jittery path +// the smoothed latencies of two equivalent peers keep drifting apart by tens of +// milliseconds, and that difference must not move the route even though it +// clears the fixed margins. +// Measurements on slow paths must keep their order: a peer measured at 1.2s is +// better than one at 2.5s, and a peer without a sample still ranks below both. +func TestSlowPathLatenciesKeepTheirOrder(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 2500 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 1200 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a 1.3s gain on a 2.5s path must move the route") + + unsampled := newRouteCandidate(w.routes["route1"], routerPeerStatus{status: peer.StatusConnected}) + slow := newRouteCandidate(w.routes["route2"], routerPeerStatus{status: peer.StatusConnected, latency: 5 * time.Second}) + assert.True(t, slow.betterThan(unsampled), "a measured peer must rank above one without a sample") +} + +func TestLatencySwitchNoiseMargin(t *testing.T) { + t.Run("a gain within the measurement noise does not switch", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route1"), chosen, "a 40ms gain between two paths jittering by 12ms is not a real difference") + }) + + t.Run("a gain beyond the measurement noise switches", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a 220ms gain stands out from 12ms of jitter") + }) + + // the held candidate is tracked so the decision is reported once per episode + // rather than on every re-evaluation + t.Run("holding a candidate back is tracked as one episode", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + within := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + } + + w.getBestRouteFromStatuses(within) + assert.Equal(t, route.ID("route2"), w.heldCandidate, "the held candidate should be recorded on the first hold") + + w.getBestRouteFromStatuses(within) + assert.Equal(t, route.ID("route2"), w.heldCandidate, "a repeated hold of the same candidate stays the same episode") + + // the smoothed latencies of two near-equal peers keep crossing: route2 + // falling behind for a moment must not start a new episode + w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + held := routeCandidate{id: "route2"} + assert.Equal(t, log.DebugLevel, w.holdLevel(held, holdLatencyMargin), "holding route2 back again after a crossing is a repeat") + + // a hold reported long ago is worth reporting again + w.heldReported = w.heldReported.Add(-holdReportInterval - time.Second) + assert.Equal(t, log.InfoLevel, w.holdLevel(held, holdLatencyMargin), "a hold is reported again once the report interval passed") + }) + + t.Run("a stable path is decided by the fixed margins", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + // the same 40ms gain as the first case, on paths that barely vary + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 300 * time.Microsecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 300 * time.Microsecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "without jitter a 40ms gain on a 120ms path is a real improvement") + }) +} + +// recordingHandler records the allowed IPs calls a watcher makes. +type recordingHandler struct { + added []string + removed int +} + +func (h *recordingHandler) String() string { return "recording" } +func (h *recordingHandler) AddRoute(context.Context) error { return nil } +func (h *recordingHandler) RemoveRoute() error { return nil } +func (h *recordingHandler) AddAllowedIPs(peerKey string) error { + h.added = append(h.added, peerKey) + return nil +} + +func (h *recordingHandler) RemoveAllowedIPs() error { + h.removed++ + return nil +} + +func newRecalcWatcher(t *testing.T) (*Watcher, *recordingHandler) { + t.Helper() + + handler := &recordingHandler{} + routes := map[route.ID]*route.Route{ + "route1": {ID: "route1", Metric: route.MaxMetric, Peer: "peer1"}, + "route2": {ID: "route2", Metric: route.MaxMetric, Peer: "peer2"}, + } + + return &Watcher{ + statusRecorder: peer.NewRecorder("https://mgm"), + routes: routes, + handler: handler, + }, handler +} + +// TestRecalculateRoutesAssignsIdlePeer covers the lazy connection path: an idle +// routing peer has no established connection yet, but must still receive the +// allowed IPs so that traffic for the route can trigger the connection. +func TestRecalculateRoutesAssignsIdlePeer(t *testing.T) { + w, handler := newRecalcWatcher(t) + + err := w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"peer1"}, handler.added, "an idle routing peer should still get the allowed IPs") + require.NotNil(t, w.currentChosen, "an idle peer should be chosen when it is the only candidate") + assert.Equal(t, route.ID("route1"), w.currentChosen.ID) +} + +func TestRecalculateRoutesDwellTime(t *testing.T) { + w, handler := newRecalcWatcher(t) + + // route2 has no status yet, so route1 is the only candidate + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + require.True(t, w.lastSwitch.IsZero(), "the initial assignment is not latency-driven and must not arm the dwell time") + + betterRoute2 := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute2)) + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "a latency switch with no recent switch should be allowed") + assert.Equal(t, 1, handler.removed) + require.False(t, w.lastSwitch.IsZero(), "a latency-driven switch must arm the dwell time") + + // the peers trade places: switching back is again latency-only, but now + // inside the dwell window + betterRoute1 := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute1)) + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "the route should be held during the dwell time") + + w.lastSwitch = w.lastSwitch.Add(-latencySwitchMinDwell - time.Second) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute1)) + assert.Equal(t, []string{"peer1", "peer2", "peer1"}, handler.added, "the route should switch once the dwell time elapsed") + assert.Equal(t, 2, handler.removed) + + // the switch re-armed the dwell time, which must not delay failover + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, latency: 100 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + })) + assert.Equal(t, []string{"peer1", "peer2", "peer1", "peer2"}, handler.added, "losing the routing peer must fail over regardless of the dwell time") + assert.Equal(t, 3, handler.removed) +} + +// TestFailoverDoesNotArmDwellTime covers the recovery path: a failover to a +// worse peer must not start the dwell time, or traffic would be stuck on the +// worse path for the full dwell after the better peer comes back. +func TestFailoverDoesNotArmDwellTime(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + // peer1 blips into connecting: unguarded failover to the worse peer2 + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1", "peer2"}, handler.added, "failover to the remaining peer should happen") + require.True(t, w.lastSwitch.IsZero(), "a failover must not arm the dwell time") + + // peer1 recovers seconds later: the return is latency-only but must not be + // held back by a dwell the failover would have armed + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + assert.Equal(t, []string{"peer1", "peer2", "peer1"}, handler.added, "the recovered better peer should take the route back immediately") +} + +// blockingHandler records the allowed IPs calls in order and blocks the first +// AddAllowedIPs until released, to hold a recalculation in flight. +type blockingHandler struct { + mu sync.Mutex + calls []string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (h *blockingHandler) String() string { return "blocking" } +func (h *blockingHandler) AddRoute(context.Context) error { return nil } +func (h *blockingHandler) RemoveRoute() error { return nil } + +func (h *blockingHandler) AddAllowedIPs(peerKey string) error { + h.once.Do(func() { + close(h.entered) + <-h.release + }) + h.record("add " + peerKey) + return nil +} + +func (h *blockingHandler) RemoveAllowedIPs() error { + h.record("remove") + return nil +} + +func (h *blockingHandler) record(call string) { + h.mu.Lock() + defer h.mu.Unlock() + h.calls = append(h.calls, call) +} + +// TestStopWaitsForStart covers the shutdown ordering: Stop must not clean up +// while the Start loop is still inside a recalculation, or it would remove the +// allowed IPs before the recalculation adds them and leave them behind. +func TestStopWaitsForStart(t *testing.T) { + w, _ := newRecalcWatcher(t) + handler := &blockingHandler{entered: make(chan struct{}), release: make(chan struct{})} + w.handler = handler + + require.NoError(t, w.statusRecorder.AddPeer("peer1", "peer1.netbird.cloud", "100.64.0.1", "")) + require.NoError(t, w.statusRecorder.UpdatePeerState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + w.peerStateUpdate = make(chan struct{}) + go w.Start() + + // the unbuffered send returns once the loop took the notification, so the + // loop is running and about to recalculate + w.peerStateUpdate <- struct{}{} + select { + case <-handler.entered: + case <-time.After(2 * time.Second): + t.Fatal("the notification should start a recalculation") + } + + finished := make(chan struct{}) + go func() { + w.Stop() + close(finished) + }() + + select { + case <-finished: + t.Fatal("Stop must wait for the recalculation in flight") + case <-time.After(100 * time.Millisecond): + } + + close(handler.release) + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("Stop should return once the Start loop has exited") + } + + handler.mu.Lock() + defer handler.mu.Unlock() + assert.Equal(t, []string{"add peer1", "remove"}, handler.calls, "Stop must clean up after the recalculation, not before it") +} + +// A notification carries no peer states: every routing peer has its own +// subscription and forwarder feeding the same channel, so a snapshot could +// arrive after a newer one. The watcher acts on the states in the recorder as +// of when it handles the notification. +func TestPeerStateNotificationUsesRecordedStates(t *testing.T) { + w, handler := newRecalcWatcher(t) + + recorder := w.statusRecorder + for _, key := range []string{"peer1", "peer2"} { + require.NoError(t, recorder.AddPeer(key, key+".netbird.cloud", "100.64.0.1", "")) + } + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnecting})) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses())) + require.Equal(t, []string{"peer1"}, handler.added) + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + w.peerStateUpdate = make(chan struct{}) + go w.Start() + + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnecting})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnected})) + w.peerStateUpdate <- struct{}{} + w.Stop() + + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "the notification must move the route according to the recorded states") +} + +// While the watcher is busy, for instance inside a recalculation, the peer state +// forwarder must keep draining its subscription. Otherwise the subscription +// buffer fills up and the peer's next state update blocks its caller, the +// peer's connection handling, until the watcher is free again. +func TestPeerStateForwarderDoesNotBlockWhileWatcherIsBusy(t *testing.T) { + w, _ := newRecalcWatcher(t) + w.peerStateUpdate = newPeerStateUpdate() + + recorder := w.statusRecorder + require.NoError(t, recorder.AddPeer("peer1", "peer1.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + closer := make(chan struct{}) + go w.watchPeerStatusChanges(ctx, "peer1", w.peerStateUpdate, closer) + + // wait for the forwarder's subscription to exist, so the updates below reach it + require.Eventually(t, func() bool { + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnecting})) + select { + case <-w.peerStateUpdate: + return true + case <-time.After(50 * time.Millisecond): + return false + } + }, 2*time.Second, 10*time.Millisecond, "the forwarder must pass on a notification") + + // nobody reads peerStateUpdate: the watcher is busy + updated := make(chan struct{}) + go func() { + defer close(updated) + for i := 0; i < 50; i++ { + status := peer.StatusConnected + if i%2 == 1 { + status = peer.StatusConnecting + } + if err := recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: status}); err != nil { + t.Errorf("update peer state: %v", err) + return + } + } + }() + + select { + case <-updated: + case <-time.After(2 * time.Second): + t.Fatal("peer state updates must not block while the watcher is busy") + } +} + +// TestStopWithoutStart covers a watcher whose Start goroutine never ran, or ran +// only after Stop: Stop must return instead of waiting for a loop that does not +// exist, and a late Start must not begin working on a stopped watcher. +func TestStopWithoutStart(t *testing.T) { + w := NewWatcher(WatcherConfig{ + Context: context.Background(), + StatusRecorder: peer.NewRecorder("https://mgm"), + Handler: &recordingHandler{}, + }) + + finished := make(chan struct{}) + go func() { + w.Stop() + close(finished) + }() + + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("Stop must return when Start was never called") + } + + started := make(chan struct{}) + go func() { + w.Start() + close(started) + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("Start after Stop must return immediately") + } +} + +// TestUnassignedNetworkReportedOncePerEpisode covers the no-routing-peer log: +// the periodic re-evaluation hits the same branch every tick, so the episode +// must be reported once, and again only after a route was assigned in between. +func TestUnassignedNetworkReportedOncePerEpisode(t *testing.T) { + w, _ := newRecalcWatcher(t) + + unassigned := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + } + assigned := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 10 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "the first unassigned evaluation should be reported") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "repeats stay within the same episode") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, assigned)) + assert.False(t, w.reportedUnassigned, "an assigned route ends the episode") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "losing the route again starts a new episode") +} + +// TestLatencyJitterDoesNotSwitchRoute walks two equivalent peers through jitter +// around the same mean. Neither peer is genuinely better, so the route must not +// move at any point. +func TestLatencyJitterDoesNotSwitchRoute(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + jitter := []int{-6, 3, -4, 8, -2, 5, -7, 1, 6, -3} + for i, offsetMs := range jitter { + offset := time.Duration(offsetMs) * time.Millisecond + statuses := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20*time.Millisecond + offset}, + "route2": {status: peer.StatusConnected, latency: 20*time.Millisecond - offset}, + } + + chosen, _ := w.getBestRouteFromStatuses(statuses) + require.Equal(t, route.ID("route1"), chosen, "route changed on jitter at sample %d", i) + } +} diff --git a/client/internal/routemanager/client/env.go b/client/internal/routemanager/client/env.go new file mode 100644 index 000000000..398667cdf --- /dev/null +++ b/client/internal/routemanager/client/env.go @@ -0,0 +1,77 @@ +package client + +import ( + "os" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // envRelayedSwitchDelay sets how long a routing peer that dropped from a + // direct to a relayed connection while carrying the route is kept before the + // route moves to an equivalent peer with a direct connection. Takes a + // duration ("30s", "0" to switch at once) or "never" to only leave such a + // peer when it disconnects. A routing peer that was already relayed when + // chosen always gives way to a direct one at once. + envRelayedSwitchDelay = "NB_HA_RELAYED_SWITCH_DELAY" + + relayedSwitchDelayNever = "never" + + // defaultRelayedSwitchDelay covers the common case of a direct connection + // that drops to relay and recovers within seconds. Moving the route + // resets every masqueraded flow using it, staying on the relay does not. + defaultRelayedSwitchDelay = 30 * time.Second +) + +// relayedSwitchPolicy is the resolved envRelayedSwitchDelay setting. +type relayedSwitchPolicy struct { + delay time.Duration + // never keeps a connected relayed routing peer regardless of delay + never bool +} + +func (p relayedSwitchPolicy) String() string { + if p.never { + return relayedSwitchDelayNever + } + return p.delay.String() +} + +var ( + relayedSwitchOnce sync.Once + resolvedRelayedSwitch relayedSwitchPolicy +) + +// relayedSwitch returns the process-wide relayed switch policy, read from the +// environment on first use. +func relayedSwitch() relayedSwitchPolicy { + relayedSwitchOnce.Do(func() { + resolvedRelayedSwitch = parseRelayedSwitchPolicy(os.Getenv(envRelayedSwitchDelay)) + log.Infof("routing peer relayed switch delay: %s", resolvedRelayedSwitch) + }) + return resolvedRelayedSwitch +} + +func parseRelayedSwitchPolicy(raw string) relayedSwitchPolicy { + def := relayedSwitchPolicy{delay: defaultRelayedSwitchDelay} + + val := strings.TrimSpace(raw) + if val == "" { + return def + } + if strings.EqualFold(val, relayedSwitchDelayNever) { + return relayedSwitchPolicy{never: true} + } + + delay, err := time.ParseDuration(val) + if err != nil || delay < 0 { + log.Warnf("invalid %s value %q, using the default of %s: want a duration or %q", + envRelayedSwitchDelay, raw, def, relayedSwitchDelayNever) + return def + } + + return relayedSwitchPolicy{delay: delay} +} diff --git a/client/internal/routemanager/client/relayed_switch_test.go b/client/internal/routemanager/client/relayed_switch_test.go new file mode 100644 index 000000000..8f4a67bba --- /dev/null +++ b/client/internal/routemanager/client/relayed_switch_test.go @@ -0,0 +1,358 @@ +package client + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/route" +) + +func TestParseRelayedSwitchPolicy(t *testing.T) { + testCases := []struct { + raw string + want relayedSwitchPolicy + }{ + {raw: "", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: " ", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "0", want: relayedSwitchPolicy{}}, + {raw: "10s", want: relayedSwitchPolicy{delay: 10 * time.Second}}, + {raw: " 2m ", want: relayedSwitchPolicy{delay: 2 * time.Minute}}, + {raw: "never", want: relayedSwitchPolicy{never: true}}, + {raw: "NEVER", want: relayedSwitchPolicy{never: true}}, + // unrecognised values keep the default rather than guessing + {raw: "-5s", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "soon", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "30", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + } + + for _, tc := range testCases { + t.Run(tc.raw, func(t *testing.T) { + assert.Equal(t, tc.want, parseRelayedSwitchPolicy(tc.raw), "policy parsed from %q", tc.raw) + }) + } +} + +// newRelayedTestWatcher is a watcher over two equal-metric routes, currently on +// route1, for the given policy. +func newRelayedTestWatcher(t *testing.T, policy relayedSwitchPolicy) *Watcher { + t.Helper() + + w := newTestWatcher(t, time.Time{}) + w.relayedSwitch = policy + t.Cleanup(func() { + if w.relayedHold != nil { + w.relayedHold.Stop() + } + }) + return w +} + +// degrade walks the current routing peer route1 from direct to relayed, the way +// it is seen when a direct connection carrying the route drops to relay. +func degrade(t *testing.T, w *Watcher) { + t.Helper() + + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + require.Equal(t, route.ID("route1"), chosen, "route1 must carry the route while direct") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "a routing peer that just dropped to relay must be kept") + require.False(t, w.degradedSince.IsZero(), "the drop to relay must be recorded") +} + +var ( + // route1 is relayed, route2 is an equivalent peer with a direct connection + currentRelayed = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + currentDirect = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } +) + +func TestDegradedRoutingPeerIsKeptDuringDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + require.NotNil(t, w.relayedHold, "the pending switch must arm a timer so it is applied when due") + + // the direct connection comes back within the delay: nothing moved, and the + // degraded period ends + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + assert.Equal(t, route.ID("route1"), chosen, "the recovered routing peer must stay chosen") + assert.True(t, w.degradedSince.IsZero(), "the degraded period must end once the peer is direct again") +} + +func TestDegradedRoutingPeerIsReplacedAfterDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a routing peer relayed for longer than the delay must give way to a direct one") + assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") +} + +// A routing peer that was relayed when it was chosen is not degraded: when an +// equivalent direct peer shows up the route must upgrade at once instead of +// staying on the slower path for the delay. +func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + // route1 was chosen while relayed and route2 was still connecting + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + require.True(t, w.degradedSince.IsZero(), "a peer relayed from the start is not degraded") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was relayed all along") + assert.Equal(t, switchReasonDirect, w.switchReason) + assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") +} + +// An idle routing peer woken by traffic whose relay connection comes up before +// the direct one was never direct: it is not degraded, and must give way to an +// equivalent direct peer at once. +func TestIdleRoutingPeerWokenOverRelayUpgradesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + "route2": {status: peer.StatusIdle}, + }) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + require.True(t, w.degradedSince.IsZero(), "waking up over relay is not a drop from direct") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was never direct") + assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") +} + +// A routing peer that drops to relay again, after recovering from an earlier +// drop, is a new degradation and must be reported at info level again. +func TestRepeatedDegradationIsReportedAgain(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + require.Equal(t, route.ID("route2"), w.heldCandidate, "the first drop must be reported") + + // the first report is well within the report interval + w.heldReported = w.heldReported.Add(-time.Minute) + firstReport := w.heldReported + + degrade(t, w) + assert.True(t, w.heldReported.After(firstReport), "a new drop to relay must be reported, not treated as a repeat") +} + +func TestDegradedRoutingPeerNeverPolicy(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + degrade(t, w) + w.degradedSince = w.degradedSince.Add(-time.Hour) + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route1"), chosen, "with never, a degraded routing peer is kept while connected") + assert.Nil(t, w.relayedHold, "with never there is nothing to schedule") +} + +func TestDegradedRoutingPeerZeroDelaySwitchesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{}) + + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a zero delay must keep the immediate switch to a direct peer") +} + +// The delay only covers a candidate that is better solely by being direct. A +// better metric or a lost routing peer must move the route at once. +func TestRelayedDelayDoesNotHoldOtherSwitches(t *testing.T) { + t.Run("better metric", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + degrade(t, w) + w.routes["route2"].Metric = route.MaxMetric - 1 + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a better metric must win over a degraded peer immediately") + assert.Equal(t, switchReasonMetric, w.switchReason) + }) + + t.Run("current peer disconnected", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + degrade(t, w) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a routing peer that is gone must be replaced regardless of the delay") + assert.Equal(t, switchReasonUnavailable, w.switchReason) + }) + + t.Run("both relayed", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, relayed: true}, + }) + assert.Equal(t, route.ID("route1"), chosen, "equally relayed peers are a tie and the current one stays") + assert.Nil(t, w.relayedHold, "a tie must not schedule a switch") + }) +} + +// TestRelayedHoldTimerAppliesSwitch drives the Start loop: once the delay has +// passed, the held switch must be applied without waiting for a peer update or +// the periodic re-evaluation. +func TestRelayedHoldTimerAppliesSwitch(t *testing.T) { + w, handler := newRecalcWatcher(t) + w.relayedSwitch = relayedSwitchPolicy{delay: 50 * time.Millisecond} + + recorder := w.statusRecorder + for _, key := range []string{"peer1", "peer2"} { + require.NoError(t, recorder.AddPeer(key, key+".netbird.cloud", "100.64.0.1", "")) + } + require.NoError(t, recorder.UpdatePeerState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected, Relayed: true})) + require.NoError(t, recorder.UpdatePeerState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnected})) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + // route1 drops to relay while route2 is direct: held for the delay + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added, "the switch must be held while the delay runs") + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + go w.Start() + t.Cleanup(w.Stop) + + // the loop owns the watcher state now, so observe the switch through the + // event it publishes rather than reading its fields + require.Eventually(t, func() bool { + for _, e := range recorder.GetEventHistory() { + if e.Message == "Routing peer changed" && e.Metadata["peer"] == "peer2" { + return true + } + } + return false + }, 2*time.Second, 10*time.Millisecond, "the held switch must be applied once the delay passed") +} + +// A route update can keep a route's ID and hand it to a different routing peer. +// That is a switch like any other: it is reported, and the new peer is tracked +// from its own state rather than inheriting the previous peer's. +func TestRouteReassignedToDifferentPeerIsASwitch(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + reassigned := *w.routes["route1"] + reassigned.Peer = "peer3" + w.routes["route1"] = &reassigned + + require.NoError(t, w.recalculateRoutes(reasonRouteUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + })) + assert.Equal(t, []string{"peer1", "peer3"}, handler.added, "the allowed IPs must move to the new routing peer") + assert.Equal(t, "peer3", w.trackedPeer, "the new routing peer must be tracked") + assert.False(t, w.trackedDirect, "the new routing peer is tracked from its own state") + + events := w.statusRecorder.GetEventHistory() + require.Len(t, events, 1, "the reassignment must publish a switch event") + assert.Equal(t, "Routing peer changed", events[0].Message) + assert.Equal(t, "peer1", events[0].Metadata["previous_peer"]) + assert.Equal(t, "peer3", events[0].Metadata["peer"]) + assert.Equal(t, string(switchReasonRouteChanged), events[0].Metadata["reason"]) +} + +// Exit nodes report their own connect and disconnect events, but those carry +// no reason: the switch event is published for default routes too. +func TestDefaultRouteSwitchPublishesEvent(t *testing.T) { + w, _ := newRecalcWatcher(t) + for _, r := range w.routes { + r.Network = netip.MustParsePrefix("0.0.0.0/0") + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnected}, + })) + + var switchEvents []*proto.SystemEvent + for _, e := range w.statusRecorder.GetEventHistory() { + if e.Message == "Routing peer changed" { + switchEvents = append(switchEvents, e) + } + } + require.Len(t, switchEvents, 1, "an exit node switch must publish a switch event") + assert.Equal(t, string(switchReasonUnavailable), switchEvents[0].Metadata["reason"]) + assert.Empty(t, switchEvents[0].UserMessage, "the switch event must not notify the user a second time") +} + +func TestRoutingPeerSwitchPublishesEvent(t *testing.T) { + w, _ := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + })) + assert.Empty(t, w.statusRecorder.GetEventHistory(), "the initial assignment is not a switch") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + })) + + events := w.statusRecorder.GetEventHistory() + require.Len(t, events, 1, "a switch must publish exactly one event") + assert.Equal(t, proto.SystemEvent_WARNING, events[0].Severity) + assert.Equal(t, "Routing peer changed", events[0].Message) + assert.Equal(t, map[string]string{ + "network": "recording", + "id": "", + "previous_peer": "peer1", + "peer": "peer2", + "reason": string(switchReasonUnavailable), + }, events[0].Metadata, "the event must name both peers and the reason") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnecting}, + })) + + events = w.statusRecorder.GetEventHistory() + require.Len(t, events, 2, "losing the last routing peer must publish an event") + assert.Equal(t, "No routing peer available", events[1].Message) + assert.Equal(t, "peer2", events[1].Metadata["previous_peer"]) +} From dfa7bd78923c64ad5ca28a59a91546aadf911ed5 Mon Sep 17 00:00:00 2001 From: Daniele Casciani Date: Tue, 13 Oct 2026 18:03:47 +0200 Subject: [PATCH 59/70] [client] Migrate macOS cask template to Homebrew install steps (#7477) Homebrew deprecated the postflight and uninstall_preflight cask stanzas in favour of the declarative *_steps DSL, so every brew command that evaluates netbirdio/tap now prints deprecation warnings. Once the deprecation becomes a disable the generated cask stops loading and netbird-ui can no longer be installed or upgraded through Homebrew. The *_steps blocks take JSON-serialisable steps run in a sandbox rather than arbitrary Ruby, so system_command is re-expressed as run/remove. The two postflight blocks merge into one because a cask carries only a single instance, preserving the original order. set_permissions moves from a hardcoded /Applications to base: :appdir, matching what the installer invocation already did. The launchctl fallbacks keep their tolerant semantics through must_succeed: false, and remove is a no-op when the plist is absent. From 9efe59b9b2b2d4678e55ec1161be37479eb39314 Mon Sep 17 00:00:00 2001 From: Brandon Hopkins <76761586+TechHutTV@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:46:27 -0700 Subject: [PATCH 60/70] [infrastructure] Fix Traefik Versioning and Update Crowdsec (#8152) --- .../workflows/test-infrastructure-files.yml | 12 +++ .../getting-started-enterprise.sh | 82 ++++++++++++++-- infrastructure_files/getting-started.sh | 34 ++++++- infrastructure_files/migrate.sh | 10 +- .../tests/test-getting-started-enterprise.sh | 96 +++++++++++++++++++ .../tests/test-getting-started-networks.sh | 54 +++++++++++ .../tests/test-getting-started.sh | 82 ++++++++++++++++ 7 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 infrastructure_files/tests/test-getting-started-enterprise.sh create mode 100644 infrastructure_files/tests/test-getting-started-networks.sh create mode 100644 infrastructure_files/tests/test-getting-started.sh diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 1313379ee..4875adc31 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -258,6 +258,18 @@ jobs: with: persist-credentials: false + - name: Verify enterprise Traefik tag validation + run: bash infrastructure_files/tests/test-getting-started-enterprise.sh + + - name: Verify community Traefik image validation + run: bash infrastructure_files/tests/test-getting-started.sh + + - name: Verify community Compose network scoping + run: bash infrastructure_files/tests/test-getting-started-networks.sh + + - name: Verify migration Compose network scoping + run: bash infrastructure_files/tests/test-getting-started-networks.sh --migration + - name: Verify fresh-install session cookie key hardening run: | grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 344bd2fed..b6565bff1 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -1,8 +1,5 @@ #!/bin/bash -set -e -set -o pipefail - # NetBird Enterprise — Getting Started # Single-node bootstrap for a self-hosted NetBird Enterprise stack with the # embedded identity provider. Owner is created via first-login flow. @@ -23,6 +20,9 @@ PROXY_TOKEN_ID="" # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" +TRAEFIK_HEADER_CONFIG=' - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete"' + LICENSE_VERDICT="unknown" LICENSE_LOG_LINES="" @@ -50,6 +50,25 @@ check_openssl() { echo "openssl is not installed or not in PATH." > /dev/stderr exit 1 fi + return +} + +# Only parseable versions can guarantee that aliasHeadersStrategy and its +# CVE-2026-88004 fix (v3.7.13) are available in the fresh-install compose. +check_traefik_tag() { + local tag="${NETBIRD_TRAEFIK_TAG:-}" ver major minor patch + [[ -n "$tag" ]] || return 0 + ver="${tag#v}" + if [[ ! "$ver" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then + die "Cannot verify NETBIRD_TRAEFIK_TAG=$tag. Use a numeric version tag such as v3.7.13 or newer, or unset NETBIRD_TRAEFIK_TAG for the default (v3.7.14)." + fi + major=$((10#${BASH_REMATCH[1]})) + minor=$((10#${BASH_REMATCH[2]})) + patch=$((10#${BASH_REMATCH[4]:-0})) + if (( major < 3 || (major == 3 && minor < 7) || (major == 3 && minor == 7 && patch < 13) )); then + die "NETBIRD_TRAEFIK_TAG=$tag predates aliasHeadersStrategy and its CVE-2026-88004 fix (added in v3.7.12, fixed in v3.7.13), which this installer always enables. Use v3.7.13 or newer, or unset NETBIRD_TRAEFIK_TAG for the default (v3.7.14)." + fi + return } die() { @@ -62,6 +81,7 @@ env_get() { local value value=$(sed -n "s/^$1=//p" .env | tail -n 1) echo "${value:-$2}" + return } # merge_env upserts the KEY=VALUE lines from stdin into .env, in place. @@ -72,14 +92,17 @@ merge_env() { { print } END { for (i = 1; i <= n; i++) if (o[i] in v) print v[o[i]] }' - .env) printf '%s\n' "$merged" > .env + return } rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" + return } rand_b64_key() { openssl rand -base64 32 + return } check_nb_domain() { @@ -133,6 +156,7 @@ read_nb_domain() { fi fi echo "$value" + return } read_letsencrypt_email() { @@ -150,6 +174,7 @@ read_letsencrypt_email() { return fi echo "$value" + return } read_required() { @@ -163,6 +188,7 @@ read_required() { fi done echo "$value" + return } read_secret() { @@ -177,6 +203,7 @@ read_secret() { fi done echo "$value" + return } # read_yes_no "" [] @@ -199,6 +226,7 @@ read_yes_no() { [Yy] | [Yy][Ee][Ss]) echo "yes" ;; *) echo "no" ;; esac + return } read_crowdsec_option() { @@ -208,6 +236,7 @@ read_crowdsec_option() { echo " blocks known malicious sources before they reach services exposed through" echo " the proxy. Adds a CrowdSec container to the stack." NETBIRD_CROWDSEC=$(read_yes_no "Enable CrowdSec" "n") + return } # Gate the install on explicit acceptance of the NetBird On-Premise EULA. @@ -245,6 +274,7 @@ EOF exit 1 fi echo "" > /dev/stderr + return } wait_postgres() { @@ -267,6 +297,7 @@ wait_postgres() { done echo " done" set -e + return } wait_for_license_verdict() { @@ -359,6 +390,7 @@ up_all_but_proxy() { services=$($DOCKER_COMPOSE_COMMAND config --services | grep -vx proxy) # shellcheck disable=SC2086 $DOCKER_COMPOSE_COMMAND up -d $services + return } wait_crowdsec() { @@ -372,6 +404,7 @@ wait_crowdsec() { admin_token() { $DOCKER_COMPOSE_COMMAND run --rm --no-deps -T netbird-server admin token "$@" --config /etc/netbird/config.yaml + return } # revoke_proxy_token revokes the token this run minted if the proxy never started. @@ -384,6 +417,7 @@ revoke_proxy_token() { fi echo "Could not revoke the unused proxy token ${PROXY_TOKEN_ID}. Revoke it with:" > /dev/stderr echo " $DOCKER_COMPOSE_COMMAND run --rm netbird-server admin token revoke ${PROXY_TOKEN_ID} --config /etc/netbird/config.yaml" > /dev/stderr + return } # start_proxy mints the proxy token and CrowdSec bouncer key, then starts the proxy. @@ -419,6 +453,7 @@ start_proxy() { die "Could not start the proxy. Check the proxy logs, then re-run with --enable-proxy." fi PROXY_TOKEN_ID="" + return } print_proxy_notes() { @@ -433,10 +468,12 @@ print_proxy_notes() { if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then echo " CrowdSec is running. Enable it per service in the dashboard under Access Control." fi + return } init_environment() { check_openssl + check_traefik_tag DOCKER_COMPOSE_COMMAND=$(check_docker_compose) if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then @@ -555,12 +592,14 @@ init_environment() { if [[ "$LICENSE_VERDICT" == "rejected" ]]; then exit 1 fi + return } # service_block NAME prints a service's definition from the compose file on stdin. service_block() { local name="$1" awk -v s=" ${name}:" '$0 == s { p = 1; print; next } p && (/^[^ ]/ || /^ [^ ]/) { exit } p' + return } # enable_features adds the proxy and/or traffic events to the install in the @@ -585,6 +624,9 @@ enable_features() { # so the re-render never removes a working Let's Encrypt setup. local traefik_block traefik_block=$(service_block traefik < docker-compose.yml) + # Enabling features does not upgrade Traefik. Preserve the existing header policy, + # including its absence on older releases that do not support these options. + TRAEFIK_HEADER_CONFIG=$(printf '%s\n' "$traefik_block" | grep -Ei -- '--entrypoints\.[^.]+\.http\.(alias|underscore)headersstrategy=' || true) if ! grep -q certificatesresolvers <<< "$traefik_block"; then CUSTOM_TLS_CERTS=$(awk '/:\/certs:ro$/ { sub(/^ *- /, ""); sub(/:\/certs:ro$/, ""); print; exit }' <<< "$traefik_block") fi @@ -684,6 +726,7 @@ enable_features() { if [[ "$want_proxy" == "yes" ]]; then print_proxy_notes fi + return } rollback() { @@ -697,6 +740,7 @@ rollback() { done $DOCKER_COMPOSE_COMMAND up -d --remove-orphans $DOCKER_COMPOSE_COMMAND restart netbird-server + return } # ------------------------------------------------------------------ @@ -723,7 +767,7 @@ NETBIRD_DOMAIN=${NETBIRD_DOMAIN} # Reverse proxy (Traefik) NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL} -NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6} +NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.7.14} NETBIRD_TRAEFIK_IP=${TRAEFIK_IP} # Image tags. Default to "latest" @@ -770,20 +814,23 @@ NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY} # Dashboard OIDC scopes NETBIRD_AUTH_SUPPORTED_SCOPES=${NETBIRD_AUTH_SUPPORTED_SCOPES:-openid profile email groups} EOF + return } render_env_flow() { echo "NETBIRD_ENRICHER_TAG=${NETBIRD_ENRICHER_TAG:-latest}" echo "NETBIRD_RECEIVER_TAG=${NETBIRD_RECEIVER_TAG:-latest}" + return } render_env_proxy() { echo "NETBIRD_PROXY_TAG=${NETBIRD_PROXY_TAG:-latest}" echo "NETBIRD_PROXY_TOKEN=" if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then - echo "NETBIRD_CROWDSEC_TAG=${NETBIRD_CROWDSEC_TAG:-v1.7.7}" + echo "NETBIRD_CROWDSEC_TAG=${NETBIRD_CROWDSEC_TAG:-v1.8.1}" echo "NETBIRD_CROWDSEC_BOUNCER_KEY=" fi + return } render_docker_compose() { @@ -801,6 +848,7 @@ render_docker_compose() { render_compose_footer } | if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then cat; else sed '/NETBIRD_LICENSE_SERVER_BASE_URL/d'; fi \ | if [[ -n "$CUSTOM_TLS_CERTS" ]]; then sed -e '/certificatesresolvers/d' -e '/certresolver/d'; else cat; fi + return } render_compose_header() { @@ -815,6 +863,7 @@ x-default: &default services: EOF + return } render_compose_common() { @@ -840,6 +889,12 @@ render_compose_common() { - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" +EOF + # Installs that predate these options have no header config; don't leave a blank line. + if [[ -n "$TRAEFIK_HEADER_CONFIG" ]]; then + printf '%s\n' "$TRAEFIK_HEADER_CONFIG" + fi + cat <<'EOF' # readTimeout bounds the whole request, and gRPC streams / relay WebSockets # never end one; idleTimeout would close the keep-alive connection they # are reused over. Entrypoint-wide is the only scope Traefik offers here. @@ -916,6 +971,7 @@ EOF - LETSENCRYPT_EMAIL= EOF + return } render_compose_server() { @@ -963,6 +1019,7 @@ render_compose_server() { - NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL} EOF + return } render_compose_flow() { @@ -1034,6 +1091,7 @@ render_compose_flow() { - traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c EOF + return } render_compose_proxy() { @@ -1097,6 +1155,7 @@ EOF EOF fi echo "" + return } render_compose_postgres() { @@ -1119,6 +1178,7 @@ render_compose_postgres() { - netbird_postgres:/var/lib/postgresql/data EOF + return } render_compose_footer() { @@ -1151,6 +1211,7 @@ networks: - subnet: 172.30.0.0/24 gateway: 172.30.0.1 EOF + return } render_config_yaml() { @@ -1215,6 +1276,7 @@ EOF if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then render_config_flow fi + return } render_config_flow() { @@ -1225,6 +1287,7 @@ render_config_flow() { address: "https://${NETBIRD_DOMAIN}:443" interval: "60s" EOF + return } render_traefik_proxy() { @@ -1235,6 +1298,7 @@ tcp: proxyProtocol: version: 2 EOF + return } usage() { @@ -1249,6 +1313,7 @@ the current directory. --enable-traffic-events add traffic events logging (NATS, receiver, enricher) -h, --help show this help EOF + return } main() { @@ -1268,6 +1333,11 @@ main() { else enable_features "$enable_proxy" "$enable_flow" fi + return } -main "$@" +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + set -e + set -o pipefail + main "$@" +fi diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index d5c6d9dc9..902956199 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -87,6 +87,27 @@ check_jq() { return 0 } +check_traefik_image() { + local image="${TRAEFIK_IMAGE##*/}" major minor patch + + # A version tag cannot establish which release a digest points to. + if [[ "$TRAEFIK_IMAGE" == *@* || + ! "$image" =~ ^[^:@]+:v?([0-9]+)(\.([0-9]+)(\.([0-9]+))?)?$ ]]; then + echo "Cannot verify TRAEFIK_IMAGE=$TRAEFIK_IMAGE." >&2 + echo "Use a numeric Traefik version tag (v3.7.13 or newer) without a digest." >&2 + return 1 + fi + major=$((10#${BASH_REMATCH[1]})) + minor=$((10#${BASH_REMATCH[3]:-0})) + patch=$((10#${BASH_REMATCH[5]:-0})) + if (( major < 3 || (major == 3 && minor < 7) || (major == 3 && minor == 7 && patch < 13) )); then + echo "TRAEFIK_IMAGE=$TRAEFIK_IMAGE is incompatible with this configuration." >&2 + echo "Use Traefik v3.7.13 or newer for aliasHeadersStrategy and its header-sanitization fix." >&2 + return 1 + fi + return 0 +} + get_main_ip_address() { if [[ "$OSTYPE" == "darwin"* ]]; then interface=$(route -n get default | grep 'interface:' | awk '{print $2}') @@ -448,8 +469,8 @@ initialize_default_values() { # Combined server replaces separate signal, relay, and management containers NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"} NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"} - TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"} - CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"} + TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.7.14"} + CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.8.1"} # Reverse proxy configuration REVERSE_PROXY_TYPE="0" TRAEFIK_EXTERNAL_NETWORK="" @@ -613,6 +634,7 @@ generate_configuration_files() { # Render docker-compose and proxy config based on selection case "$REVERSE_PROXY_TYPE" in 0) + check_traefik_image || return 1 render_docker_compose_traefik_builtin > docker-compose.yml if [[ "$ENABLE_PROXY" == "true" ]]; then # Create placeholder proxy.env so docker-compose can validate @@ -912,11 +934,13 @@ services: # Docker provider - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=netbird" + - "--providers.docker.network=\${COMPOSE_PROJECT_NAME:-}_netbird" # Entrypoints - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" + - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete" # Disable timeouts for long-lived gRPC streams - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0" - "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0" @@ -1779,4 +1803,6 @@ print_post_setup_instructions() { return 0 } -init_environment +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + init_environment +fi diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh index 67895fab6..46971a57a 100755 --- a/infrastructure_files/migrate.sh +++ b/infrastructure_files/migrate.sh @@ -838,7 +838,7 @@ generate_docker_compose_traefik() { services: # Traefik reverse proxy (automatic TLS via Let's Encrypt) traefik: - image: traefik:v3.6 + image: traefik:v3.7.14 container_name: netbird-traefik restart: unless-stopped networks: @@ -851,11 +851,13 @@ services: # Docker provider - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=netbird" + - "--providers.docker.network=\${COMPOSE_PROJECT_NAME:-}_netbird" # Entrypoints - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" + - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete" # Disable timeouts for long-lived gRPC streams - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0" - "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0" @@ -1283,4 +1285,6 @@ main() { return 0 } -main "$@" +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + main "$@" +fi diff --git a/infrastructure_files/tests/test-getting-started-enterprise.sh b/infrastructure_files/tests/test-getting-started-enterprise.sh new file mode 100644 index 000000000..9335caf2a --- /dev/null +++ b/infrastructure_files/tests/test-getting-started-enterprise.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started-enterprise.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cases=0 + +for errexit in +o -o; do + for pipefail in +o -o; do + bash -c ' + set "$1" errexit + set "$2" pipefail + before_options=$SHELLOPTS + source "$3" + if [[ "$SHELLOPTS" != "$before_options" ]]; then + echo "Sourcing changed shell options: $before_options -> $SHELLOPTS" >&2 + exit 1 + fi + ' bash "$errexit" "$pipefail" "$script_dir/../getting-started-enterprise.sh" + done +done + +assert_tag() { + local expected_status="$1" expected_message="$2" actual_status=0 + shift 2 + local label="${1-}" + + # Each call needs its own process because die exits rather than returning. + ( + unset NETBIRD_TRAEFIK_TAG + if [[ $# -gt 0 ]]; then + NETBIRD_TRAEFIK_TAG="$1" + fi + check_traefik_tag + ) >"$test_dir/stdout" 2>"$test_dir/stderr" || actual_status=$? + + if [[ "$actual_status" != "$expected_status" ]]; then + echo "Tag '$label': expected exit $expected_status, got $actual_status" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + if [[ -s "$test_dir/stdout" ]]; then + echo "Tag '$label': unexpected output on stdout" >&2 + cat "$test_dir/stdout" >&2 + exit 1 + fi + if [[ -n "$expected_message" ]]; then + if ! grep -Fq "$expected_message" "$test_dir/stderr"; then + echo "Tag '$label': expected diagnostic '$expected_message'" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + elif [[ -s "$test_dir/stderr" ]]; then + echo "Tag '$label': unexpected output on stderr" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + cases=$((cases + 1)) +} + +# Unset and empty overrides both select the installer's default image. +assert_tag 0 "" +assert_tag 0 "" "" + +for tag in v3.7.13 3.7.13 v3.7.14 v3.8 3.8 v4.0 v4.0.0 \ + v03.07.013 v3.08.0 v3.7.019; do + assert_tag 0 "" "$tag" +done + +for tag in v2.11.0 v3.6.20 v3.7 3.7 v3.7.11 v3.7.12 3.7.12 \ + v03.07.012 v3.7.08; do + assert_tag 1 "predates aliasHeadersStrategy" "$tag" +done + +for tag in latest custom-build v2 v3 sha256:abcdef v3.7.11-alpine \ + v3.7.13-alpine v3.7.13-rc1 v3.7.14+custom v3.7.14@sha256:abcdef \ + 3..14 v3.7.14.0 " 3.7.14" $'v3.7.14\n'; do + assert_tag 1 "Cannot verify NETBIRD_TRAEFIK_TAG=" "$tag" +done + +# Re-rendering an install that has no header config must not add a blank line +# to the Traefik command list. +for header_config in "" "$TRAEFIK_HEADER_CONFIG"; do + rendered=$(TRAEFIK_HEADER_CONFIG="$header_config" render_compose_common) + if [[ "$(printf '%s\n' "$rendered" | service_block traefik)" == *$'\n\n'* ]]; then + echo "Traefik command list has a blank line (header config: '${header_config:-}')" >&2 + exit 1 + fi + cases=$((cases + 1)) +done + +echo "Passed $cases Traefik tag validation cases." diff --git a/infrastructure_files/tests/test-getting-started-networks.sh b/infrastructure_files/tests/test-getting-started-networks.sh new file mode 100644 index 000000000..9283a80bc --- /dev/null +++ b/infrastructure_files/tests/test-getting-started-networks.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -eo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started.sh" +compose_command=$(check_docker_compose) +read -r -a compose <<< "$compose_command" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +mkdir "$test_dir/deployment" +cd "$test_dir/deployment" + +unset COMPOSE_PROJECT_NAME COMPOSE_FILE COMPOSE_ENV_FILES +# The generator's environment must not bake a project name into the output. +if [[ "${1:-}" == "--migration" ]]; then + COMPOSE_PROJECT_NAME=render-only bash -s -- "$script_dir/../migrate.sh" >/dev/null <<'EOF' +source "$1" +INSTALL_DIR=$PWD +DOMAIN=netbird.test +generate_dashboard_env +generate_docker_compose_traefik +EOF +else + TRAEFIK_IMAGE=traefik:v3.7.14 + NETBIRD_DOMAIN=netbird.test + NETBIRD_AGENT_NETWORK=false + initialize_default_values + COMPOSE_PROJECT_NAME=render-only generate_configuration_files >/dev/null +fi + +assert_network() { + local expected="$1" actual provider + shift + "${compose[@]}" "$@" config --format json >config.json + actual=$(jq -r '.networks.netbird.name' config.json) + provider=$(jq -r '.services.traefik.command[] | select(startswith("--providers.docker.network="))' config.json) + if [[ "$actual" != "$expected" || "$provider" != "--providers.docker.network=$expected" ]]; then + echo "Expected network '$expected'; Compose chose '$actual', Traefik received '$provider'" >&2 + exit 1 + fi +} + +assert_network deployment_netbird +COMPOSE_PROJECT_NAME=env-project assert_network env-project_netbird +echo 'COMPOSE_PROJECT_NAME=file-project' >.env +assert_network file-project_netbird +COMPOSE_PROJECT_NAME=env-project assert_network cli-project_netbird -p cli-project +: >.env +echo 'name: config-project' >>docker-compose.yml +assert_network config-project_netbird + +echo "Passed 5 Compose network scoping cases." diff --git a/infrastructure_files/tests/test-getting-started.sh b/infrastructure_files/tests/test-getting-started.sh new file mode 100644 index 000000000..0826340e5 --- /dev/null +++ b/infrastructure_files/tests/test-getting-started.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -eo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cases=0 + +assert_image() { + local expected_status="$1" expected_message="$2" image="$3" actual_status=0 + local case_dir="$test_dir/$cases" + mkdir "$case_dir" + + ( + cd "$case_dir" + TRAEFIK_IMAGE="$image" + NETBIRD_DOMAIN=netbird.test + NETBIRD_AGENT_NETWORK=false + initialize_default_values + generate_configuration_files + ) >"$case_dir/stdout" 2>"$case_dir/stderr" || actual_status=$? + + if [[ "$actual_status" != "$expected_status" ]]; then + echo "Image '$image': expected exit $expected_status, got $actual_status" >&2 + cat "$case_dir/stderr" >&2 + exit 1 + fi + if [[ "$expected_status" == 0 ]]; then + if [[ -s "$case_dir/stderr" ]]; then + cat "$case_dir/stderr" >&2 + exit 1 + fi + for entrypoint in web websecure; do + if ! grep -Fq -- "--entrypoints.$entrypoint.http.aliasHeadersStrategy=delete" "$case_dir/docker-compose.yml"; then + echo "Image '$image': missing header protection for $entrypoint" >&2 + exit 1 + fi + done + if ! grep -Fxq " image: ${image:-traefik:v3.7.14}" "$case_dir/docker-compose.yml"; then + echo "Image '$image': unexpected rendered image" >&2 + exit 1 + fi + else + if ! grep -Fq "$expected_message" "$case_dir/stderr"; then + echo "Image '$image': expected diagnostic '$expected_message'" >&2 + cat "$case_dir/stderr" >&2 + exit 1 + fi + if [[ -e "$case_dir/docker-compose.yml" || -e "$case_dir/config.yaml" || -e "$case_dir/dashboard.env" ]]; then + echo "Image '$image': generated configuration despite failed validation" >&2 + exit 1 + fi + fi + cases=$((cases + 1)) +} + +# An empty override selects the default image. +assert_image 0 "" "" +for image in traefik:v3.7.13 traefik:3.7.13 traefik:v3.7.14 traefik:v3.8 \ + traefik:v4 traefik:v4.0.0 traefik:v03.07.013 traefik:v3.08.0 \ + registry.example:5000/team/traefik:3.7.13; do + assert_image 0 "" "$image" +done + +for tag in v2 v2.11.0 v3 v3.6.20 v3.7 v3.7.11 v3.7.12 v03.07.012 v3.7.08; do + assert_image 1 "incompatible with this configuration" "traefik:$tag" +done + +digest="sha256:$(printf '%064d' 0)" +for image in traefik:latest traefik:custom-build traefik registry.example:5000/traefik \ + "traefik@$digest" "registry.example:5000/team/traefik@$digest" \ + "traefik:v3.7.13@$digest" "traefik:latest@$digest" \ + traefik:v3.7.13-rc1 traefik:v3.7.13-alpine traefik:v3.7.14+custom \ + traefik:3..14 traefik:v3.7.14.0 traefik:custom:v3.7.14 :v3.7.14 \ + "traefik: v3.7.14" $'traefik:v3.7.14\n'; do + assert_image 1 "Cannot verify TRAEFIK_IMAGE=" "$image" +done + +echo "Passed $cases Traefik image configuration cases." From 2299e3d5a742ac5c8d28cf3fa36a8fcaacbb771d Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:46:35 +0200 Subject: [PATCH 61/70] [management] move is child account (#8195) --- management/internals/server/boot.go | 2 +- management/internals/server/modules.go | 4 ---- management/server/account.go | 18 ++---------------- management/server/auth/manager.go | 4 ++-- .../server/http/middleware/auth_middleware.go | 19 ------------------- .../http/middleware/auth_middleware_test.go | 12 +----------- .../http/middleware/middleware_factory.go | 3 +-- .../testing/testing_tools/channel/channel.go | 4 ++-- 8 files changed, 9 insertions(+), 57 deletions(-) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 4ced64a16..13bbaa6fa 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -204,7 +204,7 @@ func (s *BaseServer) IDPHandler() http.Handler { func (s *BaseServer) Router() *mux.Router { return Create(s, func() *mux.Router { router := mux.NewRouter() - router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics(), s.IsValidChildAccount)...) + router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics())...) return router }) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index f548e9238..3379becf6 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -248,7 +248,3 @@ func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { return &m }) } - -func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool { - return false -} diff --git a/management/server/account.go b/management/server/account.go index 0a8ec9a59..90e384ac0 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1637,10 +1637,6 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u return "", "", status.Errorf(status.NotFound, "user %s not found", userAuth.UserId) } - if userAuth.IsChild { - return accountID, user.Id, nil - } - ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false) if err != nil { return "", "", err @@ -1692,10 +1688,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex // and propagates changes to peers if group propagation is enabled. // requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { - // Child accounts and PAT-authenticated requests do not sync JWT groups. + // PAT-authenticated requests do not sync JWT groups. // Embedded-Dex local users also skip sync because local password authentication // does not provide external IdP group claims. - if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { + if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return nil } @@ -1874,8 +1870,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth // Existing user + Existing account + Existing Indexed Domain -> Nothing changes // // Existing user + Existing account + Existing domain reclassified Domain as private -> Nothing changes (index domain) -// -// UserAuth IsChild -> checks that account exists func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context.Context, userAuth auth.UserAuth) (string, error) { log.WithContext(ctx).Tracef("getting account with authorization claims. User ID: \"%s\", Account ID: \"%s\", Domain: \"%s\", Domain Category: \"%s\"", userAuth.UserId, userAuth.AccountId, userAuth.Domain, userAuth.DomainCategory) @@ -1884,14 +1878,6 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context return "", errors.New(emptyUserID) } - if userAuth.IsChild { - exists, err := am.Store.AccountExists(ctx, store.LockingStrengthNone, userAuth.AccountId) - if err != nil || !exists { - return "", err - } - return userAuth.AccountId, nil - } - if userAuth.DomainCategory != types.PrivateCategory || !isDomainValid(userAuth.Domain) { return am.GetAccountIDByUserID(ctx, userAuth) } diff --git a/management/server/auth/manager.go b/management/server/auth/manager.go index 9498789f2..7064c094f 100644 --- a/management/server/auth/manager.go +++ b/management/server/auth/manager.go @@ -75,10 +75,10 @@ func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth } func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) { - // Child accounts and PAT-authenticated requests do not use JWT group access checks. + // PAT-authenticated requests do not use JWT group access checks. // Embedded-Dex local users also skip them because local password authentication // does not provide external IdP group claims. - if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { + if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return userAuth, nil } diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 1e831e6c3..7c21b6a55 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -26,8 +26,6 @@ type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) err type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) -type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool - // AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens type AuthMiddleware struct { authManager serverauth.Manager @@ -36,7 +34,6 @@ type AuthMiddleware struct { syncUserJWTGroups SyncUserJWTGroupsFunc rateLimiter *ratelimit.APIRateLimiter patUsageTracker *PATUsageTracker - isValidChildAccount IsValidChildAccountFunc } // NewAuthMiddleware instance constructor @@ -47,7 +44,6 @@ func NewAuthMiddleware( getUserFromUserAuth GetUserFromUserAuthFunc, rateLimiter *ratelimit.APIRateLimiter, meter metric.Meter, - isValidChildAccount IsValidChildAccountFunc, ) *AuthMiddleware { var patUsageTracker *PATUsageTracker if meter != nil { @@ -65,7 +61,6 @@ func NewAuthMiddleware( getUserFromUserAuth: getUserFromUserAuth, rateLimiter: rateLimiter, patUsageTracker: patUsageTracker, - isValidChildAccount: isValidChildAccount, } } @@ -127,13 +122,6 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts [] return err } - if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = true - } - } - // Email is now extracted in ToUserAuth (from claims or userinfo endpoint) // Available as userAuth.Email @@ -210,13 +198,6 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts [] IsPAT: true, } - if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = true - } - } - // propagates ctx change to upstream middleware *r = *nbcontext.SetUserAuthInRequest(r, userAuth) return nil diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index ceefc7c45..9e60af1fa 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -104,7 +104,7 @@ func mockMarkPATUsed(_ context.Context, token string) error { } func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbauth.UserAuth, token *jwt.Token) (nbauth.UserAuth, error) { - if userAuth.IsChild || userAuth.IsPAT { + if userAuth.IsPAT { return userAuth, nil } @@ -212,7 +212,6 @@ func TestAuthMiddleware_Handler(t *testing.T) { }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handlerToTest := authMiddleware.Handler(nextHandler) @@ -279,7 +278,6 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) @@ -332,7 +330,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -385,7 +382,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -429,7 +425,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -474,7 +469,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -539,7 +533,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -599,7 +592,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -655,7 +647,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -756,7 +747,6 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) for _, tc := range tt { diff --git a/management/server/http/middleware/middleware_factory.go b/management/server/http/middleware/middleware_factory.go index 3ae617774..5346301ea 100644 --- a/management/server/http/middleware/middleware_factory.go +++ b/management/server/http/middleware/middleware_factory.go @@ -10,7 +10,7 @@ import ( log "github.com/sirupsen/logrus" ) -func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics, isValidChildAcctFunc IsValidChildAccountFunc) []mux.MiddlewareFunc { +func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics) []mux.MiddlewareFunc { toret := make([]mux.MiddlewareFunc, 0) toret = append(toret, metrics.HTTPMiddleware().Handler) @@ -28,7 +28,6 @@ func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Man accountManager.GetUserFromUserAuth, rateLimiter, metrics.GetMeter(), - isValidChildAcctFunc, ).Handler) return toret diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 1b15ba780..88ab70b9c 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -146,7 +146,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...) apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) @@ -288,7 +288,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...) apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) From 405bbb8997f3d58d34cf6138bb05f066c1686fd2 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 14 Oct 2026 15:50:16 +0200 Subject: [PATCH 62/70] [infrastructure] Restrict the FreeBSD port job to stable releases (#8204) * [infrastructure] Restrict the FreeBSD port job to stable releases The port scripts picked the target version by scraping the GitHub tags page and only dropped tags containing "rc". A canary tag such as v0.81.0-canary.pr-8098.1 sorts above the latest stable release, so the job tried to package a prerelease, and the page only lists ten tags, so a run of prereleases could leave no candidate at all. Both scripts now share one helper that reads the tags API, keeps MAJOR.MINOR.PATCH versions only, and rejects an explicitly requested prerelease. Go proxy downloads fail instead of hashing an error page. The job moves out of the release workflow into its own, so it runs on pushes to main, on release tags, and on pull requests that touch the scripts or the workflow itself. Prerelease tags skip the job, and a stable tag is packaged directly rather than whatever happens to be the latest release at the time. * [infrastructure] Keep the FreeBSD port job in the release workflow A separate workflow would rename the check and detach the job from the release run, so the job stays in release.yml behind a small trigger job that decides when it runs: pushes to main, stable release tags, and pull requests that touch the port scripts or the workflow. Prerelease tags and other branches skip it. Tags are now listed with git ls-remote, which returns every tag in one call and needs neither a token nor pagination, and the Go module proxy downloads are limited to HTTPS like the other fetches. * [infrastructure] Address Sonar findings in the FreeBSD port helper Read the version into a local and return explicitly from is_stable_version. * [infrastructure] Address review findings in the FreeBSD port job Only a full vMAJOR.MINOR.PATCH tag starts the port job, so a tag like v1.2 skips it instead of failing in the resolver. The diff detection steps use a glob array rather than parsing ls, and quote GITHUB_OUTPUT. A failed tag listing now stops the issue body script instead of writing an incomplete changelog, and a bare "v" argument is rejected rather than treated as a request for the latest release. --- .github/workflows/release.yml | 71 ++++++++++++++--- release_files/freebsd-port-diff.sh | 86 ++++----------------- release_files/freebsd-port-issue-body.sh | 88 +++++---------------- release_files/freebsd-port-lib.sh | 98 ++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 146 deletions(-) create mode 100644 release_files/freebsd-port-lib.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d133d87f1..b3ed4763c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,50 @@ concurrency: cancel-in-progress: true jobs: + freebsd_port_trigger: + name: "FreeBSD Port / Trigger" + runs-on: ubuntu-22.04 + outputs: + run: ${{ steps.decide.outputs.run }} + version: ${{ steps.decide.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Decide whether the port job runs + id: decide + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + run=false + case "$GITHUB_EVENT_NAME" in + push) + if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then + run=true + elif [[ "$GITHUB_REF" == refs/tags/* && "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # The port tracks stable releases only, so a canary or rc tag has nothing to submit. + run=true + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + ;; + pull_request) + git fetch --depth=1 origin "$BASE_SHA" + if git diff --name-only "$BASE_SHA" HEAD | \ + grep -qE '^(release_files/freebsd-port-.*\.sh|\.github/workflows/release\.yml)$'; then + run=true + fi + ;; + esac + echo "run=$run" >> "$GITHUB_OUTPUT" + echo "FreeBSD port job runs: $run" + release_freebsd_port: name: "FreeBSD Port / Build & Test" + needs: freebsd_port_trigger + if: needs.freebsd_port_trigger.outputs.run == 'true' runs-on: ubuntu-22.04 steps: - name: Checkout @@ -33,18 +75,19 @@ jobs: persist-credentials: false - name: Generate FreeBSD port diff - run: bash -x release_files/freebsd-port-diff.sh - - - name: Generate FreeBSD port issue body - run: bash -x release_files/freebsd-port-issue-body.sh + env: + TARGET_VERSION: ${{ needs.freebsd_port_trigger.outputs.version }} + run: bash -x release_files/freebsd-port-diff.sh "$TARGET_VERSION" - name: Check if diff was generated id: check_diff run: | - if ls netbird-*.diff 1> /dev/null 2>&1; then - echo "diff_exists=true" >> $GITHUB_OUTPUT + shopt -s nullglob + diffs=(netbird-*.diff) + if [[ ${#diffs[@]} -gt 0 ]]; then + echo "diff_exists=true" >> "$GITHUB_OUTPUT" else - echo "diff_exists=false" >> $GITHUB_OUTPUT + echo "diff_exists=false" >> "$GITHUB_OUTPUT" echo "No diff file generated (port may already be up to date)" fi @@ -52,10 +95,18 @@ jobs: if: steps.check_diff.outputs.diff_exists == 'true' id: version run: | - VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT + diffs=(netbird-*.diff) + VERSION="${diffs[0]#netbird-}" + VERSION="${VERSION%.diff}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Generated files for version: $VERSION" - cat netbird-*.diff + cat "${diffs[0]}" + + - name: Generate FreeBSD port issue body + if: steps.check_diff.outputs.diff_exists == 'true' + env: + TARGET_VERSION: ${{ steps.version.outputs.version }} + run: bash -x release_files/freebsd-port-issue-body.sh "" "$TARGET_VERSION" - name: Read Go version from go.mod id: goversion diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 77ea55520..cd3c341b9 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -9,62 +9,19 @@ # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 # -# If no version is provided, it fetches the latest from GitHub. +# If no version is provided, it uses the latest stable release from GitHub. +# Prerelease versions (canary, rc, ...) are rejected: the port only tracks +# stable releases. set -e -GITHUB_REPO="netbirdio/netbird" -PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" +# shellcheck source=release_files/freebsd-port-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/freebsd-port-lib.sh" + GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' -fetch_all_tags() { - curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ - grep -iv 'rc' | \ - sed 's/.*\/v//' | \ - sort -u -V - return 0 -} - -fetch_current_ports_version() { - echo "Fetching current version from FreeBSD ports..." >&2 - local makefile version - makefile=$(fetch_ports_file "Makefile") || return 1 - version=$(echo "$makefile" | \ - grep -E "^DISTVERSION=" | \ - sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ') - if [[ -z "$version" ]]; then - echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 - return 1 - fi - echo "$version" - return 0 -} - -fetch_latest_github_release() { - echo "Fetching latest release from GitHub..." >&2 - fetch_all_tags | tail -1 - return 0 -} - -fetch_ports_file() { - local filename="$1" - local content - if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then - echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 - return 1 - fi - if [[ "$content" == \<* ]]; then - echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 - return 1 - fi - printf '%s' "$content" - return 0 -} - compute_checksums() { local version="$1" local tmpdir @@ -77,11 +34,15 @@ compute_checksums() { local mod_file="${tmpdir}/v${version}.mod" local zip_file="${tmpdir}/v${version}.zip" - curl -sL "${GO_PROXY}/v${version}.mod" -o "$mod_file" 2>/dev/null - curl -sL "${GO_PROXY}/v${version}.zip" -o "$zip_file" 2>/dev/null + local -a curl_opts=(-fsL --proto '=https' --proto-redir '=https' --retry 3) + if ! curl "${curl_opts[@]}" "${GO_PROXY}/v${version}.mod" -o "$mod_file" 2>/dev/null || \ + ! curl "${curl_opts[@]}" "${GO_PROXY}/v${version}.zip" -o "$zip_file" 2>/dev/null; then + echo "Error: Could not download v${version} from the Go module proxy" >&2 + return 1 + fi if [[ ! -s "$mod_file" ]] || [[ ! -s "$zip_file" ]]; then - echo "Error: Could not download files from Go module proxy" >&2 + echo "Error: Go module proxy returned empty files for v${version}" >&2 return 1 fi @@ -133,25 +94,12 @@ generate_new_makefile() { } # Parse arguments -NEW_VERSION="${1:-}" - -# Auto-detect versions if not provided -OLD_VERSION=$(fetch_current_ports_version) -if [[ -z "$OLD_VERSION" ]]; then - echo "Error: Could not fetch current version from FreeBSD ports" >&2 - exit 1 -fi -echo "Current FreeBSD ports version: ${OLD_VERSION}" >&2 - -if [[ -z "$NEW_VERSION" ]]; then - NEW_VERSION=$(fetch_latest_github_release) - if [[ -z "$NEW_VERSION" ]]; then - echo "Error: Could not fetch latest release from GitHub" >&2 - exit 1 - fi -fi +NEW_VERSION=$(resolve_target_version "${1:-}") echo "Target version: ${NEW_VERSION}" >&2 +OLD_VERSION=$(fetch_current_ports_version) +echo "Current FreeBSD ports version: ${OLD_VERSION}" >&2 + if [[ "$OLD_VERSION" = "$NEW_VERSION" ]]; then echo "Port is already at version ${NEW_VERSION}. Nothing to do." >&2 exit 0 diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1f0c8a567..16fe0e2b6 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -10,81 +10,29 @@ # # If no versions are provided, the script will: # - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) -# - Fetch NEW version from latest NetBird GitHub release tag +# - Fetch NEW version from the latest stable NetBird release tag on GitHub +# +# Prerelease versions (canary, rc, ...) are rejected: the port only tracks +# stable releases. set -e -GITHUB_REPO="netbirdio/netbird" -PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" - -fetch_current_ports_version() { - echo "Fetching current version from FreeBSD ports..." >&2 - local makefile_content - makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" - if [[ "$makefile_content" == \<* ]]; then - echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 - return 1 - fi - if [[ -z "$makefile_content" ]]; then - echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 - return 1 - fi - echo "$makefile_content" | grep -E "^DISTVERSION=" | sed 's/DISTVERSION=[[:space:]]*//' | tr -d '\t ' - return 0 -} - -fetch_all_tags() { - # Fetch tags from GitHub tags page (no rate limiting, no auth needed) - curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ - grep -iv 'rc' | \ - sed 's/.*\/v//' | \ - sort -u -V - return 0 -} - -fetch_latest_github_release() { - echo "Fetching latest release from GitHub..." >&2 - local latest - - # Fetch from GitHub tags page - latest=$(fetch_all_tags | tail -1) - - if [[ -z "$latest" ]]; then - # Fallback to GitHub API - latest=$(curl -sL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | \ - grep '"tag_name"' | sed 's/.*"tag_name": *"v\([^"]*\)".*/\1/') - fi - - if [[ -z "$latest" ]]; then - echo "Error: Could not fetch latest release from GitHub" >&2 - return 1 - fi - echo "$latest" - return 0 -} +# shellcheck source=release_files/freebsd-port-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/freebsd-port-lib.sh" OLD_VERSION="${1:-}" NEW_VERSION="${2:-}" if [[ -z "$OLD_VERSION" ]]; then OLD_VERSION=$(fetch_current_ports_version) - if [[ -z "$OLD_VERSION" ]]; then - echo "Error: Could not determine old version. Please provide it manually." >&2 - echo "Usage: $0 " >&2 - exit 1 - fi echo "Detected OLD version from FreeBSD ports: $OLD_VERSION" >&2 fi if [[ -z "$NEW_VERSION" ]]; then - NEW_VERSION=$(fetch_latest_github_release) - if [[ -z "$NEW_VERSION" ]]; then - echo "Error: Could not determine new version. Please provide it manually." >&2 - echo "Usage: $0 " >&2 - exit 1 - fi + NEW_VERSION=$(resolve_target_version "") echo "Detected NEW version from GitHub: $NEW_VERSION" >&2 +else + NEW_VERSION=$(resolve_target_version "$NEW_VERSION") fi if [[ "$OLD_VERSION" = "$NEW_VERSION" ]]; then @@ -98,8 +46,11 @@ OUTPUT_DIR="${OUTPUT_DIR:-.}" fetch_releases_between_versions() { echo "Fetching release history from GitHub..." >&2 - # Fetch all tags and filter to those between OLD and NEW versions - fetch_all_tags | \ + local tags + tags=$(fetch_stable_tags) || return 1 + + # List the stable releases after OLD up to and including NEW + echo "$tags" | \ while read -r ver; do if [[ "$(printf '%s\n' "$OLD_VERSION" "$ver" | sort -V | head -n1)" = "$OLD_VERSION" ]] && \ [[ "$(printf '%s\n' "$ver" "$NEW_VERSION" | sort -V | head -n1)" = "$ver" ]] && \ @@ -112,7 +63,7 @@ fetch_releases_between_versions() { generate_changelog_section() { local releases - releases=$(fetch_releases_between_versions) + releases=$(fetch_releases_between_versions) || return 1 echo "Changelogs:" if [[ -n "$releases" ]]; then @@ -127,7 +78,10 @@ generate_changelog_section() { OUTPUT_FILE="${OUTPUT_DIR}/netbird-${NEW_VERSION}-issue.txt" -cat << EOF > "$OUTPUT_FILE" +# Built before the heredoc: a failing substitution inside it would not stop the script. +CHANGELOG=$(generate_changelog_section) + +cat << EOT > "$OUTPUT_FILE" BUGZILLA ISSUE DETAILS ====================== @@ -139,11 +93,11 @@ Description: ------------ security/netbird: Update ${OLD_VERSION} => ${NEW_VERSION} -$(generate_changelog_section) +${CHANGELOG} Commit log: https://github.com/${GITHUB_REPO}/compare/v${OLD_VERSION}...v${NEW_VERSION} -EOF +EOT echo "=========================================" echo "Issue body saved to: ${OUTPUT_FILE}" diff --git a/release_files/freebsd-port-lib.sh b/release_files/freebsd-port-lib.sh new file mode 100644 index 000000000..20102be54 --- /dev/null +++ b/release_files/freebsd-port-lib.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# +# Shared helpers for the FreeBSD port scripts. Source this file, do not run it. +# +# The FreeBSD port tracks stable NetBird releases only, so every helper here +# works on plain MAJOR.MINOR.PATCH versions and ignores canary, release +# candidate and any other prerelease tag. + +GITHUB_REPO="netbirdio/netbird" +GITHUB_REPO_URL="https://github.com/${GITHUB_REPO}.git" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" +STABLE_VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+$' + +# is_stable_version reports whether the version is a plain MAJOR.MINOR.PATCH release. +is_stable_version() { + local version="$1" + if [[ "$version" =~ $STABLE_VERSION_RE ]]; then + return 0 + fi + return 1 +} + +# fetch_stable_tags prints the stable release versions known to GitHub, oldest +# first. git ls-remote returns every tag in one round trip, with no API +# pagination or rate limit to work around. +fetch_stable_tags() { + local refs + if ! refs=$(git ls-remote --tags --refs "$GITHUB_REPO_URL" 2>/dev/null); then + echo "Error: Could not list tags of ${GITHUB_REPO_URL}" >&2 + return 1 + fi + + echo "$refs" | \ + sed -nE 's#.*refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$#\1#p' | \ + sort -u -V + return 0 +} + +# resolve_target_version validates an explicitly requested version, or picks the +# latest stable release from GitHub when none was given. A leading "v" is +# accepted so a git tag name can be passed as is. +resolve_target_version() { + local raw="$1" + local requested="${raw#v}" + + if [[ -n "$raw" ]]; then + if ! is_stable_version "$requested"; then + echo "Error: '${raw}' is not a stable release. The FreeBSD port only tracks MAJOR.MINOR.PATCH versions." >&2 + return 1 + fi + echo "$requested" + return 0 + fi + + echo "Fetching latest stable release from GitHub..." >&2 + local latest + latest=$(fetch_stable_tags | tail -1) + if [[ -z "$latest" ]]; then + echo "Error: Could not determine the latest stable release from GitHub" >&2 + return 1 + fi + echo "$latest" + return 0 +} + +# fetch_ports_file prints a file from the security/netbird port on the FreeBSD +# ports GitHub mirror. +fetch_ports_file() { + local filename="$1" + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" + return 0 +} + +# fetch_current_ports_version prints the DISTVERSION currently in the ports tree. +fetch_current_ports_version() { + echo "Fetching current version from FreeBSD ports..." >&2 + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ + grep -E "^DISTVERSION=" | \ + sed 's/DISTVERSION=[[:space:]]*//' | \ + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" + return 0 +} From 85db35194ef437bc37e6f3cfe561378582c6c994 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:53:55 +0200 Subject: [PATCH 63/70] [client] Prolong HA routes on the current routing peer across relay and direct transitions (#8192) * [client] Do not move an HA route on latency before both peers have a sample A direct connection reports no latency until the first sample of its new ICE agent, and a drop to relay clears the previous one. Route selection ranked such a peer at unknownLatency, so a routing peer whose direct connection came back within the relayed switch delay lost the route to an equivalent peer on a "lower latency" switch right after recovering, resetting every masqueraded flow the delay was meant to keep alive. The sample usually lands before the evaluation, but nothing orders the two, and a pair with no round trip time yet postpones it to the next sampling tick. Latency-only switches now require a sample on both peers. Categorical criteria (connected, metric, direct versus relayed) are unaffected. * [client] Hold a never-direct HA routing peer before a direct peer takes over The peers of an HA group come up over relay first and turn direct one after the other, as when lazy connections wake the group or the client reconnects. A routing peer that was relayed when it carried the route gave way at once to the first equivalent peer that turned direct, so in a group of N peers the route moved right after the session started in roughly (N-1)/N of the cases, resetting every masqueraded flow, typically an SSH session opened on the wake-up traffic. Such a peer is now kept for the relayedSwitch delay, counted from the first evaluation that finds a direct contender. If it turns direct within the delay the route stays; otherwise it moves when the delay runs out, so the route does not stay on a relay for longer than the delay. A peer that dropped from direct keeps counting from the drop. * [client] Keep allowed IPs when only the chosen routing peer's status changes recalculateRoutes did not tell a status change of the chosen routing peer (idle to connected when traffic wakes it, connected to relayed and back) from a switch to another peer: it removed the allowed IPs and added them back on the same peer. In between the prefix had no owner and packets for it were dropped, and default routes published a spurious "disconnected due to high availability change" event followed by two "connected" ones. When the same route stays on the same peer with the same definition, only the recorded status is updated now. * [client] Limit the "never" relayed switch policy to peers that dropped from direct NB_HA_RELAYED_SWITCH_DELAY=never keeps a routing peer whose direct connection dropped to relay for as long as it stays connected. Once the relayed delay also covered peers that were never direct, "never" pinned those to the relay for good, although a direct equivalent was available and the peer showed no sign of turning direct. A peer that was never direct now uses the default delay under "never", which keeps the policy's original scope. * [client] Merge the unchanged-route checks of the HA route watcher shouldSkipRecalculation and the status-only fast path in recalculateRoutes both handled the same route staying on the same routing peer, one for an unchanged status and one for a changed one. They are now a single keepChosenRoute check. Pure refactor, no behavior change. * [client] Re-apply routed allowed IPs when a kept routing peer connects A peer update can recreate a routing peer: modifyPeers removes it from the interface, which drops its routed prefixes, and adds it back idle with its overlay addresses only. The remove and re-add that recalculateRoutes used to run on every status change of the chosen peer put the prefixes back as a side effect once the peer connected. Without it, a watcher that sees the same peer go idle and then connected leaves the routed subnet without an owner until a route update or an HA switch. When the chosen peer connects while keeping the route, the watcher now re-applies its routed allowed IPs through the route manager's reconcile, which is add-only and idempotent. Lazy connections already do the same when they arm an idle peer. * [client] Bound the HA route hold on a routing peer without a latency sample A latency-only switch waited for a sample on both peers. If the current peer never got one, because its candidate pair stats kept failing or reporting no round trip time, it kept the route indefinitely against a measured peer, however much faster that one was. The hold on an unsampled current peer now lasts unmeasuredLatencyHold (30s), long enough for a recovered direct connection to report its first sample. After that the peer is ranked at unknownLatency again and the usual switch margins apply. relayedHold is armed so the switch is reconsidered when the hold runs out. * [client] Restart the relayed delay of a never-direct peer when its contender goes For a routing peer that was never direct, the relayed delay runs from the first evaluation that finds a direct contender. It was only reset when the current peer changed or turned direct, so if that contender went away and another one showed up later, the delay counted from the first one and the new contender took over at once. The delay of such a peer now restarts once no direct contender is left. The delay of a peer that dropped from direct keeps running from the drop. * [client] Retrigger pull request synchronization * [client] Retry a failed routed allowed IPs reconcile on the next evaluation keepChosenRoute recorded the connected status before re-applying the routed allowed IPs and only logged a failed reconcile. Later evaluations then saw an unchanged status and never retried, so a transient AddAllowedIP failure left the recreated routing peer without its routed prefixes until another status transition or a route switch. The status is now recorded only once the reconcile succeeds. A failure leaves the previous status in place, so the next evaluation, at the latest the periodic re-evaluation, sees the change again and retries. --- client/internal/routemanager/client/client.go | 171 ++++++++++++--- .../routemanager/client/client_test.go | 82 +++++++ client/internal/routemanager/client/env.go | 9 +- .../client/relayed_switch_test.go | 204 +++++++++++++++--- client/internal/routemanager/manager.go | 26 +-- 5 files changed, 421 insertions(+), 71 deletions(-) diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index b882c4d4a..66c2ed491 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -65,6 +65,12 @@ const ( // well above any usable path so that slow paths keep their order. unknownLatency = 10 * time.Second + // unmeasuredLatencyHold is how long a current routing peer without a + // latency sample is kept against a measured one. A direct connection is + // sampled within seconds, so a peer still unsampled after this is ranked + // at unknownLatency rather than held indefinitely. + unmeasuredLatencyHold = 30 * time.Second + // holdReportInterval is how long a routing peer held back by a switch rule // is reported at debug level only, after being reported at info level once. holdReportInterval = 10 * time.Minute @@ -99,6 +105,7 @@ type holdKind int const ( holdLatencyMargin holdKind = iota holdLatencyDwell + holdLatencyUnmeasured holdRelayedDelay ) @@ -124,6 +131,9 @@ type routeCandidate struct { relayed bool latency time.Duration noise time.Duration + // measured is false while the peer has no latency sample, as right after a + // direct connection comes up: latency then holds unknownLatency + measured bool } func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { @@ -140,6 +150,7 @@ func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { relayed: status.relayed, latency: latency, noise: status.noise, + measured: status.latency > 0, } } @@ -230,6 +241,9 @@ type WatcherConfig struct { StatusRecorder *peer.Status Route *route.Route Handler RouteHandler + // ReconcileAllowedIPs re-applies the routed allowed IPs tracked for a + // routing peer onto the interface. Optional. + ReconcileAllowedIPs func(peerKey string) error } // Watcher watches route and peer changes and updates allowed IPs accordingly. @@ -271,11 +285,20 @@ type Watcher struct { trackedCurrent route.ID trackedPeer string trackedDirect bool - // degradedSince is when the current routing peer dropped from direct to - // relayed, zero if it is direct or was already relayed when chosen + // degradedSince is when the relayedSwitch delay of the current relayed + // routing peer started: its drop from direct, or the first direct contender + // if it was never direct. Zero while it is direct or uncontended. degradedSince time.Time - // relayedHold fires when a relayed switch being held back becomes due, so it - // is applied on time rather than on the next periodic re-evaluation + // unmeasuredRoute and unmeasuredSince track since when the current route's + // peer has had no latency sample, bounding the hold in shouldSwitchRoute + unmeasuredRoute route.ID + unmeasuredSince time.Time + // neverDirect marks a degradedSince started by a direct contender rather + // than by a drop from direct, which the "never" policy does not cover + neverDirect bool + // relayedHold fires when a switch held back by the relayed delay or the + // unmeasured latency hold becomes due, so it is applied on time rather than + // on the next periodic re-evaluation relayedHold *time.Timer // heldCandidate and heldKind identify the alternative last held back by the // switch rules and the rule holding it, and heldReported is when that was @@ -298,6 +321,9 @@ type Watcher struct { stopped bool handler RouteHandler updateSerial uint64 + // reconcileAllowedIPs re-applies a routing peer's routed allowed IPs when + // it connects while keeping the route, see keepChosenRoute. May be nil. + reconcileAllowedIPs func(peerKey string) error } func newPeerStateUpdate() chan struct{} { @@ -320,6 +346,7 @@ func NewWatcher(config WatcherConfig) *Watcher { handler: config.Handler, currentChosenStatus: nil, relayedSwitch: relayedSwitch(), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } return client } @@ -393,6 +420,8 @@ func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]router } w.trackRelayed(current, haveCurrent) + w.trackUnmeasured(current, haveCurrent) + w.trackContender(current, best, haveCurrent && haveBest) if !haveBest { w.reportUnassigned() @@ -449,9 +478,8 @@ func (w *Watcher) reportUnassigned() { // trackRelayed records when the current routing peer dropped from a direct to a // relayed connection while carrying the route, which starts the relayedSwitch -// delay. A peer that was never direct, because it was relayed when chosen or -// came up over relay from idle, is not degraded: holding on to it would only -// keep the route on the slower path. +// delay at once. A peer that was never direct starts it later, when a direct +// peer first contends for the route, see relayedSwitchDue. func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { if !haveCurrent { w.trackedCurrent, w.trackedPeer, w.trackedDirect, w.degradedSince = "", "", false, time.Time{} @@ -462,7 +490,7 @@ func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { case current.id != w.trackedCurrent, current.peer != w.trackedPeer, !current.relayed: w.degradedSince = time.Time{} case w.trackedDirect: - w.degradedSince = time.Now() + w.degradedSince, w.neverDirect = time.Now(), false // a new degradation, reported as such even if an earlier one held // back the same candidate w.heldCandidate = "" @@ -494,6 +522,20 @@ func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { return true } + // Without a sample on both sides there is no gain to weigh. A direct + // connection reports no latency until its first sample, so comparing it as + // unknownLatency would move the route away from a peer that just recovered. + // The hold is bounded so a peer that never gets a sample does not keep the + // route for good. + if w.unmeasuredHoldActive(current, candidate) { + if level := w.holdLevel(candidate, holdLatencyUnmeasured); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: latency not measured on both peers yet (%s)", + current.peer, w.handler, candidate.peer, candidate) + } + return false + } + gain := current.latency - candidate.latency if margin := switchMargin(current, candidate); gain < margin { if level := w.holdLevel(candidate, holdLatencyMargin); log.IsLevelEnabled(level) { @@ -518,22 +560,75 @@ func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { return true } +// trackContender restarts the relayed delay of a peer that was never direct +// once no direct contender is left. That delay runs from the first contender, +// so keeping it would let a later contender take over without waiting. The +// delay of a peer that dropped from direct keeps running from the drop. +func (w *Watcher) trackContender(current, best routeCandidate, have bool) { + if !w.neverDirect || w.degradedSince.IsZero() { + return + } + if have && best.winsOnlyOnRelay(current) { + return + } + w.degradedSince = time.Time{} +} + +// trackUnmeasured records since when the current route's peer has had no +// latency sample, and forgets it once the peer is measured or the route moves. +func (w *Watcher) trackUnmeasured(current routeCandidate, haveCurrent bool) { + if !haveCurrent || current.measured || current.id != w.unmeasuredRoute { + w.unmeasuredRoute, w.unmeasuredSince = "", time.Time{} + } + if haveCurrent && !current.measured && w.unmeasuredSince.IsZero() { + w.unmeasuredRoute, w.unmeasuredSince = current.id, time.Now() + } +} + +// unmeasuredHoldActive reports whether a latency comparison must wait because +// a side has no sample: always for an unsampled candidate, and for an unsampled +// current peer until unmeasuredLatencyHold runs out. A pending hold arms +// relayedHold so the switch is reconsidered when it runs out. +func (w *Watcher) unmeasuredHoldActive(current, candidate routeCandidate) bool { + if !candidate.measured { + return true + } + if current.measured { + return false + } + + remaining := unmeasuredLatencyHold - time.Since(w.unmeasuredSince) + if remaining <= 0 { + return false + } + w.armRelayedHold(remaining) + + return true +} + // relayedSwitchDue reports whether a relayed current routing peer should give // way to a candidate that is better only by being direct. // -// A peer that dropped from direct to relayed while carrying the route is kept -// for the relayedSwitch delay: the relay still carries the traffic, staying -// keeps every flow through this routing peer alive, and a direct connection that -// dropped often recovers within seconds. A peer that was already relayed when -// chosen gives way at once, so the route is not stuck on the slower path when a -// direct one becomes available. A switch that is not due yet arms relayedHold so -// it is applied as soon as it becomes due. +// A relayed peer is kept for the relayedSwitch delay: the relay still carries +// the traffic, and staying keeps every flow through this routing peer alive. +// For a peer that dropped from direct to relayed the delay runs from the drop, +// since a direct connection that dropped often recovers within seconds. For a +// peer that was never direct, as when the peers of a group come up over relay +// before their direct connections, it runs from the first evaluation that +// finds a direct contender: the current peer usually turns direct within +// seconds too. A switch that is not due yet arms relayedHold so it is applied +// as soon as it becomes due. func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { if w.degradedSince.IsZero() { - return true + w.degradedSince, w.neverDirect = time.Now(), true } policy := w.relayedSwitch + // "never" keeps a peer whose direct connection dropped; one that was never + // direct still moves after the default delay instead of staying relayed + if policy.never && w.neverDirect { + policy = relayedSwitchPolicy{delay: defaultRelayedSwitchDelay} + } if !policy.never { remaining := policy.delay - time.Since(w.degradedSince) if remaining <= 0 { @@ -544,7 +639,7 @@ func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { if level := w.holdLevel(candidate, holdRelayedDelay); log.IsLevelEnabled(level) { log.StandardLogger().Logf(level, - "keeping relayed routing peer %s for [%v] over direct %s: dropped to relay %v ago, switch delay %s", + "keeping relayed routing peer %s for [%v] over direct %s: held for %v, switch delay %s", current.peer, w.handler, candidate.peer, time.Since(w.degradedSince).Round(time.Second), policy) } @@ -677,21 +772,46 @@ func (w *Watcher) removeAllowedIPs(route *route.Route, rsn reason) error { return nil } -// shouldSkipRecalculation checks if we can skip route recalculation for the same route without status changes -func (w *Watcher) shouldSkipRecalculation(newChosenID route.ID, newStatus routerPeerStatus) bool { - if w.currentChosen == nil { +// keepChosenRoute reports whether the evaluation keeps the current route, with +// the same definition, on the same routing peer. A status change of that peer +// is recorded without removing and re-adding its allowed IPs, which would leave +// the prefix without an owner in between. When the peer connects, its routed +// allowed IPs are re-applied instead: a peer recreated by a peer update comes +// back with its overlay addresses only. +func (w *Watcher) keepChosenRoute(newChosenID route.ID, newStatus routerPeerStatus) bool { + if w.currentChosen == nil || w.currentChosen.ID != newChosenID { return false } - isSameRoute := w.currentChosen.ID == newChosenID && w.currentChosen.Equal(w.routes[newChosenID]) - if !isSameRoute { + newChosenRoute := w.routes[newChosenID] + if !w.currentChosen.Equal(newChosenRoute) { return false } - if w.currentChosenStatus != nil { - return w.currentChosenStatus.status == newStatus.status + if w.currentChosenStatus == nil || w.currentChosenStatus.status == newStatus.status { + return true } + // a failed reconcile leaves the previous status recorded, so the next + // evaluation sees the change again and retries + if newStatus.status == peer.StatusConnected && !w.reconcileRoutedAllowedIPs(newChosenRoute.Peer) { + return true + } + + w.currentChosen = newChosenRoute + w.currentChosenStatus = &newStatus + + return true +} + +func (w *Watcher) reconcileRoutedAllowedIPs(peerKey string) bool { + if w.reconcileAllowedIPs == nil { + return true + } + if err := w.reconcileAllowedIPs(peerKey); err != nil { + log.Warnf("failed to reconcile routed allowed IPs of routing peer %s for [%v], will retry: %v", peerKey, w.handler, err) + return false + } return true } @@ -719,8 +839,7 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] return nil } - // If we can skip recalculation for the same route without changes, do nothing - if w.shouldSkipRecalculation(newChosenID, newStatus) { + if w.keepChosenRoute(newChosenID, newStatus) { return nil } diff --git a/client/internal/routemanager/client/client_test.go b/client/internal/routemanager/client/client_test.go index 764a2f74d..641383e90 100644 --- a/client/internal/routemanager/client/client_test.go +++ b/client/internal/routemanager/client/client_test.go @@ -2,6 +2,7 @@ package client import ( "context" + "errors" "fmt" "net/netip" "sync" @@ -1480,3 +1481,84 @@ func TestLatencyJitterDoesNotSwitchRoute(t *testing.T) { require.Equal(t, route.ID("route1"), chosen, "route changed on jitter at sample %d", i) } } + +// A routing peer that keeps the route while only its connection status changes, +// as when an idle peer is woken by traffic, must keep its allowed IPs in place: +// removing and re-adding them leaves the prefix without an owner in between. +func TestRecalculateRoutesStatusChangeKeepsAllowedIPs(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + })) + assert.Equal(t, []string{"peer1"}, handler.added, "a status change must not re-add the allowed IPs") + assert.Equal(t, 0, handler.removed, "a status change must not remove the allowed IPs") + require.NotNil(t, w.currentChosenStatus) + assert.Equal(t, peer.StatusConnected, w.currentChosenStatus.status, "the new status must be recorded") +} + +// A routing peer recreated by a peer update comes back idle with its overlay +// addresses only. When it connects while keeping the route, its routed allowed +// IPs must be re-applied, since nothing else puts them back on the new peer. +func TestRecalculateRoutesReconcilesAllowedIPsOnConnect(t *testing.T) { + w, handler := newRecalcWatcher(t) + var reconciled []string + w.reconcileAllowedIPs = func(peerKey string) error { + reconciled = append(reconciled, peerKey) + return nil + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + require.Empty(t, reconciled, "a fresh assignment adds the allowed IPs itself") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + assert.Empty(t, reconciled, "going idle must not reconcile") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + assert.Equal(t, []string{"peer1"}, reconciled, "connecting while keeping the route must reconcile its allowed IPs") + assert.Equal(t, []string{"peer1"}, handler.added, "the reconcile must not go through the refcounted add") + assert.Equal(t, 0, handler.removed, "the allowed IPs must not be removed") +} + +// A failed reconcile must be retried: the new status is only recorded once the +// routed allowed IPs are back, so the next evaluation tries again. +func TestRecalculateRoutesRetriesFailedReconcile(t *testing.T) { + w, _ := newRecalcWatcher(t) + calls := 0 + w.reconcileAllowedIPs = func(string) error { + calls++ + if calls == 1 { + return errors.New("add allowed IP: transient failure") + } + return nil + } + + connected := map[route.ID]routerPeerStatus{"route1": {status: peer.StatusConnected}} + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + require.Equal(t, 1, calls, "connecting must reconcile") + assert.Equal(t, peer.StatusIdle, w.currentChosenStatus.status, "a failed reconcile must not record the new status") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + assert.Equal(t, 2, calls, "the next evaluation must retry the reconcile") + assert.Equal(t, peer.StatusConnected, w.currentChosenStatus.status, "a successful reconcile records the status") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + assert.Equal(t, 2, calls, "nothing to retry once the reconcile succeeded") +} diff --git a/client/internal/routemanager/client/env.go b/client/internal/routemanager/client/env.go index 398667cdf..6a7c08a1f 100644 --- a/client/internal/routemanager/client/env.go +++ b/client/internal/routemanager/client/env.go @@ -12,10 +12,11 @@ import ( const ( // envRelayedSwitchDelay sets how long a routing peer that dropped from a // direct to a relayed connection while carrying the route is kept before the - // route moves to an equivalent peer with a direct connection. Takes a - // duration ("30s", "0" to switch at once) or "never" to only leave such a - // peer when it disconnects. A routing peer that was already relayed when - // chosen always gives way to a direct one at once. + // route moves to an equivalent peer with a direct connection. A routing peer + // that was never direct is kept for the same delay from when a direct peer + // first contends for the route. Takes a duration ("30s", "0" to switch at + // once) or "never" to only leave a peer that dropped from direct when it + // disconnects; a peer that was never direct then uses the default delay. envRelayedSwitchDelay = "NB_HA_RELAYED_SWITCH_DELAY" relayedSwitchDelayNever = "never" diff --git a/client/internal/routemanager/client/relayed_switch_test.go b/client/internal/routemanager/client/relayed_switch_test.go index 8f4a67bba..f70dfff50 100644 --- a/client/internal/routemanager/client/relayed_switch_test.go +++ b/client/internal/routemanager/client/relayed_switch_test.go @@ -103,11 +103,11 @@ func TestDegradedRoutingPeerIsReplacedAfterDelay(t *testing.T) { assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") } -// A routing peer that was relayed when it was chosen is not degraded: when an -// equivalent direct peer shows up the route must upgrade at once instead of -// staying on the slower path for the delay. -func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { - w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) +// A routing peer that was relayed when it was chosen is held for the delay when +// an equivalent direct peer shows up, from that moment on: it usually turns +// direct within seconds, and moving the route resets its masqueraded flows. +func TestRelayedRoutingPeerIsKeptDuringDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) // route1 was chosen while relayed and route2 was still connecting chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ @@ -115,36 +115,57 @@ func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { "route2": {status: peer.StatusConnecting}, }) require.Equal(t, route.ID("route1"), chosen) - require.True(t, w.degradedSince.IsZero(), "a peer relayed from the start is not degraded") + require.True(t, w.degradedSince.IsZero(), "the delay must not run before a direct peer contends") chosen, _ = w.getBestRouteFromStatuses(currentRelayed) - assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was relayed all along") - assert.Equal(t, switchReasonDirect, w.switchReason) - assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") + assert.Equal(t, route.ID("route1"), chosen, "a relayed peer must be kept when a direct one shows up") + assert.False(t, w.degradedSince.IsZero(), "the direct contender must start the delay") + assert.NotNil(t, w.relayedHold, "the pending switch must arm a timer so it is applied when due") + + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a peer still relayed after the delay must give way to a direct one") + assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") } -// An idle routing peer woken by traffic whose relay connection comes up before -// the direct one was never direct: it is not degraded, and must give way to an -// equivalent direct peer at once. -func TestIdleRoutingPeerWokenOverRelayUpgradesAtOnce(t *testing.T) { - w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) +// The peers of an HA group woken from idle come up over relay first and turn +// direct one after the other. The route must stay on the peer it was on when +// the other one turns direct first and the current one follows within the +// delay, including before the current peer's first latency sample. +func TestIdleRoutingPeersWokenOverRelayKeepRoute(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) - chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ - "route1": {status: peer.StatusIdle}, - "route2": {status: peer.StatusIdle}, - }) - require.Equal(t, route.ID("route1"), chosen) + steps := []struct { + name string + statuses map[route.ID]routerPeerStatus + }{ + {"both idle", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + "route2": {status: peer.StatusIdle}, + }}, + {"both relayed", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, relayed: true}, + }}, + {"other direct first", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + {"current direct, not sampled", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + {"current direct, sampled", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 38 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + } - chosen, _ = w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ - "route1": {status: peer.StatusConnected, relayed: true}, - "route2": {status: peer.StatusConnecting}, - }) - require.Equal(t, route.ID("route1"), chosen) - require.True(t, w.degradedSince.IsZero(), "waking up over relay is not a drop from direct") - - chosen, _ = w.getBestRouteFromStatuses(currentRelayed) - assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was never direct") - assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") + for _, step := range steps { + chosen, _ := w.getBestRouteFromStatuses(step.statuses) + require.Equal(t, route.ID("route1"), chosen, "step %q must keep the route on route1", step.name) + } + assert.True(t, w.degradedSince.IsZero(), "the delay must end once the current peer is direct") } // A routing peer that drops to relay again, after recovering from an earlier @@ -356,3 +377,128 @@ func TestRoutingPeerSwitchPublishesEvent(t *testing.T) { assert.Equal(t, "No routing peer available", events[1].Message) assert.Equal(t, "peer2", events[1].Metadata["previous_peer"]) } + +// The statuses below follow the status recorder: a drop to relay clears the +// latency, and a recovered direct connection reports no latency until the new +// ICE agent's first sample lands. +var ( + flapDirect = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + flapRelayed = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + flapRecoveredUnsampled = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } +) + +// A routing peer whose direct connection drops to relay and comes back within +// the delay must keep the route, including the window between the recovery and +// the first latency sample of the new connection. +func TestRecoveredRoutingPeerWithoutLatencySampleIsKept(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "a routing peer that just dropped to relay must be kept") + + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + assert.Equal(t, route.ID("route1"), chosen, "a recovered routing peer must not lose the route before its latency is sampled") +} + +// A routing peer that keeps dropping to relay for less than the delay, and +// staying direct for less than the delay in between, must keep the route on +// every cycle: each drop is a new degradation with its own delay. +func TestFlappingRoutingPeerIsKept(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen) + + for cycle := 0; cycle < 3; cycle++ { + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: drop to relay", cycle) + + // relayed for 25s, still within the delay + w.degradedSince = w.degradedSince.Add(-25 * time.Second) + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: relayed within the delay", cycle) + + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: recovered, latency not sampled yet", cycle) + + chosen, _ = w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: recovered and sampled", cycle) + } +} + +// "never" only keeps a routing peer that dropped from direct. A peer that was +// never direct must still move to a direct one after the default delay, so the +// route is not left on a relay for good. +func TestNeverPolicyDoesNotPinNeverDirectPeer(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "a never-direct peer must be held for the default delay") + require.NotNil(t, w.relayedHold, "the default delay must arm the timer") + + w.degradedSince = w.degradedSince.Add(-defaultRelayedSwitchDelay - time.Second) + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "with never, a never-direct peer must still give way after the default delay") +} + +// A current peer that never gets a latency sample must not keep the route +// forever against a measured peer: once the hold runs out it is ranked as +// unknown latency again. +func TestUnmeasuredRoutingPeerHoldIsBounded(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapRecoveredUnsampled) + require.Equal(t, route.ID("route1"), chosen, "an unsampled current peer must be held at first") + require.False(t, w.unmeasuredSince.IsZero(), "the hold must record when it started") + + w.unmeasuredSince = w.unmeasuredSince.Add(-unmeasuredLatencyHold - time.Second) + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + assert.Equal(t, route.ID("route2"), chosen, "a peer still unsampled after the hold must give way to a measured one") + assert.Equal(t, switchReasonLatency, w.switchReason) +} + +// The delay of a peer that was never direct runs from the first direct +// contender. When that contender goes away the delay must start over, so a +// later contender is held for the full delay instead of taking over at once. +func TestNeverDirectDelayRestartsWhenContenderGoes(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + relayedAlone := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + } + chosen, _ := w.getBestRouteFromStatuses(relayedAlone) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "the first direct contender must be held") + require.False(t, w.degradedSince.IsZero()) + + // the first contender was seen longer ago than the delay + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + + chosen, _ = w.getBestRouteFromStatuses(relayedAlone) + require.Equal(t, route.ID("route1"), chosen) + assert.True(t, w.degradedSince.IsZero(), "the delay must start over once no direct contender is left") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route1"), chosen, "a new direct contender must be held for the full delay") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 981b0c987..7d699fbb4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -578,12 +578,13 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { } config := client.WatcherConfig{ - Context: m.ctx, - DNSRouteInterval: m.dnsRouteInterval, - WGInterface: m.wgInterface, - StatusRecorder: m.statusRecorder, - Route: routes[0], - Handler: handler, + Context: m.ctx, + DNSRouteInterval: m.dnsRouteInterval, + WGInterface: m.wgInterface, + StatusRecorder: m.statusRecorder, + Route: routes[0], + Handler: handler, + ReconcileAllowedIPs: m.ReconcilePeerAllowedIPs, } clientNetworkWatcher := client.NewWatcher(config) m.clientNetworks[id] = clientNetworkWatcher @@ -628,12 +629,13 @@ func (m *DefaultManager) updateClientNetworks(updateSerial uint64, networks rout } config := client.WatcherConfig{ - Context: m.ctx, - DNSRouteInterval: m.dnsRouteInterval, - WGInterface: m.wgInterface, - StatusRecorder: m.statusRecorder, - Route: routes[0], - Handler: handler, + Context: m.ctx, + DNSRouteInterval: m.dnsRouteInterval, + WGInterface: m.wgInterface, + StatusRecorder: m.statusRecorder, + Route: routes[0], + Handler: handler, + ReconcileAllowedIPs: m.ReconcilePeerAllowedIPs, } clientNetworkWatcher = client.NewWatcher(config) m.clientNetworks[id] = clientNetworkWatcher From 9d837c6ae672c93fd23d40dafb615541d4bd6b2e Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:14:26 +0900 Subject: [PATCH 64/70] [client, management] Fix the edge cases left open in the certificate posture check (#8174) --- .github/workflows/release.yml | 2 + .goreleaser.yaml | 32 ++++- client/internal/certproof/collect.go | 14 ++- client/internal/certproof/collect_darwin.go | 8 +- client/internal/certproof/collect_test.go | 18 +-- client/internal/certproof/collect_windows.go | 6 +- .../internal/certproof/consoleuser_darwin.go | 4 +- .../certproof/consoleuser_darwin_test.go | 4 +- .../internal/certproof/desktopuser_windows.go | 12 +- .../certproof/desktopuser_windows_test.go | 8 +- client/internal/certproof/helper_spawn.go | 45 ------- .../internal/certproof/helper_spawn_test.go | 27 ---- client/internal/certproof/helper_test.go | 11 +- client/internal/certproof/keychain_darwin.go | 27 ++-- client/internal/certproof/keystore_test.go | 6 +- client/internal/certproof/merge.go | 50 ++++++++ client/internal/certproof/merge_test.go | 54 ++++++++ client/internal/certproof/pkcs11store.go | 51 ++++---- client/internal/certproof/pkcs11store_test.go | 33 +++-- client/internal/certproof/signattempts.go | 24 ++++ .../internal/certproof/signattempts_test.go | 41 +++++++ client/internal/certproof/store.go | 12 +- client/internal/certproof/store_other.go | 6 +- client/internal/certproof/store_other_test.go | 10 +- client/internal/certproof/store_tpm_test.go | 4 +- .../internal/certproof/storefile_unix_test.go | 2 +- .../internal/certproof/systemstore_windows.go | 22 ++-- client/internal/pkcs11/driver_linux.go | 69 ++++++++--- client/internal/pkcs11/driver_linux_test.go | 116 ++++++++++++++++++ client/internal/pkcs11/pkcs11.go | 1 + client/internal/pkcs11/publickey.go | 8 +- client/internal/pkcs11/publickey_test.go | 16 +++ client/internal/pkcs11/uri.go | 57 ++++++++- client/internal/pkcs11/uri_test.go | 56 +++++++++ .../network_map_db/shared_types_test.go | 22 ++++ .../certposture/certposture_test.go | 21 ++++ shared/management/client/grpc_test.go | 24 ++++ shared/management/http/api/openapi.yml | 1 + 38 files changed, 718 insertions(+), 206 deletions(-) create mode 100644 client/internal/certproof/merge.go create mode 100644 client/internal/certproof/merge_test.go create mode 100644 client/internal/certproof/signattempts.go create mode 100644 client/internal/certproof/signattempts_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3ed4763c..22ab7f566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -417,6 +417,8 @@ jobs: path: | dist/netbird_linux** dist/netbird-pkcs11_linux** + dist/netbird-deb-32bit_linux** + dist/netbird-rpm-*_linux** retention-days: 7 - name: upload windows packages id: upload_windows_packages diff --git a/.goreleaser.yaml b/.goreleaser.yaml index de5f7419c..41c330a64 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -40,8 +40,9 @@ builds: tags: - load_wgnt_from_rsrc - # deb and rpm packages target glibc distributions, so they carry the PKCS#11 store, which - # links libc. Tarballs and the Alpine-based images keep the static build above. + # deb and rpm packages target glibc distributions, so on amd64 and arm64, the only + # architectures with a PKCS#11 driver, they carry the PKCS#11 store, which links libc. + # Tarballs, the Alpine-based images and the other architectures keep the static build. - id: netbird-pkcs11 dir: client binary: netbird @@ -49,10 +50,8 @@ builds: goos: - linux goarch: - - arm - amd64 - arm64 - - 386 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -60,8 +59,24 @@ builds: - load_wgnt_from_rsrc - pkcs11 - # Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. They - # carry the PKCS#11 store like the deb build above. + # The 32-bit deb architectures, which have no PKCS#11 driver. + - id: netbird-deb-32bit + dir: client + binary: netbird + env: [CGO_ENABLED=0] + goos: + - linux + goarch: + - arm + - 386 + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - load_wgnt_from_rsrc + + # Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. The + # ones with a PKCS#11 driver carry the store like the deb build above. - &netbird_rpm_build id: netbird-rpm-amd64 dir: client @@ -83,10 +98,14 @@ builds: - <<: *netbird_rpm_build id: netbird-rpm-arm goarch: [arm] + tags: + - load_wgnt_from_rsrc - <<: *netbird_rpm_build id: netbird-rpm-386 goarch: [386] + tags: + - load_wgnt_from_rsrc - id: netbird-static dir: client @@ -323,6 +342,7 @@ nfpms: bindir: /usr/bin builds: - netbird-pkcs11 + - netbird-deb-32bit formats: - deb scripts: diff --git a/client/internal/certproof/collect.go b/client/internal/certproof/collect.go index 65affca71..310fd2b19 100644 --- a/client/internal/certproof/collect.go +++ b/client/internal/certproof/collect.go @@ -149,9 +149,19 @@ func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byt } func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte { - buf := make([]byte, 0, len(chain)*sha256.Size) + ders := make([][]byte, 0, len(chain)) for _, cert := range chain { - certHash := sha256.Sum256(cert.Raw) + ders = append(ders, cert.Raw) + } + return chainDERFingerprint(ders) +} + +// chainDERFingerprint identifies a chain of DER certificates by the hashes of all of them, +// in order. +func chainDERFingerprint(chain [][]byte) [sha256.Size]byte { + buf := make([]byte, 0, len(chain)*sha256.Size) + for _, der := range chain { + certHash := sha256.Sum256(der) buf = append(buf, certHash[:]...) } return sha256.Sum256(buf) diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go index 92f0471e7..cc21563c1 100644 --- a/client/internal/certproof/collect_darwin.go +++ b/client/internal/certproof/collect_darwin.go @@ -60,7 +60,7 @@ func UserContext(cfg Config) string { if os.Geteuid() != 0 || cfg.OwnerUnknown { return "" } - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok || !user.isOwner(cfg.ProfileOwner) { return "" } @@ -72,7 +72,7 @@ func UserContext(cfg Config) string { // per-session securityd, so the helper has to enter their Mach bootstrap namespace, // which is what launchctl asuser does. func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok { return nil, nil } @@ -126,12 +126,12 @@ func helperStore() Store { home, err := os.UserHomeDir() if err != nil { log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err) - return NewKeychainStore() + return newKeychainStore() } login := filepath.Join(home, "Library", "Keychains", "login.keychain-db") if _, err := os.Stat(login); err != nil { // Keychains created before macOS 10.12 keep the old file name. login = filepath.Join(home, "Library", "Keychains", "login.keychain") } - return NewKeychainStore(login) + return newKeychainStore(login) } diff --git a/client/internal/certproof/collect_test.go b/client/internal/certproof/collect_test.go index e16ee3668..aa033dea5 100644 --- a/client/internal/certproof/collect_test.go +++ b/client/internal/certproof/collect_test.go @@ -51,7 +51,7 @@ func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) { challenge("not a pem"), } - proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey) + proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey) require.Len(t, proofs, 2) var subjects []string @@ -74,10 +74,10 @@ func TestCollect_NothingToProve(t *testing.T) { store Store checks []*proto.Checks }{ - {"no checks", NewFileStore(dir), nil}, - {"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}}, - {"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}}, - {"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}}, + {"no checks", newFileStore(dir), nil}, + {"files only", newFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}}, + {"challenge without nonce", newFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}}, + {"missing store dir", newFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,7 +95,7 @@ func TestFileStore_ChainWithIntermediate(t *testing.T) { dir := storeDir(t) writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key)) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1) require.Len(t, candidates[0].Chain, 2) @@ -124,7 +124,7 @@ func TestCollectChallenges_RefusesMalformedInput(t *testing.T) { dir := storeDir(t) key := certtest.ECDSAKey(t) writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key)) - store := NewFileStore(dir) + store := newFileStore(dir) nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()) tests := []struct { @@ -159,7 +159,7 @@ func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) { good := ca.Issue(t, goodKey, "good") writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey)) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate") assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept") @@ -167,7 +167,7 @@ func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) { challenger := certposture.NewChallenger([]byte("secret")) now := time.Now() nonce := challenger.Nonce(peerKey, now) - proofs := CollectChallenges(context.Background(), NewFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey) + proofs := CollectChallenges(context.Background(), newFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey) require.Len(t, proofs, 1) _, err = challenger.Verify(proofs[0], peerKey, now) assert.NoError(t, err, "the proof sent is one management accepts") diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go index 8e5695539..3b21ec147 100644 --- a/client/internal/certproof/collect_windows.go +++ b/client/internal/certproof/collect_windows.go @@ -51,7 +51,7 @@ func UserContext(cfg Config) string { if !runningAsLocalSystem() || cfg.OwnerUnknown { return "" } - user, ok := CurrentDesktopUser(cfg.ProfileOwner) + user, ok := currentDesktopUser(cfg.ProfileOwner) if !ok { return "" } @@ -62,14 +62,14 @@ func UserContext(cfg Config) string { // helperStore is the store the helper reads. It runs as the signed-in user, so it wants // that user's store rather than the machine store the service already read. func helperStore() Store { - return NewUserStore() + return newUserStore() } // collectAsDesktopUser runs the helper inside the interactive session of the signed-in // user. Unlike a keychain on macOS, a Windows service can assume a user identity // directly, so the session token goes straight into the child process. func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { - user, ok := CurrentDesktopUser(owner) + user, ok := currentDesktopUser(owner) if !ok { return nil, nil } diff --git a/client/internal/certproof/consoleuser_darwin.go b/client/internal/certproof/consoleuser_darwin.go index a5182c7fd..1045bc014 100644 --- a/client/internal/certproof/consoleuser_darwin.go +++ b/client/internal/certproof/consoleuser_darwin.go @@ -35,10 +35,10 @@ type ConsoleUser struct { GID uint32 } -// CurrentConsoleUser reports the user sitting at the desktop. The second return value is +// currentConsoleUser reports the user sitting at the desktop. The second return value is // false when nobody is: at the login window macOS either reports no console user at all // or attributes the session to root, and neither has a login keychain to offer. -func CurrentConsoleUser() (ConsoleUser, bool) { +func currentConsoleUser() (ConsoleUser, bool) { if err := loadConsoleUser(); err != nil { log.Debugf("console user lookup unavailable: %v", err) return ConsoleUser{}, false diff --git a/client/internal/certproof/consoleuser_darwin_test.go b/client/internal/certproof/consoleuser_darwin_test.go index 9c1aaa22c..051cc4ab0 100644 --- a/client/internal/certproof/consoleuser_darwin_test.go +++ b/client/internal/certproof/consoleuser_darwin_test.go @@ -27,10 +27,10 @@ func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) { } } -// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with +// currentConsoleUser runs against the real SystemConfiguration framework. A machine with // a desktop open must report a non-root user; a headless runner must report none. func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) { - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok { t.Log("no console user, running headless") return diff --git a/client/internal/certproof/desktopuser_windows.go b/client/internal/certproof/desktopuser_windows.go index 6f50aae4f..0a9d63b36 100644 --- a/client/internal/certproof/desktopuser_windows.go +++ b/client/internal/certproof/desktopuser_windows.go @@ -13,12 +13,8 @@ const ( noActiveSession = 0xFFFFFFFF servicesSession = 0 - // wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE; wtsActive and wtsDisconnected are - // WTSActive and WTSDisconnected of WTS_CONNECTSTATE_CLASS. None is exported by - // x/sys/windows. + // wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE, which x/sys/windows does not export. wtsCurrentServer = windows.Handle(0) - wtsActive = 0 - wtsDisconnected = 4 ) // DesktopUser is an interactive session and the account signed into it. The user's @@ -37,7 +33,7 @@ func (u DesktopUser) Close() { } } -// CurrentDesktopUser returns a token for the session whose certificate store should be +// currentDesktopUser returns a token for the session whose certificate store should be // asked: a session of owner, the account the active profile belongs to, with the // physical console preferred over remote sessions. With no owner only the console user // counts. Picking any signed-in user instead would let whoever else is logged in to a @@ -46,7 +42,7 @@ func (u DesktopUser) Close() { // // Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an // ordinary process does not. -func CurrentDesktopUser(owner string) (DesktopUser, bool) { +func currentDesktopUser(owner string) (DesktopUser, bool) { console := windows.WTSGetActiveConsoleSessionId() if owner == "" { return consoleUser(console) @@ -163,7 +159,7 @@ func userSessions(console uint32) ([]uint32, error) { if console != noActiveSession && console != servicesSession { sessions = append(sessions, console) } - for _, state := range []uint32{wtsActive, wtsDisconnected} { + for _, state := range []uint32{windows.WTSActive, windows.WTSDisconnected} { for _, session := range unsafe.Slice(info, count) { if session.SessionID == servicesSession || session.SessionID == console || session.State != state { continue diff --git a/client/internal/certproof/desktopuser_windows_test.go b/client/internal/certproof/desktopuser_windows_test.go index 95d29eb5f..c3c4022e5 100644 --- a/client/internal/certproof/desktopuser_windows_test.go +++ b/client/internal/certproof/desktopuser_windows_test.go @@ -26,10 +26,10 @@ func TestSameAccountName(t *testing.T) { } } -// CurrentDesktopUser against the real session manager: an owner no session belongs to +// currentDesktopUser against the real session manager: an owner no session belongs to // must never yield a session, whoever else is signed in. func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) { - user, ok := CurrentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`) + user, ok := currentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`) if ok { user.Close() } @@ -44,14 +44,14 @@ func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) { if owner == "" { t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem") } - user, ok := CurrentDesktopUser(owner) + user, ok := currentDesktopUser(owner) require.True(t, ok, "the signed-in owner %s must have a session", owner) defer user.Close() t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name) assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name) assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner") - console, consoleOK := CurrentDesktopUser("") + console, consoleOK := currentDesktopUser("") if consoleOK { defer console.Close() t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name) diff --git a/client/internal/certproof/helper_spawn.go b/client/internal/certproof/helper_spawn.go index 968cc3679..54ed265ec 100644 --- a/client/internal/certproof/helper_spawn.go +++ b/client/internal/certproof/helper_spawn.go @@ -3,12 +3,6 @@ package certproof import ( - "crypto/sha256" - "crypto/x509" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/shared/management/certposture" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -22,42 +16,3 @@ func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) Hel } return req } - -// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that -// both stores hold so the same certificate is proven once. -func mergeProofs(device, user []certposture.Proof) []certposture.Proof { - if len(user) == 0 { - return device - } - - seen := make(map[[sha256.Size]byte]struct{}, len(device)) - for _, proof := range device { - if len(proof.Chain) > 0 { - seen[sha256.Sum256(proof.Chain[0])] = struct{}{} - } - } - - merged := device - for _, proof := range user { - if len(proof.Chain) == 0 { - continue - } - fingerprint := sha256.Sum256(proof.Chain[0]) - if _, done := seen[fingerprint]; done { - continue - } - seen[fingerprint] = struct{}{} - merged = append(merged, proof) - logUserProof(proof) - } - return merged -} - -func logUserProof(proof certposture.Proof) { - leaf, err := x509.ParseCertificate(proof.Chain[0]) - if err != nil { - log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err) - return - } - log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer) -} diff --git a/client/internal/certproof/helper_spawn_test.go b/client/internal/certproof/helper_spawn_test.go index 338a5f66a..74a792a49 100644 --- a/client/internal/certproof/helper_spawn_test.go +++ b/client/internal/certproof/helper_spawn_test.go @@ -3,42 +3,15 @@ package certproof import ( - "crypto/sha256" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/shared/management/certposture" "github.com/netbirdio/netbird/shared/management/certposture/certtest" "github.com/netbirdio/netbird/shared/management/proto" ) -func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) { - ca := certtest.NewCA(t, "corp-root") - shared := ca.Issue(t, certtest.ECDSAKey(t), "shared") - userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only") - - device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}} - user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}} - - merged := mergeProofs(device, user) - - require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped") - assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place") - assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended") -} - -func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) { - ca := certtest.NewCA(t, "corp-root") - device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}} - - merged := mergeProofs(device, nil) - - require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof") - assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged") -} - func TestHelperRequest_CarriesEveryChallenge(t *testing.T) { ca := certtest.NewCA(t, "corp-root") challenges := []*proto.CertificateChallenge{ diff --git a/client/internal/certproof/helper_test.go b/client/internal/certproof/helper_test.go index 37dadead2..61fcc8532 100644 --- a/client/internal/certproof/helper_test.go +++ b/client/internal/certproof/helper_test.go @@ -14,7 +14,10 @@ import ( "github.com/netbirdio/netbird/shared/management/certposture/certtest" ) -func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) { +// TestRunHelper_AnswersAChallengeFromItsStore runs the helper's request and response +// handling in-process: the proof it writes verifies as the daemon will verify it. The +// process boundary itself is covered by the runHelperCmd tests. +func TestRunHelper_AnswersAChallengeFromItsStore(t *testing.T) { ca := certtest.NewCA(t, "corp-root") dir := storeDir(t) key := certtest.ECDSAKey(t) @@ -29,7 +32,7 @@ func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) { require.NoError(t, err, "request must encode") var stdout bytes.Buffer - require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout)) + require.NoError(t, runHelper(context.Background(), newFileStore(dir), bytes.NewReader(request), &stdout)) var resp HelperResponse require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON") @@ -47,7 +50,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) { require.NoError(t, err) var stdout bytes.Buffer - require.NoError(t, runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader(request), &stdout)) + require.NoError(t, runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader(request), &stdout)) var resp HelperResponse require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON") @@ -56,7 +59,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) { func TestRunHelper_RejectsMalformedRequest(t *testing.T) { var stdout bytes.Buffer - err := runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout) + err := runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout) require.Error(t, err, "a malformed request must fail rather than emit an empty proof set") assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure") diff --git a/client/internal/certproof/keychain_darwin.go b/client/internal/certproof/keychain_darwin.go index 2bffdc81e..5b1426997 100644 --- a/client/internal/certproof/keychain_darwin.go +++ b/client/internal/certproof/keychain_darwin.go @@ -71,25 +71,25 @@ const systemKeychain = "/Library/Keychains/System.keychain" // by an ordinary user, it is that user's keychain search list. func DefaultStore() Store { if os.Geteuid() == 0 { - return NewKeychainStore(systemKeychain) + return newKeychainStore(systemKeychain) } - return NewKeychainStore() + return newKeychainStore() } -// KeychainStore yields the identities of a set of keychains, or of the process's keychain +// keychainStore yields the identities of a set of keychains, or of the process's keychain // search list when none is named, reached through purego so the client keeps building // with CGO_ENABLED=0. -type KeychainStore struct { +type keychainStore struct { keychains []string } -// NewKeychainStore returns a store that searches the keychain files at paths, or the +// newKeychainStore returns a store that searches the keychain files at paths, or the // process's keychain search list when no path is given. -func NewKeychainStore(paths ...string) *KeychainStore { - return &KeychainStore{keychains: paths} +func newKeychainStore(paths ...string) *keychainStore { + return &keychainStore{keychains: paths} } -func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *keychainStore) Candidates(_ context.Context) ([]Candidate, error) { if err := loadKeychain(); err != nil { return nil, err } @@ -163,17 +163,24 @@ func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts algorithm := keychainAlgorithm(scheme) var signature []byte + var attempts signAttempts err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) { if !bytes.Equal(der, s.leaf.Raw) { return false, nil } - signature, err = signWithIdentity(identity, algorithm, digest) - return true, err + // The same certificate can sit in the keychain with more than one key, of which + // only one may be usable, so a failure moves on to the next match. + var ok bool + signature, ok = attempts.try(func() ([]byte, error) { return signWithIdentity(identity, algorithm, digest) }) + return ok, nil }) if err != nil { return nil, err } if signature == nil { + if err := attempts.err(); err != nil { + return nil, err + } return nil, errors.New("certificate is no longer in the keychain") } log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature)) diff --git a/client/internal/certproof/keystore_test.go b/client/internal/certproof/keystore_test.go index 592d7414f..7d028c4fb 100644 --- a/client/internal/certproof/keystore_test.go +++ b/client/internal/certproof/keystore_test.go @@ -56,6 +56,8 @@ func TestSchemeFor(t *testing.T) { {"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384}, {"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256}, {"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0}, + {"ecdsa sha512", ecKey.Public(), crypto.SHA512, 0}, + {"rsa pss sha384", rsaKey.Public(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA384}, 0}, {"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0}, } for _, tc := range tests { @@ -83,5 +85,7 @@ func TestECDSASignatureASN1(t *testing.T) { assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der)) _, err = ecdsaSignatureASN1(raw[:63]) - assert.Error(t, err) + assert.Error(t, err, "an odd length cannot split into r and s") + _, err = ecdsaSignatureASN1(nil) + assert.Error(t, err, "an empty signature is malformed") } diff --git a/client/internal/certproof/merge.go b/client/internal/certproof/merge.go new file mode 100644 index 000000000..d539ce927 --- /dev/null +++ b/client/internal/certproof/merge.go @@ -0,0 +1,50 @@ +package certproof + +import ( + "crypto/sha256" + "crypto/x509" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/shared/management/certposture" +) + +// mergeProofs appends the user session proofs to the device proofs, dropping one whose +// chain the device already proved. Management checks each chain against each check's CAs, +// so the same leaf proven through another chain is kept. +func mergeProofs(device, user []certposture.Proof) []certposture.Proof { + if len(user) == 0 { + return device + } + + seen := make(map[[sha256.Size]byte]struct{}, len(device)+len(user)) + for _, proof := range device { + if len(proof.Chain) > 0 { + seen[chainDERFingerprint(proof.Chain)] = struct{}{} + } + } + + merged := device + for _, proof := range user { + if len(proof.Chain) == 0 { + continue + } + fingerprint := chainDERFingerprint(proof.Chain) + if _, done := seen[fingerprint]; done { + continue + } + seen[fingerprint] = struct{}{} + merged = append(merged, proof) + logUserProof(proof) + } + return merged +} + +func logUserProof(proof certposture.Proof) { + leaf, err := x509.ParseCertificate(proof.Chain[0]) + if err != nil { + log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err) + return + } + log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer) +} diff --git a/client/internal/certproof/merge_test.go b/client/internal/certproof/merge_test.go new file mode 100644 index 000000000..7db2dd421 --- /dev/null +++ b/client/internal/certproof/merge_test.go @@ -0,0 +1,54 @@ +package certproof + +import ( + "crypto/sha256" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/certposture" + "github.com/netbirdio/netbird/shared/management/certposture/certtest" +) + +func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + shared := ca.Issue(t, certtest.ECDSAKey(t), "shared") + userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only") + + device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}} + user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}} + + merged := mergeProofs(device, user) + + require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped") + assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place") + assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended") +} + +func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}} + + merged := mergeProofs(device, nil) + + require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof") + assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged") +} + +func TestMergeProofs_KeepsAnotherChainOfTheSameLeaf(t *testing.T) { + root := certtest.NewCA(t, "corp-root") + interA := root.Issue(t, certtest.ECDSAKey(t), "inter-a") + interB := root.Issue(t, certtest.ECDSAKey(t), "inter-b") + leaf := root.Issue(t, certtest.ECDSAKey(t), "leaf") + + // Management checks each chain against each check's CAs, so the same leaf proven + // through another chain is a different proof, not a duplicate. + device := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interA.Raw}}} + user := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interB.Raw}}, {Chain: [][]byte{leaf.Raw, interA.Raw}}} + + merged := mergeProofs(device, user) + + require.Len(t, merged, 2, "the chain only the user store holds is kept, the one both hold is not repeated") + assert.Equal(t, interB.Raw, merged[1].Chain[1], "the user's chain through the other intermediate is appended") +} diff --git a/client/internal/certproof/pkcs11store.go b/client/internal/certproof/pkcs11store.go index 1f88e7600..381f55799 100644 --- a/client/internal/certproof/pkcs11store.go +++ b/client/internal/certproof/pkcs11store.go @@ -38,24 +38,24 @@ func PKCS11FromEnv() PKCS11Config { return PKCS11Config{URI: os.Getenv(PKCS11URIEnv), PIN: os.Getenv(PINEnv)} } -// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes +// pkcs11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes // TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the // convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM // directory by public key. Every signature happens on the token. -type PKCS11Store struct { +type pkcs11Store struct { uri *pkcs11.URI pin string certDir string } -// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in +// newPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in // certDir without a key of their own are paired with the token's keys by public key. // // A PIN is only accepted together with a token label: without one the PIN would go to // whichever token the module lists first, which on a machine with a smartcard plugged // in may be the card, and every wrong PIN counts towards that card's lockout. -func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) { - store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir} +func newPKCS11Store(cfg PKCS11Config, certDir string) (*pkcs11Store, error) { + store := &pkcs11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir} if cfg.URI != "" { parsed, err := pkcs11.ParseURI(cfg.URI) if err != nil { @@ -69,7 +69,7 @@ func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) { return store, nil } -func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { +func (s *pkcs11Store) Candidates(_ context.Context) ([]Candidate, error) { session, err := s.open() if err != nil { return nil, err @@ -80,7 +80,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { if err != nil { return nil, err } - fileChains, err := s.fileChains() + fileChains, fileIntermediates, err := s.fileChains() if err != nil { return nil, err } @@ -93,6 +93,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { for _, chain := range fileChains { pool = append(pool, chain...) } + pool = append(pool, fileIntermediates...) var candidates []Candidate for _, cert := range certs { @@ -106,9 +107,12 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { return candidates, nil } + // A token that cannot list its public keys still answers for the certificates it + // holds, so the identities found above are kept. keys, err := tokenPublicKeys(session) if err != nil { - return nil, err + log.Warnf("%s: cannot read its public keys, certificate files are not paired with them: %v", s, err) + return candidates, nil } for _, chain := range fileChains { leaf := chain[0] @@ -122,41 +126,45 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { return candidates, nil } -func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate { +func (s *pkcs11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate { chain := buildChain(leaf, pool) log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain)) return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool} } // fileChains reads the certificate files in the PEM directory that carry no key of their -// own; the file store answers for the ones that do. -func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) { +// own; the file store answers for the ones that do. The certificates of a file that does +// carry a key are returned too, since they can complete the chain of a token certificate. +func (s *pkcs11Store) fileChains() (chains [][]*x509.Certificate, intermediates []*x509.Certificate, err error) { if s.certDir == "" { - return nil, nil + return nil, nil, nil } paths, err := certFiles(s.certDir) if err != nil { - return nil, err + return nil, nil, err } - var chains [][]*x509.Certificate for _, path := range paths { chain, signer, err := loadPEM(path) - if err != nil || signer != nil { + if err != nil { + continue + } + if signer != nil { + intermediates = append(intermediates, chain...) continue } chains = append(chains, chain) } - return chains, nil + return chains, intermediates, nil } -func (s *PKCS11Store) String() string { +func (s *pkcs11Store) String() string { if s.uri.Token == "" { return "PKCS#11 token" } return fmt.Sprintf("PKCS#11 token %q", s.uri.Token) } -func (s *PKCS11Store) open() (*pkcs11.Session, error) { +func (s *pkcs11Store) open() (*pkcs11.Session, error) { module, err := pkcs11.Load(s.uri.Module()) if err != nil { return nil, err @@ -185,7 +193,7 @@ func (s *PKCS11Store) open() (*pkcs11.Session, error) { return session, err } -func (s *PKCS11Store) userPIN() ([]byte, error) { +func (s *pkcs11Store) userPIN() ([]byte, error) { if s.pin != "" { return []byte(s.pin), nil } @@ -208,7 +216,8 @@ func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) { for _, object := range objects { id, err := session.Attribute(object, pkcs11.AttrID) if err != nil { - return nil, err + log.Debugf("skipping public key on PKCS#11 token: %v", err) + continue } public, err := session.PublicKey(object) if err != nil { @@ -287,7 +296,7 @@ func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) { // pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh // session at signing time so no token handle outlives a call. type pkcs11Signer struct { - store *PKCS11Store + store *pkcs11Store leaf *x509.Certificate id []byte } diff --git a/client/internal/certproof/pkcs11store_test.go b/client/internal/certproof/pkcs11store_test.go index 485c84aae..746b54918 100644 --- a/client/internal/certproof/pkcs11store_test.go +++ b/client/internal/certproof/pkcs11store_test.go @@ -44,7 +44,7 @@ func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) { dir := storeDir(t) writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key)) - candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background()) + candidates, err := Stores{failingStore{}, newFileStore(dir)}.Candidates(context.Background()) require.NoError(t, err) assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token") } @@ -241,13 +241,13 @@ func attr(typ uint, value []byte) pkcs11.Attribute { // pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when // no token is configured or this build lacks PKCS#11 support. -func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) { +func pkcs11TestStore(t *testing.T, certDir string) (*pkcs11Store, string) { t.Helper() uri := os.Getenv(testPKCS11URIEnv) if uri == "" { t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv) } - store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir) + store, err := newPKCS11Store(PKCS11Config{URI: uri}, certDir) require.NoError(t, err) if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) { t.Skip(err) @@ -297,17 +297,30 @@ func TestPKCS11Store_FileChains(t *testing.T) { writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed)) writeFile(t, dir, "notes.txt", "not a certificate") - chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains() + // An intermediate that only a keyed file carries still has to reach the pool a token + // certificate's chain is built from. + root := certtest.NewCA(t, "corp-root") + intermediateCA := certtest.NewIntermediate(t, root, "corp-intermediate") + chained := certtest.ECDSAKey(t) + writeFile(t, dir, "chained.pem", certtest.CertPEM(intermediateCA.Issue(t, chained, "chained"))+certtest.CertPEM(intermediateCA.Cert)+certtest.KeyPEM(t, chained)) + + chains, extra, err := (&pkcs11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains() require.NoError(t, err) + require.NotEmpty(t, extra, "the keyed file's certificates are offered for chain building") + tokenLeaf := intermediateCA.Issue(t, certtest.ECDSAKey(t), "token-leaf") + chain := buildChain(tokenLeaf, extra) + require.Len(t, chain, 2, "a token certificate is completed through the intermediate only the keyed file holds") + assert.True(t, intermediateCA.Cert.Equal(chain[1]), "the second certificate is that intermediate") var subjects []string for _, chain := range chains { subjects = append(subjects, chain[0].Subject.CommonName) } assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token") - chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains() + chains, extra, err = (&pkcs11Store{uri: &pkcs11.URI{}}).fileChains() require.NoError(t, err) assert.Empty(t, chains, "no directory configured means no file certificates") + assert.Empty(t, extra, "and no extra intermediates") } func TestNewPKCS11Store_PIN(t *testing.T) { @@ -324,7 +337,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - store, err := NewPKCS11Store(tt.cfg, "") + store, err := newPKCS11Store(tt.cfg, "") require.NoError(t, err) pin, err := store.userPIN() require.NoError(t, err) @@ -333,7 +346,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { }) } - _, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "") + _, err := newPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "") assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults") for name, cfg := range map[string]PKCS11Config{ @@ -342,7 +355,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { "inline pin-value only": {URI: "pkcs11:?pin-value=0000"}, "pin-source only": {URI: "pkcs11:?pin-source=file:/etc/netbird/pkcs11.pin"}, } { - _, err := NewPKCS11Store(cfg, "") + _, err := newPKCS11Store(cfg, "") assert.ErrorIs(t, err, errPINNeedsToken, "%s: a PIN must not go to whichever token is listed first", name) } } @@ -360,7 +373,7 @@ func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) { _, uri := pkcs11TestStore(t, "") wrongPIN := "wrong-pin-" + t.Name() - store, err := NewPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "") + store, err := newPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "") require.NoError(t, err) _, err = store.Candidates(context.Background()) @@ -370,7 +383,7 @@ func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) { _, err = store.Candidates(context.Background()) assert.ErrorIs(t, err, errPINRejectedBefore, "the rejected PIN is not sent to the token again") - good, err := NewPKCS11Store(PKCS11Config{URI: uri}, "") + good, err := newPKCS11Store(PKCS11Config{URI: uri}, "") require.NoError(t, err) _, err = good.Candidates(context.Background()) assert.NoError(t, err, "the correct PIN for the same token is unaffected") diff --git a/client/internal/certproof/signattempts.go b/client/internal/certproof/signattempts.go new file mode 100644 index 000000000..b056991bf --- /dev/null +++ b/client/internal/certproof/signattempts.go @@ -0,0 +1,24 @@ +package certproof + +import "errors" + +// signAttempts tracks the identities tried to sign with, for a certificate that can match +// more than one of them. +type signAttempts struct { + errs []error +} + +// try runs sign and reports its signature and whether it succeeded. A failure is kept. +func (a *signAttempts) try(sign func() ([]byte, error)) ([]byte, bool) { + signature, err := sign() + if err != nil { + a.errs = append(a.errs, err) + return nil, false + } + return signature, true +} + +// err joins the failures of the attempts, nil when there were none. +func (a *signAttempts) err() error { + return errors.Join(a.errs...) +} diff --git a/client/internal/certproof/signattempts_test.go b/client/internal/certproof/signattempts_test.go new file mode 100644 index 000000000..f5a2fef4f --- /dev/null +++ b/client/internal/certproof/signattempts_test.go @@ -0,0 +1,41 @@ +package certproof + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errNeedsApprovalForTest = errors.New("key needs approval") + +// TestSignAttempts_FallsThroughToTheNextIdentity: a certificate can sit in a keychain with +// more than one key, of which only some can sign. A failure moves on, and an identity that +// signs ends the search without reporting the earlier failure. +func TestSignAttempts_FallsThroughToTheNextIdentity(t *testing.T) { + var attempts signAttempts + + signature, ok := attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest }) + assert.False(t, ok, "a failing identity does not end the search") + assert.Nil(t, signature) + + signature, ok = attempts.try(func() ([]byte, error) { return []byte("sig"), nil }) + require.True(t, ok, "the next identity signs") + assert.Equal(t, []byte("sig"), signature) +} + +// TestSignAttempts_KeepsEveryFailure: when no identity signs, the approval a user has to +// give must not be hidden behind the failure of a later identity. +func TestSignAttempts_KeepsEveryFailure(t *testing.T) { + var attempts signAttempts + assert.NoError(t, attempts.err(), "no attempts, no error") + + attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest }) + attempts.try(func() ([]byte, error) { return nil, errors.New("key is gone") }) + + err := attempts.err() + require.Error(t, err) + assert.ErrorIs(t, err, errNeedsApprovalForTest, "the earlier approval error survives the later failure") + assert.ErrorContains(t, err, "key is gone") +} diff --git a/client/internal/certproof/store.go b/client/internal/certproof/store.go index 295d6caa4..85ab7cda7 100644 --- a/client/internal/certproof/store.go +++ b/client/internal/certproof/store.go @@ -46,7 +46,7 @@ func (c Candidate) issuers() []*x509.Certificate { return append(slices.Clip(c.Chain[1:]), c.Intermediates...) } -// Store yields the certificates a peer may prove possession of. FileStore is the PEM +// Store yields the certificates a peer may prove possession of. fileStore is the PEM // directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here. type Store interface { Candidates(ctx context.Context) ([]Candidate, error) @@ -68,15 +68,15 @@ type Config struct { OwnerUnknown bool } -// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and +// fileStore reads PEM files from a directory. A file holds the chain (leaf first) and // either its private key or a sibling ".key" file holds it. The key is a plain // PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with. -type FileStore struct { +type fileStore struct { dir string } -func NewFileStore(dir string) *FileStore { - return &FileStore{dir: dir} +func newFileStore(dir string) *fileStore { + return &fileStore{dir: dir} } func StoreDir() string { @@ -86,7 +86,7 @@ func StoreDir() string { return defaultStoreDir } -func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *fileStore) Candidates(_ context.Context) ([]Candidate, error) { paths, err := certFiles(s.dir) if err != nil { return nil, err diff --git a/client/internal/certproof/store_other.go b/client/internal/certproof/store_other.go index e6646732b..eeecf3c39 100644 --- a/client/internal/certproof/store_other.go +++ b/client/internal/certproof/store_other.go @@ -14,13 +14,13 @@ var unsupportedTokenOnce sync.Once // DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs. func DefaultStore() Store { - return NewFileStore(StoreDir()) + return newFileStore(StoreDir()) } // storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg // names one. The token pairs the directory's key-less certificates with its own keys. func storeWithToken(cfg Config) Store { - files := NewFileStore(StoreDir()) + files := newFileStore(StoreDir()) if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" { return files } @@ -30,7 +30,7 @@ func storeWithToken(cfg Config) Store { }) return files } - token, err := NewPKCS11Store(cfg.PKCS11, StoreDir()) + token, err := newPKCS11Store(cfg.PKCS11, StoreDir()) if err != nil { log.Warnf("ignoring PKCS#11 URI: %v", err) return files diff --git a/client/internal/certproof/store_other_test.go b/client/internal/certproof/store_other_test.go index 47b05f058..68b4df2a6 100644 --- a/client/internal/certproof/store_other_test.go +++ b/client/internal/certproof/store_other_test.go @@ -15,12 +15,12 @@ func TestStoreWithToken(t *testing.T) { dir := storeDir(t) t.Setenv(StoreDirEnv, dir) - files, ok := storeWithToken(Config{}).(*FileStore) + files, ok := storeWithToken(Config{}).(*fileStore) require.True(t, ok, "no token configured reads the PEM directory alone") assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR") - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory") - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory") + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory") + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory") configured := map[string]PKCS11Config{ "env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"}, @@ -28,7 +28,7 @@ func TestStoreWithToken(t *testing.T) { } if !pkcs11.Supported() { for name, cfg := range configured { - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: cfg}), + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: cfg}), "%s: a build without PKCS#11 support reads the PEM directory alone", name) } return @@ -37,7 +37,7 @@ func TestStoreWithToken(t *testing.T) { store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores) require.True(t, ok, "%s joins the token to the PEM directory", name) require.Len(t, store, 2, name) - token, ok := store[1].(*PKCS11Store) + token, ok := store[1].(*pkcs11Store) require.True(t, ok, name) assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name) } diff --git a/client/internal/certproof/store_tpm_test.go b/client/internal/certproof/store_tpm_test.go index f8a7a0960..cce570891 100644 --- a/client/internal/certproof/store_tpm_test.go +++ b/client/internal/certproof/store_tpm_test.go @@ -36,7 +36,7 @@ func TestFileStore_TPMKeyFile(t *testing.T) { writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked"))) writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth())) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1, "only the key without an authorization value is usable") assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate") @@ -73,7 +73,7 @@ func TestCollect_TPMKeyEndToEnd(t *testing.T) { nonce := challenger.Nonce(peerKey, now) checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}} - proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey) + proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey) require.Len(t, proofs, 1, "the TPM-held key must prove the certificate") chain, err := challenger.Verify(proofs[0], peerKey, now) require.NoError(t, err) diff --git a/client/internal/certproof/storefile_unix_test.go b/client/internal/certproof/storefile_unix_test.go index d35a4e759..9932388b6 100644 --- a/client/internal/certproof/storefile_unix_test.go +++ b/client/internal/certproof/storefile_unix_test.go @@ -26,7 +26,7 @@ func devicePair(t *testing.T, dir string) { func candidates(t *testing.T, dir string) ([]Candidate, error) { t.Helper() - return NewFileStore(dir).Candidates(context.Background()) + return newFileStore(dir).Candidates(context.Background()) } func TestFileStore_AcceptsAPrivatePair(t *testing.T) { diff --git a/client/internal/certproof/systemstore_windows.go b/client/internal/certproof/systemstore_windows.go index 92aa03d4b..908899107 100644 --- a/client/internal/certproof/systemstore_windows.go +++ b/client/internal/certproof/systemstore_windows.go @@ -49,10 +49,10 @@ type bcryptPSSPaddingInfo struct { // DefaultStore is the local machine's personal certificate store, where device // certificates enrolled through AD or Intune are kept. func DefaultStore() Store { - return NewSystemStore() + return newSystemStore() } -// SystemStore yields the identities of a personal certificate store, completing their +// systemStore yields the identities of a personal certificate store, completing their // chains from the matching intermediate CA store. Keys are used through CNG and never // exported. // @@ -60,22 +60,22 @@ func DefaultStore() Store { // a service reads; the current user store lives in the signed-in user's registry hive // with keys protected against their profile, so it is only readable while running as // that user. -type SystemStore struct { +type systemStore struct { location uint32 } -// NewSystemStore reads the local machine store, which is what the daemon uses. -func NewSystemStore() *SystemStore { - return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE} +// newSystemStore reads the local machine store, which is what the daemon uses. +func newSystemStore() *systemStore { + return &systemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE} } -// NewUserStore reads the calling user's personal store. It is only useful in a process +// newUserStore reads the calling user's personal store. It is only useful in a process // already running as that user, which is what the posture helper is. -func NewUserStore() *SystemStore { - return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER} +func newUserStore() *systemStore { + return &systemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER} } -func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *systemStore) Candidates(_ context.Context) ([]Candidate, error) { leaves, err := storeCertificates(s.location, personalStore) if err != nil { return nil, err @@ -100,7 +100,7 @@ func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) { } // String names the store location the way the Windows documentation does. -func (s *SystemStore) String() string { +func (s *systemStore) String() string { if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER { return "CurrentUser" } diff --git a/client/internal/pkcs11/driver_linux.go b/client/internal/pkcs11/driver_linux.go index 93bf4a5ba..54a3863cb 100644 --- a/client/internal/pkcs11/driver_linux.go +++ b/client/internal/pkcs11/driver_linux.go @@ -32,6 +32,7 @@ const ( // signature is 2 KiB. maxAttributeLen = 1 << 20 maxSignatureLen = 1 << 16 + maxFoundObjects = 1 << 16 ) type version struct { @@ -103,6 +104,15 @@ const ( fnSign = 43 ) +// checkFunctionListVersion refuses a module whose function list is older than the layout +// functionList mirrors, which would otherwise be read past its end. +func checkFunctionListVersion(v version) error { + if v.major < 2 { + return fmt.Errorf("PKCS#11 function list version %d.%d is older than 2.0", v.major, v.minor) + } + return nil +} + // module holds the entry points of one loaded library, bound straight from its // CK_FUNCTION_LIST. type module struct { @@ -152,6 +162,10 @@ func load(path string) (_ driver, err error) { return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)} } + if err := checkFunctionListVersion(list.version); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + m := &module{} for _, entry := range []struct { fn any @@ -185,21 +199,43 @@ func load(path string) (_ driver, err error) { return m, nil } +// slotListAttempts bounds how often the slot list is asked for again after it grew between +// the size query and the read. +const slotListAttempts = 3 + +// slotList returns the slots with a token present. A token plugged in between the size +// query and the read makes the module answer CKR_BUFFER_TOO_SMALL, in which case the list +// is asked for again. +func (m *module) slotList() ([]ulong, error) { + for range slotListAttempts { + var count ulong + if rv := m.cGetSlotList(1, nil, &count); rv != rvOK { + return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + } + if count == 0 { + return nil, nil + } + slots := make([]ulong, count) + rv := m.cGetSlotList(1, &slots[0], &count) + if rv == rvBufferTooSmall { + continue + } + if rv != rvOK { + return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + } + return slots[:count], nil + } + return nil, Error{Op: "C_GetSlotList", Code: rvBufferTooSmall} +} + func (m *module) tokens() ([]Token, error) { - var count ulong - if rv := m.cGetSlotList(1, nil, &count); rv != rvOK { - return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} - } - if count == 0 { - return nil, nil - } - slots := make([]ulong, count) - if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK { - return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + slots, err := m.slotList() + if err != nil { + return nil, err } - tokens := make([]Token, 0, count) - for _, slot := range slots[:count] { + tokens := make([]Token, 0, len(slots)) + for _, slot := range slots { var info tokenInfo if rv := m.cGetTokenInfo(slot, &info); rv != rvOK { continue @@ -262,11 +298,16 @@ func (m *module) findObjects(session uint, template []Attribute) ([]Object, erro if count > findBatch { return nil, fmt.Errorf("C_FindObjects reported %d handles for a batch of %d", count, findBatch) } + // Only an empty batch ends the search: a module may return fewer handles than + // asked for before it has returned them all. + if count == 0 { + return objects, nil + } for _, handle := range batch[:count] { objects = append(objects, Object(handle)) } - if count < findBatch { - return objects, nil + if len(objects) > maxFoundObjects { + return nil, fmt.Errorf("C_FindObjects returned more than %d objects", maxFoundObjects) } } } diff --git a/client/internal/pkcs11/driver_linux_test.go b/client/internal/pkcs11/driver_linux_test.go index 408a3fabc..2362ee18f 100644 --- a/client/internal/pkcs11/driver_linux_test.go +++ b/client/internal/pkcs11/driver_linux_test.go @@ -70,3 +70,119 @@ func loadFirst(t *testing.T, paths ...string) *Module { t.Skip("p11-kit trust module not installed") return nil } + +// TestSlotList_RetriesWhenTheListGrows: a token plugged in between the size query and the +// fill makes the module answer CKR_BUFFER_TOO_SMALL; the list has to be asked for again. +func TestSlotList_RetriesWhenTheListGrows(t *testing.T) { + calls := 0 + m := &module{cGetSlotList: func(_ byte, slots *ulong, count *ulong) ulong { + calls++ + switch { + case slots == nil && calls == 1: + *count = 1 + case slots != nil && calls == 2: + *count = 2 + return rvBufferTooSmall + case slots == nil: + *count = 2 + default: + *count = 2 + backing := unsafe.Slice(slots, 2) + backing[0], backing[1] = 7, 9 + } + return rvOK + }} + + slots, err := m.slotList() + + require.NoError(t, err) + assert.Equal(t, []ulong{7, 9}, slots, "the list is read again at its new size") +} + +func TestSlotList_GivesUpOnAListThatKeepsGrowing(t *testing.T) { + m := &module{cGetSlotList: func(_ byte, slots *ulong, count *ulong) ulong { + if slots == nil { + *count = 1 + return rvOK + } + return rvBufferTooSmall + }} + + _, err := m.slotList() + + assert.Error(t, err, "a module that never settles on a size is an error, not a loop") +} + +func TestCheckFunctionListVersion(t *testing.T) { + assert.NoError(t, checkFunctionListVersion(version{major: 2, minor: 40})) + assert.NoError(t, checkFunctionListVersion(version{major: 3, minor: 0})) + assert.Error(t, checkFunctionListVersion(version{major: 1, minor: 0}), "a pre-2.0 list has no entry points to index") + assert.Error(t, checkFunctionListVersion(version{}), "an empty version is not a function list") +} + +// fakeFindModule answers C_FindObjects from batches, a handle per entry, and reports how +// the search was torn down. +func fakeFindModule(batches [][]ulong, finalized *int) *module { + next := 0 + return &module{ + cFindObjectsInit: func(ulong, *attribute, ulong) ulong { return rvOK }, + cFindObjectsFinal: func(ulong) ulong { *finalized++; return rvOK }, + cFindObjects: func(_ ulong, objects *ulong, max ulong, count *ulong) ulong { + if next >= len(batches) { + *count = 0 + return rvOK + } + batch := batches[next] + next++ + copy(unsafe.Slice(objects, max), batch) + *count = ulong(len(batch)) + return rvOK + }, + } +} + +var findTemplate = []Attribute{{Type: AttrClass, Value: ULong(ClassPrivateKey)}} + +// TestFindObjects_ReadsPastAShortBatch: PKCS#11 ends a search with an empty batch, not with +// one shorter than asked for, so a module that hands out its handles in small batches must +// still have all of them returned. +func TestFindObjects_ReadsPastAShortBatch(t *testing.T) { + finalized := 0 + m := fakeFindModule([][]ulong{{1, 2}, {3}, {4, 5, 6}}, &finalized) + + objects, err := m.findObjects(1, findTemplate) + + require.NoError(t, err) + assert.Equal(t, []Object{1, 2, 3, 4, 5, 6}, objects, "every batch is read until the module returns an empty one") + assert.Equal(t, 1, finalized, "the search is finished once") +} + +func TestFindObjects_StopsAtAnEmptyBatch(t *testing.T) { + finalized := 0 + m := fakeFindModule(nil, &finalized) + + objects, err := m.findObjects(1, findTemplate) + + require.NoError(t, err) + assert.Empty(t, objects, "a search that finds nothing returns nothing") + assert.Equal(t, 1, finalized) +} + +// TestFindObjects_BoundsAModuleThatNeverEnds: a module that keeps returning handles must +// not make the daemon allocate without limit. +func TestFindObjects_BoundsAModuleThatNeverEnds(t *testing.T) { + finalized := 0 + endless := &module{ + cFindObjectsInit: func(ulong, *attribute, ulong) ulong { return rvOK }, + cFindObjectsFinal: func(ulong) ulong { finalized++; return rvOK }, + cFindObjects: func(_ ulong, _ *ulong, _ ulong, count *ulong) ulong { + *count = findBatch + return rvOK + }, + } + + _, err := endless.findObjects(1, findTemplate) + + assert.Error(t, err, "an endless search is cut off") + assert.Equal(t, 1, finalized, "and still finished") +} diff --git a/client/internal/pkcs11/pkcs11.go b/client/internal/pkcs11/pkcs11.go index da874fb62..4a34848d9 100644 --- a/client/internal/pkcs11/pkcs11.go +++ b/client/internal/pkcs11/pkcs11.go @@ -50,6 +50,7 @@ const ( rvPINExpired = 0xa3 rvPINLocked = 0xa4 rvUserAlreadyLoggedIn = 0x100 + rvBufferTooSmall = 0x150 rvAlreadyInitialized = 0x191 ) diff --git a/client/internal/pkcs11/publickey.go b/client/internal/pkcs11/publickey.go index 4d59598e6..aac78524a 100644 --- a/client/internal/pkcs11/publickey.go +++ b/client/internal/pkcs11/publickey.go @@ -70,8 +70,10 @@ func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) { // uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare. func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) { var oid asn1.ObjectIdentifier - if _, err := asn1.Unmarshal(params, &oid); err != nil { + if rest, err := asn1.Unmarshal(params, &oid); err != nil { return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err) + } else if len(rest) != 0 { + return nil, errors.New("CKA_EC_PARAMS has trailing data") } curve, ok := curvesByOID[oid.String()] if !ok { @@ -80,8 +82,10 @@ func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) { size := (curve.Params().BitSize + 7) / 8 raw := point if len(raw) != 1+2*size { - if _, err := asn1.Unmarshal(point, &raw); err != nil { + if rest, err := asn1.Unmarshal(point, &raw); err != nil { return nil, fmt.Errorf("CKA_EC_POINT: %w", err) + } else if len(rest) != 0 { + return nil, errors.New("CKA_EC_POINT has trailing data") } } if len(raw) != 1+2*size || raw[0] != 4 { diff --git a/client/internal/pkcs11/publickey_test.go b/client/internal/pkcs11/publickey_test.go index 527d6db46..70c1c9a15 100644 --- a/client/internal/pkcs11/publickey_test.go +++ b/client/internal/pkcs11/publickey_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/asn1" + "math" "math/big" "testing" @@ -21,6 +22,8 @@ func TestECPublicKey(t *testing.T) { }{ {"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}}, {"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}}, + // The wrapped P-521 point is 133 bytes, which needs the long form of the DER length. + {"P-521", elliptic.P521(), asn1.ObjectIdentifier{1, 3, 132, 0, 35}}, } for _, tt := range curves { t.Run(tt.name, func(t *testing.T) { @@ -57,6 +60,13 @@ func TestECPublicKey_Rejections(t *testing.T) { assert.Error(t, err, "a truncated point must be rejected") _, err = ecPublicKey([]byte("junk"), point) assert.Error(t, err, "malformed parameters must be rejected") + + wrapped, err := asn1.Marshal(point) + require.NoError(t, err) + _, err = ecPublicKey(append(append([]byte{}, p256...), 0), point) + assert.Error(t, err, "bytes after the curve parameters must be rejected") + _, err = ecPublicKey(p256, append(append([]byte{}, wrapped...), 0)) + assert.Error(t, err, "bytes after the wrapped point must be rejected") } func TestRSAPublicKey(t *testing.T) { @@ -69,6 +79,12 @@ func TestRSAPublicKey(t *testing.T) { _, err = rsaPublicKey(key.N.Bytes(), nil) assert.Error(t, err, "a missing exponent must be rejected") + _, err = rsaPublicKey(key.N.Bytes(), big.NewInt(math.MaxInt32).Bytes()) + assert.NoError(t, err, "the largest exponent an int holds is accepted") + _, err = rsaPublicKey(key.N.Bytes(), big.NewInt(math.MaxInt32+1).Bytes()) + assert.Error(t, err, "an exponent past int32 must be rejected") + _, err = rsaPublicKey(key.N.Bytes(), []byte{0}) + assert.Error(t, err, "a zero exponent must be rejected") } func TestULongRoundTrip(t *testing.T) { diff --git a/client/internal/pkcs11/uri.go b/client/internal/pkcs11/uri.go index c7248c10e..fb5dc2765 100644 --- a/client/internal/pkcs11/uri.go +++ b/client/internal/pkcs11/uri.go @@ -29,7 +29,7 @@ type URI struct { // module-path must be absolute and a module-name must be a bare name. Unknown query // attributes are ignored, as the RFC asks. func ParseURI(raw string) (*URI, error) { - rest, ok := strings.CutPrefix(raw, "pkcs11:") + rest, ok := cutPrefixFold(raw, "pkcs11:") if !ok { return nil, errors.New("PKCS#11 URI does not start with the pkcs11 scheme") } @@ -72,11 +72,61 @@ func (u *URI) setQueryAttribute(name, value string) error { case "pin-value": u.pinValue = &value case "pin-source": - u.pinSource = value + path, err := pinSourcePath(value) + if err != nil { + return err + } + u.pinSource = path } return nil } +// pinSourcePath turns a pin-source value into the file to read. A plain path is used as +// it is; a file URI must name a local absolute path, with no host or localhost (RFC 8089 +// section 2), so a PIN is never read from somewhere the URI only appears to point to. +func pinSourcePath(value string) (string, error) { + if value == "" { + return "", errors.New("PKCS#11 URI pin-source is empty") + } + rest, isFile := cutPrefixFold(value, "file:") + if !isFile { + return value, nil + } + authorityAndPath, hasAuthority := strings.CutPrefix(rest, "//") + if !hasAuthority { + if !isAbsolutePath(rest) { + return "", fmt.Errorf("PKCS#11 URI pin-source %q must be an absolute path", value) + } + return rest, nil + } + if isAbsolutePath(authorityAndPath) { + return authorityAndPath, nil + } + host, path, _ := strings.Cut(authorityAndPath, "/") + if host != "" && !strings.EqualFold(host, "localhost") { + return "", fmt.Errorf("PKCS#11 URI pin-source host %q is not the local machine", host) + } + if path == "" { + return "", fmt.Errorf("PKCS#11 URI pin-source %q names no file", value) + } + return "/" + path, nil +} + +// isAbsolutePath reports whether p is absolute in a file URI. A path starting with a slash +// is absolute there on every platform, including Windows, where filepath.IsAbs wants a +// drive letter. +func isAbsolutePath(p string) bool { + return strings.HasPrefix(p, "/") || filepath.IsAbs(p) +} + +// cutPrefixFold is strings.CutPrefix with the prefix matched without regard to case. +func cutPrefixFold(s, prefix string) (string, bool) { + if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) { + return s, false + } + return s[len(prefix):], true +} + // Module is the library to load, DefaultModule when the URI names none. func (u *URI) Module() string { if u.ModulePath == "" { @@ -99,8 +149,7 @@ func (u *URI) PIN() ([]byte, error) { if u.pinSource == "" { return nil, nil } - path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:") - pin, err := os.ReadFile(path) + pin, err := os.ReadFile(u.pinSource) if err != nil { return nil, fmt.Errorf("read PIN: %w", err) } diff --git a/client/internal/pkcs11/uri_test.go b/client/internal/pkcs11/uri_test.go index 54e8a0d91..09aede6a7 100644 --- a/client/internal/pkcs11/uri_test.go +++ b/client/internal/pkcs11/uri_test.go @@ -84,6 +84,10 @@ func TestParseURI_Rejections(t *testing.T) { "module-name with dotdot": "pkcs11:token=a?module-name=..", "empty module-name": "pkcs11:token=a?module-name=", "pin-value and pin-source": "pkcs11:token=a?pin-value=1&pin-source=file:/etc/pin", + // Without a value the URI would silently mean "no PIN" and skip the login. + "empty pin-source": "pkcs11:token=a?pin-source=", + "remote pin-source": "pkcs11:token=a?pin-source=file://example.com/etc/pin", + "relative pin-source": "pkcs11:token=a?pin-source=file:pin", } for name, raw := range tests { _, err := ParseURI(raw) @@ -91,6 +95,31 @@ func TestParseURI_Rejections(t *testing.T) { } } +// TestParseURI_SchemeIsCaseInsensitive: RFC 3986 section 3.1 makes schemes case-insensitive. +func TestParseURI_SchemeIsCaseInsensitive(t *testing.T) { + for _, raw := range []string{"PKCS11:token=netbird", "Pkcs11:token=netbird"} { + uri, err := ParseURI(raw) + require.NoError(t, err, raw) + assert.Equal(t, "netbird", uri.Token, raw) + } +} + +func TestURI_PINFromLocalhostFileURI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file URIs with a host name are written differently on Windows") + } + path := filepath.Join(t.TempDir(), "pin") + require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600)) + + for _, host := range []string{"localhost", "LocalHost"} { + uri, err := ParseURI("pkcs11:token=netbird?pin-source=file://" + host + path) + require.NoError(t, err, host) + pin, err := uri.PIN() + require.NoError(t, err, host) + assert.Equal(t, []byte("secret"), pin, "RFC 8089 names the local machine as localhost") + } +} + func TestURI_PINFromFile(t *testing.T) { path := filepath.Join(t.TempDir(), "pin") require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600)) @@ -117,3 +146,30 @@ func absModule(name string) string { } return "/lib/" + name } + +// TestPinSourcePath covers how a pin-source value becomes a file path, without touching the +// file system, so the same cases run on every platform. A path starting with a slash is +// absolute in a file URI everywhere, including Windows, where filepath.IsAbs would refuse it. +func TestPinSourcePath(t *testing.T) { + tests := []struct { + value string + want string + }{ + {"/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file:/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file:///etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"FILE:///etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file://localhost/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file://LocalHost/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + } + for _, tt := range tests { + got, err := pinSourcePath(tt.value) + require.NoError(t, err, tt.value) + assert.Equal(t, tt.want, got, tt.value) + } + + for _, value := range []string{"", "file:pin", "file://example.com/etc/pin", "file://localhost", "file://localhost/"} { + _, err := pinSourcePath(value) + assert.Error(t, err, "%q must be refused", value) + } +} diff --git a/management/internals/network_map_db/shared_types_test.go b/management/internals/network_map_db/shared_types_test.go index 8a1239e26..34b69d265 100644 --- a/management/internals/network_map_db/shared_types_test.go +++ b/management/internals/network_map_db/shared_types_test.go @@ -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") +} diff --git a/shared/management/certposture/certposture_test.go b/shared/management/certposture/certposture_test.go index ae31a2203..e8f3d20c1 100644 --- a/shared/management/certposture/certposture_test.go +++ b/shared/management/certposture/certposture_test.go @@ -2,6 +2,8 @@ package certposture import ( "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -86,6 +88,7 @@ func TestSignVerify_RoundTripPerKeyType(t *testing.T) { ca := certtest.NewCA(t, "root") keys := map[string]crypto.Signer{ SigAlgECDSASHA256: certtest.ECDSAKey(t), + SigAlgECDSASHA384: p384Key(t), SigAlgRSAPSSSHA256: certtest.RSAKey(t), SigAlgEd25519: certtest.Ed25519Key(t), } @@ -103,6 +106,24 @@ func TestSignVerify_RoundTripPerKeyType(t *testing.T) { } } +func p384Key(t *testing.T) crypto.Signer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + return key +} + +// TestSign_RefusesCurvesTheProofDoesNotName: a proof names its algorithm from the key, and +// the verifier only knows P-256 and P-384, so P-521 is refused before anything is signed. +func TestSign_RefusesCurvesTheProofDoesNotName(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err) + + _, _, err = Sign(key, NewChallenger(secret).Nonce(peerKey, now), peerKey) + + assert.ErrorIs(t, err, ErrUnsupportedKey) +} + func TestVerify_Rejections(t *testing.T) { ca := certtest.NewCA(t, "root") c := NewChallenger(secret) diff --git a/shared/management/client/grpc_test.go b/shared/management/client/grpc_test.go index c947130fd..8c69cea1c 100644 --- a/shared/management/client/grpc_test.go +++ b/shared/management/client/grpc_test.go @@ -12,6 +12,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/management/certposture" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" ) @@ -93,3 +95,25 @@ func TestMaxRecvMsgSizeIntegration(t *testing.T) { assert.Len(t, resp.Key, payloadSize) }) } + +// TestInfoToMetaData_CarriesCertificateProofs: management verifies the proofs it receives +// in the peer meta, so a field dropped by the mapping fails the certificate check without +// any error on either side. +func TestInfoToMetaData_CarriesCertificateProofs(t *testing.T) { + info := &system.Info{CertificateProofs: []certposture.Proof{ + {Nonce: []byte("nonce-1"), Chain: [][]byte{[]byte("leaf"), []byte("intermediate")}, SigAlg: "ecdsa-sha256", Signature: []byte("sig-1")}, + {Nonce: []byte("nonce-2"), Chain: [][]byte{[]byte("other")}, SigAlg: "rsa-pss-sha256", Signature: []byte("sig-2")}, + }} + + meta := infoToMetaData(info) + + require.Len(t, meta.GetCertificateProofs(), 2, "every proof reaches the meta") + first := meta.GetCertificateProofs()[0] + assert.Equal(t, []byte("nonce-1"), first.GetNonce()) + assert.Equal(t, [][]byte{[]byte("leaf"), []byte("intermediate")}, first.GetChain(), "the whole chain is carried, in order") + assert.Equal(t, "ecdsa-sha256", first.GetSigAlg()) + assert.Equal(t, []byte("sig-1"), first.GetSignature()) + assert.Equal(t, "rsa-pss-sha256", meta.GetCertificateProofs()[1].GetSigAlg()) + + assert.Empty(t, infoToMetaData(&system.Info{}).GetCertificateProofs(), "a device without proofs sends none") +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 8b97d5f47..fbf8bf5cd 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -1821,6 +1821,7 @@ components: ca_certificates: description: PEM encoded CA certificates the peer's certificate must chain to type: array + minItems: 1 items: type: string example: ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"] From 547dd1f177bc1190558a680488f085550b1f39a4 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:19:57 +0200 Subject: [PATCH 65/70] [client] Detect default route changes in the Linux network monitor (#8216) The Linux network monitor skipped every route update whose Dst was not nil, treating a nil Dst as the default route. Since the bump to vishvananda/netlink v1.3 (v0.43.0) the default route arrives as an explicit 0.0.0.0/0 or ::/0, so every default route change was filtered out and the monitor never fired on Linux, no matter how the underlay moved. Treat both a nil Dst and a zero-length prefix as the default route. --- .../networkmonitor/check_change_linux.go | 13 +++++- .../networkmonitor/check_change_linux_test.go | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 client/internal/networkmonitor/check_change_linux_test.go diff --git a/client/internal/networkmonitor/check_change_linux.go b/client/internal/networkmonitor/check_change_linux.go index efd8b5884..4fbff4448 100644 --- a/client/internal/networkmonitor/check_change_linux.go +++ b/client/internal/networkmonitor/check_change_linux.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "net" "syscall" log "github.com/sirupsen/logrus" @@ -35,7 +36,7 @@ func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) er // handle route changes case route := <-routeChan: // default route and main table - if route.Dst != nil || route.Table != syscall.RT_TABLE_MAIN { + if !isDefaultRoute(route.Dst) || route.Table != syscall.RT_TABLE_MAIN { continue } switch route.Type { @@ -52,3 +53,13 @@ func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) er } } } + +// isDefaultRoute reports whether dst is a default route. netlink v1.3 reports +// the default as 0.0.0.0/0 or ::/0, older versions as a nil Dst. +func isDefaultRoute(dst *net.IPNet) bool { + if dst == nil { + return true + } + ones, _ := dst.Mask.Size() + return ones == 0 +} diff --git a/client/internal/networkmonitor/check_change_linux_test.go b/client/internal/networkmonitor/check_change_linux_test.go new file mode 100644 index 000000000..13e1c9e6d --- /dev/null +++ b/client/internal/networkmonitor/check_change_linux_test.go @@ -0,0 +1,43 @@ +//go:build !android + +package networkmonitor + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsDefaultRoute(t *testing.T) { + mustCIDR := func(s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("parse %s: %v", s, err) + } + return n + } + + tests := []struct { + name string + dst *net.IPNet + want bool + }{ + // netlink before v1.3 left Dst nil for the default route. + {name: "nil dst", dst: nil, want: true}, + // netlink v1.3 reports the default route with an explicit zero-length prefix. + {name: "v4 default", dst: mustCIDR("0.0.0.0/0"), want: true}, + {name: "v6 default", dst: mustCIDR("::/0"), want: true}, + // The exit node split routes and ordinary prefixes are not defaults. + {name: "v4 split half", dst: mustCIDR("0.0.0.0/1"), want: false}, + {name: "v4 subnet", dst: mustCIDR("10.0.0.0/8"), want: false}, + {name: "v4 host", dst: mustCIDR("1.1.1.1/32"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDefaultRoute(tt.dst), "default route detection") + }) + } +} From 357d8a2a7c565605e0e10cd79d1b46c5ea958555 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 15 Oct 2026 14:26:50 +0200 Subject: [PATCH 66/70] [proxy,client] Add --external-ip-map flag for the embedded client (#8185) * [proxy] Add --external-ip-map flag for the embedded client The proxy behind a NAT with a forwarded fixed --wg-port could not advertise its public address as an ICE candidate, so peers fell back to relay. Expose the agent's external IP map through embed.Options and a proxy flag (NB_PROXY_EXTERNAL_IP_MAP). * [proxy] Validate --external-ip-map at startup The engine drops every external IP mapping when one fails to parse, so a typo or a space after a comma silently disabled the feature. Move the agent's validator to shared/externalipmap, run it from the proxy at startup, and trim entries first. * [client,proxy] Require an external IP first in --external-ip-map The engine reads the first component as the external IP, so a mapping like iface/10.0.0.5 passed validation and then dropped every mapping. --- client/cmd/up.go | 73 +-------------------------- client/embed/embed.go | 4 ++ proxy/cmd/proxy/cmd/root.go | 20 ++++++++ proxy/internal/roundtrip/netbird.go | 4 ++ proxy/lifecycle.go | 4 ++ proxy/server.go | 12 +++-- shared/externalipmap/validate.go | 70 +++++++++++++++++++++++++ shared/externalipmap/validate_test.go | 48 ++++++++++++++++++ 8 files changed, 160 insertions(+), 75 deletions(-) create mode 100644 shared/externalipmap/validate.go create mode 100644 shared/externalipmap/validate_test.go diff --git a/client/cmd/up.go b/client/cmd/up.go index 120a25595..e336ba257 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net" "net/netip" "runtime" "strings" @@ -26,16 +25,11 @@ import ( "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" ) -const ( - invalidInputType int = iota - ipInputType - interfaceInputType -) - const ( dnsLabelsFlag = "extra-dns-labels" @@ -106,7 +100,7 @@ func upFunc(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed initializing log %v", err) } - err = validateNATExternalIPs(natExternalIPs) + err = externalipmap.Validate(natExternalIPs) if err != nil { return err } @@ -836,36 +830,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte return &loginRequest, nil } -func validateNATExternalIPs(list []string) error { - for _, element := range list { - if element == "" { - return fmt.Errorf("empty string is not a valid input for %s", externalIPMapFlag) - } - - subElements := strings.Split(element, "/") - if len(subElements) > 2 { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, externalIPMapFlag) - } - - if len(subElements) == 1 && !isValidIP(subElements[0]) { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, externalIPMapFlag) - } - - last := 0 - for _, singleElement := range subElements { - inputType, err := validateElement(singleElement) - if err != nil { - return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name", singleElement, externalIPMapFlag) - } - if last == interfaceInputType && inputType == interfaceInputType { - return fmt.Errorf("%s is not a valid input for %s. it should not contain two interface names", element, externalIPMapFlag) - } - last = inputType - } - } - return nil -} - func parseInterfaceName(name string) error { if runtime.GOOS != "darwin" { return nil @@ -878,39 +842,6 @@ func parseInterfaceName(name string) error { return fmt.Errorf("invalid interface name %s. Please use the prefix utun followed by a number on MacOS. e.g., utun1 or utun199", name) } -func validateElement(element string) (int, error) { - if isValidIP(element) { - return ipInputType, nil - } - validIface, err := isValidInterface(element) - if err != nil { - return invalidInputType, fmt.Errorf("unable to validate the network interface name, error: %s", err) - } - - if validIface { - return interfaceInputType, nil - } - - return interfaceInputType, fmt.Errorf("invalid IP or network interface name not found") -} - -func isValidIP(ip string) bool { - return net.ParseIP(ip) != nil -} - -func isValidInterface(name string) (bool, error) { - netInterfaces, err := net.Interfaces() - if err != nil { - return false, err - } - for _, iface := range netInterfaces { - if iface.Name == name { - return true, nil - } - } - return false, nil -} - func parseCustomDNSAddress(modified bool) ([]byte, error) { var parsed []byte if modified { diff --git a/client/embed/embed.go b/client/embed/embed.go index 6fdddab48..208e71817 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -118,6 +118,9 @@ type Options struct { MTU *uint16 // DNSLabels defines additional DNS labels configured in the peer. DNSLabels []string + // NATExternalIPs maps external IPs to internal IPs or interfaces for ICE + // candidates, same format as the agent's --external-ip-map flag. + NATExternalIPs []string // Performance configures the tunnel's buffer pool cap and batch size. Performance Performance } @@ -224,6 +227,7 @@ func New(opts Options) (*Client, error) { WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, + NATExternalIPs: opts.NATExternalIPs, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index 765d5c05a..5734efe4e 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/acme" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/profiling" @@ -74,6 +76,7 @@ var ( certLockMethod string wildcardCertDir string wgPort uint16 + externalIPMap []string proxyProtocol bool preSharedKey string supportsCustomPorts bool @@ -117,6 +120,7 @@ func init() { rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease") rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (.crt/.key). Wildcard patterns are extracted from SANs automatically") rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments") + rootCmd.Flags().StringSliceVar(&externalIPMap, "external-ip-map", envStringSliceOrDefault("NB_PROXY_EXTERNAL_IP_MAP", nil), "Comma-separated external IPs to advertise as ICE candidates, as IP or IP/internal-IP-or-interface (e.g. '203.0.113.5/10.0.0.5'). Requires a fixed --wg-port forwarded on the NAT") rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") @@ -221,6 +225,13 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid --trusted-proxies: %w", err) } + for i := range externalIPMap { + externalIPMap[i] = strings.TrimSpace(externalIPMap[i]) + } + if err := externalipmap.Validate(externalIPMap); err != nil { + return fmt.Errorf("invalid --external-ip-map: %w", err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() @@ -248,6 +259,7 @@ func runServer(cmd *cobra.Command, args []string) error { CertLockMethod: nbacme.CertLockMethod(certLockMethod), WildcardCertDir: wildcardCertDir, WireguardPort: wgPort, + NATExternalIPs: externalIPMap, Performance: perf, ProxyProtocol: proxyProtocol, PreSharedKey: preSharedKey, @@ -286,6 +298,14 @@ func envStringOrDefault(key string, def string) string { return v } +func envStringSliceOrDefault(key string, def []string) []string { + v, exists := os.LookupEnv(key) + if !exists || v == "" { + return def + } + return strings.Split(v, ",") +} + func envUint16OrDefault(key string, def uint16) uint16 { v, exists := os.LookupEnv(key) if !exists { diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 07b497c46..21feb59b7 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -144,6 +144,8 @@ type ClientConfig struct { WGPort uint16 PreSharedKey string Performance embed.Performance + // NATExternalIPs mirrors embed.Options.NATExternalIPs. + NATExternalIPs []string // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the // standalone proxy where the embedded client never accepts inbound; // set to false on the private/embedded proxy so the engine creates @@ -402,6 +404,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account WireguardPort: &wgPort, PreSharedKey: n.clientCfg.PreSharedKey, Performance: n.clientCfg.Performance, + NATExternalIPs: n.clientCfg.NATExternalIPs, } logEmbedOptions(n.logger, accountID, serviceID, publicKey.String(), embedOpts) client, err := embed.New(embedOpts) @@ -914,6 +917,7 @@ func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID ty "management_url": opts.ManagementURL, "log_level": opts.LogLevel, "wg_port": wgPort, + "nat_external_ips": opts.NATExternalIPs, "mtu": mtu, "block_inbound": opts.BlockInbound, "block_lan_access": opts.BlockLANAccess, diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index f8c74d8b5..206c296ac 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -89,6 +89,9 @@ type Config struct { // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 + // NATExternalIPs are external IPs advertised as ICE candidates by the + // embedded tunnel. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. ProxyProtocol bool // PreSharedKey is the WireGuard pre-shared key used between the @@ -160,6 +163,7 @@ func New(ctx context.Context, cfg Config) *Server { ForwardedProto: cfg.ForwardedProto, TrustedProxies: cfg.TrustedProxies, WireguardPort: cfg.WireguardPort, + NATExternalIPs: cfg.NATExternalIPs, ProxyProtocol: cfg.ProxyProtocol, PreSharedKey: cfg.PreSharedKey, Performance: cfg.Performance, diff --git a/proxy/server.go b/proxy/server.go index 083449962..9ad81b50f 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -203,6 +203,9 @@ type Server struct { // single-account deployments; multiple accounts will fail to bind // the same port. WireguardPort uint16 + // NATExternalIPs maps external IPs advertised as ICE candidates by every + // embedded client. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // Performance configures the tunnel pool/batch sizes for every // embedded client this proxy spawns. Performance embed.Performance @@ -581,10 +584,11 @@ func (s *Server) initManagementClient() error { // inbound listeners. func (s *Server) initNetBirdClient() error { s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{ - MgmtAddr: s.ManagementAddress, - WGPort: s.WireguardPort, - PreSharedKey: s.PreSharedKey, - Performance: s.Performance, + MgmtAddr: s.ManagementAddress, + WGPort: s.WireguardPort, + PreSharedKey: s.PreSharedKey, + Performance: s.Performance, + NATExternalIPs: s.NATExternalIPs, // On --private the embedded client serves per-account inbound // listeners and must apply management's ACL: keep BlockInbound off // so the engine creates the ACL manager. On the standalone proxy diff --git a/shared/externalipmap/validate.go b/shared/externalipmap/validate.go new file mode 100644 index 000000000..30517007c --- /dev/null +++ b/shared/externalipmap/validate.go @@ -0,0 +1,70 @@ +// Package externalipmap validates the --external-ip-map flag shared by the +// agent and the proxy. +package externalipmap + +import ( + "fmt" + "net" + "strings" +) + +const flagName = "external-ip-map" + +// Validate checks each element is formatted as "IP", "IP/IP" or +// "IP/interface". The engine drops every mapping when one element fails to +// parse, so callers should reject bad input up front. +func Validate(list []string) error { + for _, element := range list { + if element == "" { + return fmt.Errorf("empty string is not a valid input for %s", flagName) + } + + subElements := strings.Split(element, "/") + if len(subElements) > 2 { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, flagName) + } + + if !isValidIP(subElements[0]) { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, flagName) + } + + if len(subElements) == 2 { + if err := validateInternal(subElements[1]); err != nil { + return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name: %w", subElements[1], flagName, err) + } + } + } + return nil +} + +func validateInternal(element string) error { + if isValidIP(element) { + return nil + } + validIface, err := isValidInterface(element) + if err != nil { + return fmt.Errorf("validate network interface name: %w", err) + } + if !validIface { + return fmt.Errorf("invalid IP or network interface name not found") + } + return nil +} + +// isValidIP uses net.ParseIP to match the engine's parser exactly. +func isValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +func isValidInterface(name string) (bool, error) { + netInterfaces, err := net.Interfaces() + if err != nil { + return false, err + } + for _, iface := range netInterfaces { + if iface.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/shared/externalipmap/validate_test.go b/shared/externalipmap/validate_test.go new file mode 100644 index 000000000..baf3cf787 --- /dev/null +++ b/shared/externalipmap/validate_test.go @@ -0,0 +1,48 @@ +package externalipmap + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + ifaces, err := net.Interfaces() + require.NoError(t, err) + require.NotEmpty(t, ifaces, "host must have at least one interface") + iface := ifaces[0].Name + + tests := []struct { + name string + input []string + wantErr bool + }{ + {name: "nil", input: nil}, + {name: "single IP", input: []string{"203.0.113.5"}}, + {name: "IP to IP", input: []string{"203.0.113.5/10.0.0.5"}}, + {name: "IP to interface", input: []string{"203.0.113.5/" + iface}}, + {name: "several", input: []string{"203.0.113.5", "203.0.113.6/10.0.0.6"}}, + {name: "empty element", input: []string{""}, wantErr: true}, + // The engine rejects padded values, so validation must too. + {name: "space padded", input: []string{"203.0.113.5", " 203.0.113.6"}, wantErr: true}, + {name: "not an IP", input: []string{"not-an-ip"}, wantErr: true}, + {name: "too many slashes", input: []string{"203.0.113.5/10.0.0.5/10.0.0.6"}, wantErr: true}, + {name: "unknown interface", input: []string{"203.0.113.5/nb-no-such-iface0"}, wantErr: true}, + {name: "external interface", input: []string{iface}, wantErr: true}, + {name: "interface to IP", input: []string{iface + "/10.0.0.5"}, wantErr: true}, + {name: "two interfaces", input: []string{iface + "/" + iface}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.input) + if tt.wantErr { + assert.Error(t, err, "input %q should be rejected", tt.input) + return + } + assert.NoError(t, err, "input %q should be accepted", tt.input) + }) + } +} From 89c1b47e9ca73d7bc037f431760674ff919435eb Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:29 +0200 Subject: [PATCH 67/70] [management] cancel peer expiration for account if account is gone (#8218) --- management/server/account.go | 21 +++++++++++++++++++-- management/server/account_test.go | 24 ++++++++++++++++++++++++ management/server/peer.go | 6 ++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 90e384ac0..59991a4e4 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -770,7 +770,12 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, acc ctx = context.WithValue(ctx, hook.ExecutionContextKey, fmt.Sprintf("%s-PEER-EXPIRATION", hook.SystemSource)) expiredPeers, err := am.getExpiredPeers(ctx, accountID) + if isNotFound(err) { + log.WithContext(ctx).Debugf("account %s not found, stopping peer login expiration job", accountID) + return 0, false + } if err != nil { + log.WithContext(ctx).Errorf("failed getting expired peers for account %s: %v", accountID, err) return peerSchedulerRetryInterval, true } @@ -806,8 +811,12 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) { return func() (time.Duration, bool) { inactivePeers, err := am.getInactivePeers(ctx, accountID) + if isNotFound(err) { + log.WithContext(ctx).Debugf("account %s not found, stopping peer inactivity expiration job", accountID) + return 0, false + } if err != nil { - log.WithContext(ctx).Errorf("failed getting inactive peers for account %s", accountID) + log.WithContext(ctx).Errorf("failed getting inactive peers for account %s: %v", accountID, err) return peerSchedulerRetryInterval, true } @@ -827,6 +836,14 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context } } +func isNotFound(err error) bool { + if err == nil { + return false + } + sErr, ok := status.FromError(err) + return ok && sErr.Type() == status.NotFound +} + // checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) { am.peerInactivityExpiry.Cancel(ctx, []string{accountID}) @@ -978,8 +995,8 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u log.WithContext(ctx).Errorf("failed deleting account %s. error: %s", accountID, err) return err } - // cancel peer login expiry job am.peerLoginExpiry.Cancel(ctx, []string{account.Id}) + am.peerInactivityExpiry.Cancel(ctx, []string{account.Id}) am.certChallenges.Forget(account.Id) meta := map[string]any{"account_id": account.Id, "domain": account.Domain, "created_at": account.CreatedAt} diff --git a/management/server/account_test.go b/management/server/account_test.go index 7aa88ee64..ef7525961 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -4990,3 +4990,27 @@ func TestUpdateUserAuthWithSingleMode(t *testing.T) { assert.Contains(t, err.Error(), "query failed") }) } + +func TestDefaultAccountManager_ExpirationJobsStopForDeletedAccount(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + ctx := context.Background() + accountID, err := manager.GetAccountIDByUserID(ctx, auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + account, err := manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err, "unable to get account") + require.NoError(t, manager.Store.DeleteAccount(ctx, account), "unable to delete account") + + jobs := map[string]func() (time.Duration, bool){ + "login": manager.peerLoginExpirationJob(ctx, accountID), + "inactivity": manager.peerInactivityExpirationJob(ctx, accountID), + } + for name, job := range jobs { + t.Run(name, func(t *testing.T) { + _, reschedule := job() + assert.False(t, reschedule, "job must not reschedule for a deleted account") + }) + } +} diff --git a/management/server/peer.go b/management/server/peer.go index 1e3f72a3c..f21e5ebc9 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1702,6 +1702,9 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if isNotFound(err) { + return 0, false + } if err != nil { log.WithContext(ctx).Errorf("failed to get account settings: %v", err) return peerSchedulerRetryInterval, true @@ -1745,6 +1748,9 @@ func (am *DefaultAccountManager) getNextInactivePeerExpiration(ctx context.Conte } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if isNotFound(err) { + return 0, false + } if err != nil { log.WithContext(ctx).Errorf("failed to get account settings: %v", err) return peerSchedulerRetryInterval, true From dd830f089086790e988b345e5a5f9415ce8e3e15 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:26:04 +0200 Subject: [PATCH 68/70] [management] clean nul byte strings in peer meta (#8219) --- .../internals/shared/grpc/peer_meta_test.go | 45 +++++++++++++++++++ management/internals/shared/grpc/server.go | 41 +++++++++-------- 2 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 management/internals/shared/grpc/peer_meta_test.go diff --git a/management/internals/shared/grpc/peer_meta_test.go b/management/internals/shared/grpc/peer_meta_test.go new file mode 100644 index 000000000..c6336ff1b --- /dev/null +++ b/management/internals/shared/grpc/peer_meta_test.go @@ -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) + } +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 51023c34d..b48a86d55 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -553,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())) @@ -567,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(), @@ -618,6 +618,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 { @@ -711,7 +716,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(), From afd5c6df6a26eec3c0a81df2374dfe1ce9983ea8 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:36 +0200 Subject: [PATCH 69/70] [management] fix proxy conn lifecycle during acks (#8220) --- management/internals/shared/grpc/proxy.go | 57 ++++++++++++--- .../shared/grpc/sync_mappings_test.go | 72 +++++++++++++++++++ proxy/server.go | 8 +++ 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 8e93f77b0..439ff1dba 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -152,6 +152,10 @@ 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 } @@ -168,6 +172,8 @@ 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 { @@ -185,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 @@ -484,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) } @@ -728,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 } } @@ -740,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 } } @@ -748,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 diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go index 6949b79e9..753142ea0 100644 --- a/management/internals/shared/grpc/sync_mappings_test.go +++ b/management/internals/shared/grpc/sync_mappings_test.go @@ -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") +} diff --git a/proxy/server.go b/proxy/server.go index 9ad81b50f..3bba38644 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -1303,6 +1303,10 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities { } func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + // Canceling closes the stream on management; a bare return leaves it open. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + connectTime := time.Now() mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ ProxyId: s.ID, @@ -1325,6 +1329,10 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ } func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + // Canceling closes the stream on management; a bare return leaves it open. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + connectTime := time.Now() stream, err := client.SyncMappings(ctx) if err != nil { From 28657607c14d4b5135c48cb79fcb9f4fd0600b42 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 16 Oct 2026 02:16:45 +0200 Subject: [PATCH 70/70] [client] Keep interface discovery out of the bind open path (#8226) * [client] Keep interface discovery out of the bind open path Since 0.81.0 a userspace-mode agent whose interface name is not covered by the interface blacklist deadlocks while bringing the device up, and the daemon, the GUI and the service stop all hang. wireguard-go holds the device net lock across bind.Open, our bind builds the UDP mux there, the mux enumerated interfaces in its constructor, and the interface filter probed every unlisted name through wgctrl. For the agent's own interface that probe connected to the daemon's own UAPI endpoint and waited, with no deadline, for a reply the handler could only give after taking the same lock. #7346 moved the first enumeration from the stdnet constructor, which ran before the device existed, to this call site. The mux no longer discovers interfaces when it is constructed. It records whether its socket is bound to an unspecified address and GetConn uses that instead of the length of the discovered list, while GetListenAddresses keeps refreshing on demand, which pion calls during candidate gathering outside the device locks. The WireGuard detector also answers for the agent's own interface without probing, so no filter asks the daemon about itself. * [client] Take the agent's own interface name in the detector constructor Every device implementation reports the configured name, so there is no later name to follow and the setter was state without a reader that needed it. The detector takes the name once and compares without a lock. * [client] Create the default mux Net in the constructor With discovery moved to GetListenAddresses, the nil Net fallback ran there as well, and the first concurrent callers raced on writing it. stdnet.NewNet does no discovery, so the constructor can create it before the mux is shared and the address refresh only reads it. * [client] Follow the created interface name in the detector Android ignores the configured interface name and keeps the TUN name returned by CreateUnmonitoredTUNFromFD, so the detector built from the configuration would still probe the agent's real interface. The engine updates the detector with wgInterface.Name() once the interface exists. * [client] Keep the detector's own interface name immutable On Android the kernel names the TUN and the name can change again on renew, while the UAPI socket cannot be opened there at all, so neither the probe nor a name set after creation can identify the agent's own interface reliably. Excluding that interface on Android belongs to the interface discovery the app provides. Everywhere else the configured name is the created name, so the constructor argument is enough and the detector needs no setter and no lock on the hot path. --- client/iface/bind/ice_bind_test.go | 88 ++++++++++++++++++++ client/iface/udpmux/mux.go | 20 ++--- client/iface/udpmux/mux_test.go | 99 +++++++++++++++++++++++ client/internal/engine.go | 2 +- client/internal/stdnet/wgdetector.go | 19 ++++- client/internal/stdnet/wgdetector_test.go | 36 +++++++++ 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 client/iface/udpmux/mux_test.go diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go index 0b8db7640..8dd82d7df 100644 --- a/client/iface/bind/ice_bind_test.go +++ b/client/iface/bind/ice_bind_test.go @@ -2,17 +2,22 @@ package bind import ( "fmt" + "io" "net" "net/netip" "sync" + "sync/atomic" "testing" "time" + "github.com/pion/transport/v3" "github.com/pion/transport/v3/stdnet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -326,3 +331,86 @@ func listenUDP(t *testing.T, network, addr string) *net.UDPConn { require.NoError(t, err) return conn } + +type countingNet struct { + transport.Net + calls atomic.Int64 + ifaces []*transport.Interface + before func() +} + +func (n *countingNet) Interfaces() ([]*transport.Interface, error) { + n.calls.Add(1) + if n.before != nil { + n.before() + } + return n.ifaces, nil +} + +func TestICEBind_OpenDoesNotDiscoverInterfaces(t *testing.T) { + n := newCountingNet() + iceBind := NewICEBind(n, testAddress(), 1280) + + for i := 0; i < 2; i++ { + _, port, err := iceBind.Open(0) + require.NoError(t, err) + require.NotZero(t, port, "the bind listens on a port") + assert.Equal(t, int64(0), n.calls.Load(), "opening the bind must not enumerate interfaces, attempt %d", i+1) + require.NoError(t, iceBind.Close()) + } + + _, _, err := iceBind.Open(0) + require.NoError(t, err) + t.Cleanup(func() { _ = iceBind.Close() }) + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "listen addresses come from the discovered interfaces") + assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered when the listen addresses are asked for") +} + +func TestICEBind_DeviceUpDoesNotWaitOnInterfaceDiscovery(t *testing.T) { + tunDev, _, err := netstack.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280) + require.NoError(t, err) + + var dev *device.Device + n := newCountingNet() + n.before = func() { + _ = dev.IpcGetOperation(io.Discard) + } + iceBind := NewICEBind(n, testAddress(), 1280) + dev = device.NewDevice(tunDev, iceBind, device.NewLogger(device.LogLevelSilent, "")) + + upDone := make(chan error, 1) + go func() { + upDone <- dev.Up() + }() + + select { + case err := <-upDone: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("device Up did not return: interface discovery ran under the device lock and waited on the device itself") + } + t.Cleanup(dev.Close) + + assert.Equal(t, int64(0), n.calls.Load(), "bringing the device up must not enumerate interfaces") + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "discovery that talks to the device succeeds once the device is up") + assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered outside the device lock") +} + +func newCountingNet() *countingNet { + ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: "eth0", Flags: net.FlagUp | net.FlagMulticast}) + ifc.AddAddress(&net.IPNet{IP: net.IPv4(192, 168, 1, 10), Mask: net.CIDRMask(24, 32)}) + return &countingNet{ifaces: []*transport.Interface{ifc}} +} + +func testAddress() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/10"), + } +} diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index 3aa0b4d88..2f7287b53 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -47,6 +47,7 @@ type SingleSocketUDPMux struct { // for UDP connection listen at unspecified address localAddrsForUnspecified []net.Addr + unspecified bool } const maxAddrSize = 512 @@ -157,6 +158,9 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { if params.Logger == nil { params.Logger = getLogger() } + if params.Net == nil { + params.Net = stdnet.NewNet(context.Background(), nil, nil) + } mux := &SingleSocketUDPMux{ addressMap: map[string][]*udpMuxedConn{}, @@ -165,6 +169,7 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { connsIPv6: make(map[string]*udpMuxedConn), candidateConnMap: make(map[string]*udpMuxedConn), closedChan: make(chan struct{}, 1), + unspecified: isUnspecified(params.UDPConn.LocalAddr()), pool: &sync.Pool{ New: func() interface{} { // big enough buffer to fit both packet and address @@ -173,7 +178,6 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { }, } - mux.updateLocalAddresses() return mux } @@ -199,10 +203,6 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() { m.params.Logger.Errorf("LocalAddr expected IPV4 or IPV6, got %T", m.params.UDPConn.LocalAddr()) } if len(networks) > 0 { - if m.params.Net == nil { - m.params.Net = stdnet.NewNet(context.Background(), nil, nil) - } - ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true) if err == nil { for _, ip := range ips { @@ -241,10 +241,7 @@ func (m *SingleSocketUDPMux) GetListenAddresses() []net.Addr { // creates the connection if an existing one can't be found func (m *SingleSocketUDPMux) GetConn(ufrag string, addr net.Addr, candidateID string) (net.PacketConn, error) { // don't check addr for mux using unspecified address - m.mu.Lock() - lenLocalAddrs := len(m.localAddrsForUnspecified) - m.mu.Unlock() - if lenLocalAddrs == 0 && m.params.UDPConn.LocalAddr().String() != addr.String() { + if !m.unspecified && m.params.UDPConn.LocalAddr().String() != addr.String() { return nil, fmt.Errorf("invalid address %s", addr.String()) } @@ -508,3 +505,8 @@ func getLogger() logging.LeveledLogger { //fac.Writer = log.StandardLogger().Writer() return fac.NewLogger("ice") } + +func isUnspecified(addr net.Addr) bool { + udpAddr, ok := addr.(*net.UDPAddr) + return ok && udpAddr.IP.IsUnspecified() +} diff --git a/client/iface/udpmux/mux_test.go b/client/iface/udpmux/mux_test.go new file mode 100644 index 000000000..2e2770282 --- /dev/null +++ b/client/iface/udpmux/mux_test.go @@ -0,0 +1,99 @@ +package udpmux + +import ( + "fmt" + "net" + "sync" + "sync/atomic" + "testing" + + "github.com/pion/transport/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type countingNet struct { + transport.Net + calls atomic.Int64 + ifaces []*transport.Interface +} + +func (n *countingNet) Interfaces() ([]*transport.Interface, error) { + n.calls.Add(1) + return n.ifaces, nil +} + +func TestNewSingleSocketUDPMux_DoesNotDiscoverInterfaces(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + n := &countingNet{ifaces: []*transport.Interface{testInterface("eth0", net.IPv4(192, 168, 1, 10))}} + + mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n}) + t.Cleanup(func() { _ = mux.Close() }) + + assert.Equal(t, int64(0), n.calls.Load(), "constructing the mux must not enumerate interfaces") + + addrs := mux.GetListenAddresses() + assert.Equal(t, int64(1), n.calls.Load(), "listen addresses are discovered when they are asked for") + + port := conn.LocalAddr().(*net.UDPAddr).Port + require.Len(t, addrs, 1, "one address per discovered interface IP") + assert.Equal(t, fmt.Sprintf("192.168.1.10:%d", port), addrs[0].String(), "the discovered IP is paired with the socket port") +} + +func TestSingleSocketUDPMux_GetConnWithoutListenAddresses(t *testing.T) { + t.Run("unspecified socket accepts any address", func(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + n := &countingNet{} + mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n}) + t.Cleanup(func() { _ = mux.Close() }) + + port := conn.LocalAddr().(*net.UDPAddr).Port + pc, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: port}, "candidate") + require.NoError(t, err) + assert.NotNil(t, pc, "a connection is created for a local address of an unspecified socket") + assert.Equal(t, int64(0), n.calls.Load(), "GetConn must not depend on a prior discovery") + }) + + t.Run("specific socket rejects a foreign address", func(t *testing.T) { + conn := listenUDP(t, net.IPv4(127, 0, 0, 1)) + mux := NewSingleSocketUDPMux(Params{UDPConn: conn}) + t.Cleanup(func() { _ = mux.Close() }) + + _, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: 1234}, "candidate") + assert.Error(t, err, "an address the socket is not bound to must be rejected") + + pc, err := mux.GetConn("ufrag", conn.LocalAddr(), "candidate") + require.NoError(t, err) + assert.NotNil(t, pc, "the bound address is accepted") + }) +} + +func listenUDP(t *testing.T, ip net.IP) *net.UDPConn { + t.Helper() + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func testInterface(name string, ip net.IP) *transport.Interface { + ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: name, Flags: net.FlagUp | net.FlagMulticast}) + ifc.AddAddress(&net.IPNet{IP: ip, Mask: net.CIDRMask(24, 32)}) + return ifc +} + +func TestSingleSocketUDPMux_ConcurrentListenAddressesWithDefaultNet(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + mux := NewSingleSocketUDPMux(Params{UDPConn: conn}) + t.Cleanup(func() { _ = mux.Close() }) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + assert.NotEmpty(t, mux.GetListenAddresses(), "every caller gets at least the socket address") + }() + } + wg.Wait() +} diff --git a/client/internal/engine.go b/client/internal/engine.go index fdf89ae06..c4bdf5121 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -387,7 +387,7 @@ func NewEngine( mgmClient: services.MgmClient, relayManager: services.RelayManager, peerStore: peerstore.NewConnStore(), - wgDetector: stdnet.NewWGDetector(), + wgDetector: stdnet.NewWGDetector(config.WgIfaceName), syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, diff --git a/client/internal/stdnet/wgdetector.go b/client/internal/stdnet/wgdetector.go index f34e469e1..8ed290190 100644 --- a/client/internal/stdnet/wgdetector.go +++ b/client/internal/stdnet/wgdetector.go @@ -34,6 +34,8 @@ type WGDetector struct { // probe is replaced in tests; it is the call this type exists to avoid repeating. probe func(string) bool + ownIface string + mu sync.RWMutex cache map[string]wgDetectorEntry @@ -41,11 +43,12 @@ type WGDetector struct { } // NewWGDetector returns a detector with the default time to live. -func NewWGDetector() *WGDetector { +func NewWGDetector(ownIface string) *WGDetector { return &WGDetector{ - ttl: wgDetectorTTL, - probe: probeWireGuard, - cache: make(map[string]wgDetectorEntry), + ttl: wgDetectorTTL, + probe: probeWireGuard, + ownIface: ownIface, + cache: make(map[string]wgDetectorEntry), } } @@ -57,6 +60,10 @@ func (d *WGDetector) IsWireGuard(iFace string) bool { return probeWireGuard(iFace) } + if d.isOwnInterface(iFace) { + return true + } + if isWireGuard, ok := d.cached(iFace); ok { return isWireGuard } @@ -104,6 +111,10 @@ func (d *WGDetector) store(iFace string, isWireGuard bool) { d.cache[iFace] = wgDetectorEntry{isWireGuard: isWireGuard, expireAt: now.Add(d.ttl)} } +func (d *WGDetector) isOwnInterface(iFace string) bool { + return d.ownIface != "" && iFace == d.ownIface +} + func probeWireGuard(iFace string) bool { wg, err := wgctrl.New() if err != nil { diff --git a/client/internal/stdnet/wgdetector_test.go b/client/internal/stdnet/wgdetector_test.go index 68260ae59..28cf85f6a 100644 --- a/client/internal/stdnet/wgdetector_test.go +++ b/client/internal/stdnet/wgdetector_test.go @@ -150,3 +150,39 @@ func TestWGDetectorDropsExpiredEntries(t *testing.T) { assert.Len(t, d.cache, 1, "expired entries of vanished interfaces must be dropped") assert.Contains(t, d.cache, "eth0", "the fresh answer must be kept") } + +func TestWGDetectorOwnInterfaceIsWireGuardWithoutProbing(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + d.ownIface = "NetBird" + + assert.True(t, d.IsWireGuard("NetBird"), "the agent's own interface is a WireGuard device") + assert.Equal(t, int64(0), calls.Load(), "the own interface must never reach the probe") + + assert.False(t, d.IsWireGuard("NetBird2"), "only the exact own name is answered without probing") + assert.Equal(t, int64(1), calls.Load(), "a different interface is probed") +} + +func TestWGDetectorWithoutOwnInterfaceProbesEmptyName(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + + assert.False(t, d.IsWireGuard(""), "an unnamed interface is not mistaken for the own one") + assert.Equal(t, int64(1), calls.Load(), "with no own interface set every name is probed") +} + +func TestInterfaceFilterRejectsOwnInterfaceRegardlessOfDisallowList(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + d.ownIface = "NetBird" + filter := InterfaceFilter([]string{"wt"}, d) + + assert.False(t, filter("NetBird"), "the own interface is rejected even when the disallow list does not cover it") + assert.Equal(t, int64(0), calls.Load(), "the own interface is not probed") + + assert.True(t, filter("eth0"), "an ordinary interface stays available") + assert.Equal(t, int64(1), calls.Load(), "an ordinary interface is still probed") +} + +func TestNewWGDetectorKeepsTheOwnInterface(t *testing.T) { + d := NewWGDetector("NetbirdWT") + + assert.True(t, d.IsWireGuard("NetbirdWT"), "the constructor records the own interface") +}