Compare commits

..

1 Commits

Author SHA1 Message Date
Zoltan Papp
e2a4fdfe07 [client] Create GUI windows on demand and destroy them on close
The main and Settings windows were created at startup and kept alive hidden on
close, so an idle tray held two webview processes for surfaces the user may
never open. Both are now built on first show and destroyed on close, which
takes the idle footprint on macOS from ~160 MB to ~74 MB.

The WindowManager owns creation: it rebuilds the main window on the next show
and hands out live pointers, since a stored one goes stale. Every show is
deferred until the frontend reports it has rendered, so a freshly created
window is never on screen empty, with a timeout so a frontend that never
reports cannot strand a window hidden.
2026-08-07 14:42:18 +02:00
30 changed files with 445 additions and 1532 deletions

View File

@@ -4,17 +4,11 @@ package metrics
type ConnectionType string
const (
// ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE
ConnectionTypeICEP2P ConnectionType = "ice_p2p"
// ConnectionTypeICETurn represents an ICE connection through a TURN server
ConnectionTypeICETurn ConnectionType = "ice_turn"
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
ConnectionTypeICE ConnectionType = "ice"
// ConnectionTypeRelay represents a relayed connection
ConnectionTypeRelay ConnectionType = "relay"
// ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
ConnectionTypeUnknown ConnectionType = "unknown"
)
// String returns the string representation of the connection type

View File

@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
}
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
var buf bytes.Buffer
err := m.Export(&buf)
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
// Record multiple times and verify consistent field order
for i := 0; i < 10; i++ {
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
}
var buf bytes.Buffer

View File

@@ -56,33 +56,14 @@ Measurement: `netbird_peer_connection`
Tags:
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below)
- `connection_type`: "ice" | "relay"
- `attempt_type`: "initial" | "reconnection"
- `version`: NetBird version string
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
#### `connection_type` values
Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
| Value | Priority | Traffic is |
|-------|----------|------------|
| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
| `ice_turn` | `ICETurn` | relayed, through a TURN server |
| `relay` | `Relay` | relayed, through a NetBird relay |
| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
### Sync Duration
Measurement: `netbird_sync`

View File

@@ -307,8 +307,6 @@ func (conn *Conn) Close(signalToRemote bool) {
if conn.wgWatcherCancel != nil {
conn.wgWatcherCancel()
conn.wgWatcher = nil
conn.wgWatcherCancel = nil
}
conn.workerRelay.CloseConn()
if conn.workerICE != nil {
@@ -961,9 +959,12 @@ func (conn *Conn) recordConnectionMetrics() {
priority := conn.currentConnPriority
conn.mu.Unlock()
connType := metricsConnType(priority)
if connType == metrics.ConnectionTypeUnknown {
return
var connType metrics.ConnectionType
switch priority {
case conntype.Relay:
connType = metrics.ConnectionTypeRelay
default:
connType = metrics.ConnectionTypeICE
}
// Record metrics with timestamps - duration calculation happens in metrics package
@@ -1064,16 +1065,3 @@ func boolToConnStatus(connected bool) guard.ConnStatus {
}
return guard.ConnStatusDisconnected
}
func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType {
switch priority {
case conntype.Relay:
return metrics.ConnectionTypeRelay
case conntype.ICETurn:
return metrics.ConnectionTypeICETurn
case conntype.ICEP2P:
return metrics.ConnectionTypeICEP2P
default:
return metrics.ConnectionTypeUnknown
}
}

View File

@@ -11,8 +11,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface"
"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"
@@ -388,33 +386,3 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
}
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
}
func TestMetricsConnType(t *testing.T) {
tests := []struct {
name string
priority conntype.ConnPriority
expected metrics.ConnectionType
}{
{"relay", conntype.Relay, metrics.ConnectionTypeRelay},
{"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
{"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
{"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
{"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, metricsConnType(tc.priority))
})
}
}
func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
conn := &Conn{currentConnPriority: priority}
tag := metricsConnType(priority)
relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
assert.Equal(t, conn.isRelayed(), relayedTag,
"priority %s: isRelayed and the %q metric tag must agree", priority, tag)
}
}

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
import { Events } from "@wailsio/runtime";
import { useStatus } from "@/contexts/StatusContext.tsx";
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
export const ReadySignal = () => {
const { isReady } = useStatus();
const sent = useRef(false);
useEffect(() => {
if (!isReady || sent.current) return;
sent.current = true;
void Events.Emit(EVENT_WINDOW_PAINTED);
}, [isReady]);
return null;
};

View File

@@ -5,6 +5,7 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
import { ReadySignal } from "@/components/ReadySignal.tsx";
export const AppLayout = () => {
return (
@@ -16,6 +17,7 @@ export const AppLayout = () => {
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
<ReadySignal />
</ClientVersionProvider>
</DebugBundleProvider>
</RestrictionsProvider>

View File

@@ -139,13 +139,11 @@ func main() {
prefStore: prefStore,
})
window := newMainWindow(app, prefStore)
// Settings is created eagerly (hidden) so the first gear click paints
// instantly and React keeps per-tab state across reopens. The other
// auxiliary windows stay lazy + destroy-on-close so Wails's macOS
// dock-reopen handler can't resurrect them.
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
windowManager.SetMainFactory(func() *application.WebviewWindow {
return newMainWindow(app, prefStore, windowManager)
})
registerDockReopenHook(app, windowManager)
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
// position across hide -> show, dropping them top-left. Gate Go-side
// re-centering on that environment; nil leaves placement to the WM on full
@@ -168,7 +166,7 @@ func main() {
// RegisterStatusNotifierItem hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, window, TrayServices{
tray = NewTray(app, nil, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
@@ -338,9 +336,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.compat))
}
// newMainWindow creates the hidden main window, sized to the user's last view
// mode, and installs the hide-on-close and macOS dock-reopen hooks.
func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager) *application.WebviewWindow {
// Width matches the last view mode so Advanced-mode users don't see the
// window pop from 380px to 900px on launch. Height is mode-agnostic.
initialWidth := 380
@@ -368,29 +364,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
},
})
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
if services.ShuttingDown() {
return
}
e.Cancel()
window.Hide()
wm.ForgetMain()
})
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
window.Show()
window.Focus()
})
}
return window
}
func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
if runtime.GOOS != "darwin" {
return
}
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
wm.ShowMain()
})
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -29,6 +30,10 @@ const EventBrowserLoginCancel = "browser-login:cancel"
// EventSettingsOpen tells the mounted settings window which tab to show.
const EventSettingsOpen = "netbird:settings:open"
const EventWindowPainted = "netbird:window-painted"
const paintedFallback = 2 * time.Second
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
// WindowHeight is shared by the main and Settings windows.
@@ -94,9 +99,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
}
}
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
type WindowManager struct {
app *application.App
mainWindow *application.WebviewWindow
@@ -112,15 +114,29 @@ type WindowManager struct {
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
newMain func() *application.WebviewWindow
ready map[uint]bool
showPending map[uint]bool
pendingTab map[uint]string
fallbackTimers map[uint]*time.Timer
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
// Settings window is created here (hidden) so the first OpenSettings is instant.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
s := &WindowManager{
app: app,
mainWindow: mainWindow,
translator: translator,
prefs: prefs,
linuxIcon: linuxIcon,
ready: map[uint]bool{},
showPending: map[uint]bool{},
pendingTab: map[uint]string{},
fallbackTimers: map[uint]*time.Timer{},
}
s.watchPainted()
// Re-title live windows on language flip. Wired internally so the binding generator
// doesn't try to expose the interface param.
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
@@ -136,7 +152,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
}()
}
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
return s
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
@@ -150,18 +170,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
URL: "/#/settings",
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Linux: LinuxAppearanceOptions(linuxIcon),
Linux: LinuxAppearanceOptions(s.linuxIcon),
})
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if ShuttingDown() {
return
}
e.Cancel()
s.app.Event.Emit(EventSettingsOpen, "general")
s.settings.Hide()
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.settings = nil
s.forgetWindowLocked(w)
s.mu.Unlock()
})
return s
return w
}
// OpenSettings shows the settings window on tab (empty → General), switching tab via
@@ -171,11 +188,23 @@ func (s *WindowManager) OpenSettings(tab string) {
if target == "" {
target = "general"
}
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
s.mu.Lock()
fresh := s.settings == nil
if fresh {
s.settings = s.newSettingsWindow()
s.armReady(s.settings)
}
w := s.settings
if fresh {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if !fresh {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
}
// OpenBrowserLogin shows the SSO popup, creating it on first use.
@@ -440,13 +469,150 @@ func (s *WindowManager) OpenMain() {
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
func (s *WindowManager) ShowMain() {
if s.mainWindow == nil {
s.showWhenReady(s.MainWindow())
}
func (s *WindowManager) MainWindow() *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
if s.mainWindow == nil && s.newMain != nil {
s.mainWindow = s.newMain()
s.armReady(s.mainWindow)
}
return s.mainWindow
}
func (s *WindowManager) armReady(w *application.WebviewWindow) {
if w == nil {
return
}
s.mainWindow.Show()
s.mainWindow.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
timer := time.AfterFunc(paintedFallback, func() {
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
s.markReady(w)
})
s.mu.Lock()
s.fallbackTimers[w.ID()] = timer
s.mu.Unlock()
})
}
func (s *WindowManager) watchPainted() {
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
if w := s.windowByName(e.Sender); w != nil {
s.markReady(w)
}
})
}
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
}
delete(s.fallbackTimers, id)
delete(s.ready, id)
delete(s.showPending, id)
delete(s.pendingTab, id)
kept := s.hiddenForLogin[:0]
for _, hidden := range s.hiddenForLogin {
if hidden != application.Window(w) {
kept = append(kept, hidden)
}
}
s.hiddenForLogin = kept
}
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
switch name {
case "main":
return s.mainWindow
case "settings":
return s.settings
default:
return nil
}
}
func (s *WindowManager) markReady(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
already := s.ready[id]
s.ready[id] = true
wanted := s.showPending[id]
tab, hasTab := s.pendingTab[id]
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
delete(s.fallbackTimers, id)
}
delete(s.showPending, id)
delete(s.pendingTab, id)
s.mu.Unlock()
if already {
return
}
if hasTab {
s.app.Event.Emit(EventSettingsOpen, tab)
}
if wanted {
s.showNow(w)
}
}
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
s.showPending[id] = true
}
s.mu.Unlock()
if ready {
s.showNow(w)
}
}
func (s *WindowManager) showNow(w *application.WebviewWindow) {
w.Show()
w.Focus()
s.centerWhenReady(w)
}
func (s *WindowManager) ShowMainAt(url string) {
w := s.MainWindow()
if w == nil {
return
}
w.SetURL(url)
s.showWhenReady(w)
}
func (s *WindowManager) SetMainFactory(f func() *application.WebviewWindow) {
s.mu.Lock()
defer s.mu.Unlock()
s.newMain = f
}
func (s *WindowManager) ForgetMain() {
s.mu.Lock()
defer s.mu.Unlock()
s.forgetWindowLocked(s.mainWindow)
s.mainWindow = nil
}
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).

View File

@@ -174,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -241,9 +241,6 @@ func (t *Tray) ShowWindow() {
w.Focus()
return
}
if t.window == nil {
return
}
// Route through WindowManager so the main window is centered on first
// show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in
// the top-left corner.
@@ -251,8 +248,40 @@ func (t *Tray) ShowWindow() {
t.svc.WindowManager.ShowMain()
return
}
t.window.Show()
t.window.Focus()
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
func (t *Tray) mainWindow() *application.WebviewWindow {
if t.svc.WindowManager == nil {
return t.window
}
return t.svc.WindowManager.MainWindow()
}
func (t *Tray) showMainAt(url string) {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMainAt(url)
return
}
if w := t.mainWindow(); w != nil {
w.SetURL(url)
w.Show()
w.Focus()
}
}
func (t *Tray) showMain() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current

View File

@@ -30,10 +30,7 @@ const (
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.Show()
t.window.Focus()
}
t.showMain()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -307,6 +304,7 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
t.showMain()
t.app.Event.Emit(services.EventTriggerLogin)
return
}

View File

@@ -19,7 +19,7 @@ import (
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
type trayUpdater struct {
app *application.App
window *application.WebviewWindow
showMainAt func(url string)
update *services.Update
notifier *Notifier
loc *Localizer
@@ -36,10 +36,10 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
showMainAt: showMainAt,
update: update,
notifier: notifier,
loc: loc,
@@ -185,14 +185,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
// openProgressWindow points the main window at the /update progress page and
// brings it forward.
func (u *trayUpdater) openProgressWindow(version string) {
if u.window == nil {
if u.showMainAt == nil {
return
}
url := "/#/update"
if version != "" {
url += "?version=" + version
}
u.window.SetURL(url)
u.window.Show()
u.window.Focus()
u.showMainAt(url)
}

View File

@@ -173,11 +173,11 @@ EOF
# ---------------------------------------------------------------------------
detect_combined_service() {
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE"
}
detect_dashboard_service() {
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/dashboard([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE"
}
detect_config_yaml_host_path() {
@@ -661,12 +661,12 @@ init_migration() {
COMPOSE_NETWORK=$(detect_compose_network)
if [[ -z "$COMBINED_SERVICE" ]]; then
echo "Could not find a service running netbirdio/netbird-server or ghcr.io/netbirdio/netbird-server in $COMPOSE_FILE." > /dev/stderr
echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr
echo "This script targets the community combined-server deployment." > /dev/stderr
exit 1
fi
if [[ -z "$DASHBOARD_SERVICE" ]]; then
echo "Could not find a service running netbirdio/dashboard or ghcr.io/netbirdio/dashboard in $COMPOSE_FILE." > /dev/stderr
echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr
exit 1
fi
if [[ -z "$CONFIG_YAML_HOST" ]]; then

View File

@@ -176,7 +176,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
semaphore := make(chan struct{}, 10)
c.injectAllProxyPolicies(ctx, account)
account.PrecomputePostureValidation(ctx)
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
@@ -358,7 +357,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
// network map that omitted the synth DNS zone, and the agent kept
// resolving against the stale or absent record.
c.injectAllProxyPolicies(ctx, account)
account.PrecomputePostureValidation(ctx)
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)

View File

@@ -29,7 +29,6 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
@@ -37,6 +36,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -1579,55 +1579,9 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
return verifier, redirectURL, nil
}
// Denied reasons reported to the proxy when access is refused because of the
// account status of the user behind the request.
const (
deniedReasonPendingApproval = "pending_approval"
deniedReasonUserBlocked = "user_blocked"
deniedReasonUserNotFound = "user_not_found"
)
var (
// ErrUserPendingApproval reports a user whose account still awaits approval
// by an administrator and may therefore not hold a proxy session.
ErrUserPendingApproval = errors.New("user pending approval")
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
ErrUserBlocked = errors.New("user blocked")
errUserUnresolved = errors.New("user could not be resolved")
)
// checkUserStatus reports whether the user's account status permits reverse
// proxy access, returning the denied reason for the proxy access log together
// with the sentinel error callers match on. A user awaiting approval is stored
// as both pending and blocked, so the pending state is reported first: it is
// the one an administrator can act on.
func checkUserStatus(user *types.User) (string, error) {
switch {
case user == nil:
return deniedReasonUserNotFound, errUserUnresolved
case user.PendingApproval:
return deniedReasonPendingApproval, ErrUserPendingApproval
case user.IsBlocked():
return deniedReasonUserBlocked, ErrUserBlocked
default:
return "", nil
}
}
// userStatusDeniedReason returns the denied reason for callers that report a
// decision rather than an error, and an empty string when the user may proceed.
func userStatusDeniedReason(user *types.User) string {
reason, _ := checkUserStatus(user)
return reason
}
// GenerateSessionToken creates a signed session JWT for the given domain and
// user. The user's group memberships are embedded in the token so policy-aware
// middlewares on the proxy can authorise without an extra management round-trip.
// A user the store cannot resolve, or whose account is pending approval or
// blocked, gets no token at all, so the browser never receives a session cookie.
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
service, err := s.getServiceByDomain(ctx, domain)
if err != nil {
@@ -1638,25 +1592,25 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
return "", fmt.Errorf("no session key configured for domain: %s", domain)
}
if s.usersManager == nil {
return "", errors.New("users manager not configured")
var (
email string
groupIDs []string
groupNames []string
)
if s.usersManager != nil {
user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
if uerr != nil {
log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
} else if user != nil {
email = user.Email
groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
}
}
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil {
return "", fmt.Errorf("get user %s: %w", userID, err)
}
if _, err := checkUserStatus(user); err != nil {
return "", fmt.Errorf("session token for user %s: %w", userID, err)
}
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return sessionkey.SignToken(
service.SessionPrivateKey,
userID,
user.Email,
email,
domain,
method,
groupIDs,
@@ -1674,10 +1628,6 @@ func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain
return fmt.Errorf("user not found: %s", userID)
}
if _, err := checkUserStatus(user); err != nil {
return fmt.Errorf("user %s denied access to domain %s: %w", userID, domain, err)
}
service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain)
if err != nil {
return err
@@ -1732,7 +1682,10 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
sessionToken := req.GetSessionToken()
if domain == "" || sessionToken == "" {
return deniedSessionResponse("missing domain or session_token"), nil
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "missing domain or session_token",
}, nil
}
service, err := s.getServiceByDomain(ctx, domain)
@@ -1742,31 +1695,56 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"error": err.Error(),
}).Debug("ValidateSession: service not found")
//nolint:nilerr
return deniedSessionResponse("service_not_found"), nil
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "service_not_found",
}, nil
}
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
return nil, err
}
userID, reason := sessionTokenSubject(domain, service, sessionToken)
if reason != "" {
return deniedSessionResponse(reason), nil
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Error("ValidateSession: decode public key")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "invalid_service_config",
}, nil
}
userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Debug("ValidateSession: invalid session token")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "invalid_token",
}, nil
}
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil || user == nil {
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": userID,
"error": err,
"error": err.Error(),
}).Debug("ValidateSession: user not found")
//nolint:nilerr
return deniedSessionResponse(deniedReasonUserNotFound), nil
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "user_not_found",
}, nil
}
// A user from another account gets a bare response: none of their identity
// belongs in an answer to a proxy serving a different account.
if user.AccountID != service.AccountID {
log.WithFields(log.Fields{
"domain": domain,
@@ -1774,17 +1752,26 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"user_account": user.AccountID,
"service_account": service.AccountID,
}).Debug("ValidateSession: user account mismatch")
return deniedSessionResponse("account_mismatch"), nil
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "account_mismatch",
}, nil
}
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
if err := s.checkGroupAccess(service, user); err != nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": userID,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
UserId: user.Id,
UserEmail: user.Email,
DeniedReason: reason,
DeniedReason: "not_in_group",
PeerGroupIds: groupIDs,
PeerGroupNames: groupNames,
}, nil
@@ -1796,6 +1783,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"email": user.Email,
}).Debug("ValidateSession: access granted")
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return &proto.ValidateSessionResponse{
Valid: true,
UserId: user.Id,
@@ -1805,66 +1793,6 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
// deniedSessionResponse builds a denial that carries no identity, for the
// checks that run before a user of this service's account is resolved.
func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: reason,
}
}
// sessionTokenSubject verifies the session token against the service's session
// key and returns the user it was minted for, or the reason it cannot be
// trusted.
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Error("ValidateSession: decode public key")
return "", "invalid_service_config"
}
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Debug("ValidateSession: invalid session token")
return "", "invalid_token"
}
return userID, ""
}
// accountUserDeniedReason gates a user of the service's own account, returning
// an empty string when access is granted. Account status comes before group
// membership: a user awaiting approval or blocked has no access regardless of
// the groups they were auto-assigned.
func (s *ProxyServiceServer) accountUserDeniedReason(domain string, service *rpservice.Service, user *types.User) string {
if reason := userStatusDeniedReason(user); reason != "" {
log.WithFields(log.Fields{
"domain": domain,
"user_id": user.Id,
"reason": reason,
}).Debug("ValidateSession: user status denies access")
return reason
}
if err := s.checkGroupAccess(service, user); err != nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": user.Id,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
return "not_in_group"
}
return ""
}
func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
service, err := s.serviceManager.GetServiceByDomain(ctx, domain)
if err == nil {
@@ -1981,18 +1909,6 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
if reason := s.peerOwnerDeniedReason(ctx, peer); reason != "" {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "user_id": peer.UserID, "reason": reason}).Debug("ValidateTunnelPeer: owner status denies access")
return &proto.ValidateTunnelPeerResponse{
Valid: false,
UserId: principalID,
UserEmail: displayIdentity,
DeniedReason: reason,
PeerGroupIds: groupIDs,
PeerGroupNames: groupNames,
}, nil
}
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
//nolint:nilerr
@@ -2028,25 +1944,6 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}, nil
}
// peerOwnerDeniedReason gates the mesh fast-path on the account status of the
// peer's owning user, so a user blocked after registering a peer loses
// mesh-origin access too. Unlinked peers (machine agents) have no owner to gate
// on and stay first-class callers. An owner the store cannot resolve denies:
// an unavailable lookup must not grant access.
func (s *ProxyServiceServer) peerOwnerDeniedReason(ctx context.Context, peer *peer.Peer) string {
if peer.UserID == "" {
return ""
}
user, err := s.usersManager.GetUser(ctx, peer.UserID)
if err != nil {
log.WithContext(ctx).Debugf("ValidateTunnelPeer: look up owner %s of peer %s: %v", peer.UserID, peer.ID, err)
return deniedReasonUserNotFound
}
return userStatusDeniedReason(user)
}
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
// user or peer ID, and peer name or user email.
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {

View File

@@ -350,64 +350,6 @@ func TestValidateUserGroupAccess(t *testing.T) {
},
expectErr: false,
},
{
name: "user pending approval denied despite group membership",
domain: "app.example.com",
userID: "user1",
proxiesByAccount: map[string][]*service.Service{
"account1": {{
Domain: "app.example.com",
AccountID: "account1",
Auth: service.AuthConfig{
BearerAuth: &service.BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{"group1"},
},
},
}},
},
users: map[string]*types.User{
// The approval flow stores a pending user as blocked as well.
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true, PendingApproval: true},
},
expectErr: true,
expectErrMsg: "user pending approval",
},
{
name: "blocked user denied despite group membership",
domain: "app.example.com",
userID: "user1",
proxiesByAccount: map[string][]*service.Service{
"account1": {{
Domain: "app.example.com",
AccountID: "account1",
Auth: service.AuthConfig{
BearerAuth: &service.BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{"group1"},
},
},
}},
},
users: map[string]*types.User{
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true},
},
expectErr: true,
expectErrMsg: "user blocked",
},
{
name: "blocked user denied on a service with no auth configured",
domain: "app.example.com",
userID: "user1",
proxiesByAccount: map[string][]*service.Service{
"account1": {{Domain: "app.example.com", AccountID: "account1", Auth: service.AuthConfig{}}},
},
users: map[string]*types.User{
"user1": {Id: "user1", AccountID: "account1", Blocked: true},
},
expectErr: true,
expectErrMsg: "user blocked",
},
{
name: "proxy manager error",
domain: "app.example.com",
@@ -479,18 +421,17 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}}
tests := []struct {
name string
peerUserID string
storedUsers map[string]*types.User
storedErr error
noIdP bool
idpEmail string
idpHasData bool
idpErr error
expectEmail string
expectUserID string
expectIdPHit bool
expectDeniedReason string
name string
peerUserID string
storedUsers map[string]*types.User
storedErr error
noIdP bool
idpEmail string
idpHasData bool
idpErr error
expectEmail string
expectUserID string
expectIdPHit bool
}{
{
name: "idp email wins over stored email",
@@ -549,17 +490,14 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
expectIdPHit: true,
},
{
// The identity still resolves from the IdP, but an owner the store
// cannot resolve denies the fast-path rather than granting it.
name: "idp email when stored user missing keeps peer.UserID as principal",
peerUserID: userID,
storedUsers: map[string]*types.User{},
idpEmail: "idp@example.com",
idpHasData: true,
expectEmail: "idp@example.com",
expectUserID: userID,
expectIdPHit: true,
expectDeniedReason: deniedReasonUserNotFound,
name: "idp email when stored user missing keeps peer.UserID as principal",
peerUserID: userID,
storedUsers: map[string]*types.User{},
idpEmail: "idp@example.com",
idpHasData: true,
expectEmail: "idp@example.com",
expectUserID: userID,
expectIdPHit: true,
},
{
name: "unlinked peer uses peer name and never consults idp",
@@ -607,13 +545,9 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
assert.True(t, resp.GetValid(), "expected access granted")
assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
assert.Equal(t, tt.expectUserID, resp.GetUserId())
if tt.expectDeniedReason != "" {
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
}
if idpMock != nil {
if tt.expectIdPHit {
@@ -628,87 +562,6 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
}
}
// TestDeniedReasonValues pins the wire values of the account status denied
// reasons. The proxy logs them and operators filter access logs on them, so a
// rename is a breaking change rather than an internal detail.
func TestDeniedReasonValues(t *testing.T) {
assert.Equal(t, "pending_approval", deniedReasonPendingApproval, "pending approval denied reason wire value")
assert.Equal(t, "user_blocked", deniedReasonUserBlocked, "blocked user denied reason wire value")
assert.Equal(t, "user_not_found", deniedReasonUserNotFound, "unresolved user denied reason wire value")
}
// TestValidateTunnelPeerOwnerStatus verifies that the mesh fast-path gates on
// the account status of the peer's owning user. A peer whose owner was blocked
// after the peer registered must lose access, while an unlinked machine peer
// keeps it.
func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
const (
domain = "app.example.com"
accountID = "account1"
peerID = "peer1"
peerName = "peer-display-name"
userID = "user1"
)
tests := []struct {
name string
peerUserID string
owner *types.User
expectDeniedReason string
}{
{
name: "active owner allowed",
peerUserID: userID,
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com"},
},
{
name: "owner pending approval denied",
peerUserID: userID,
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true, PendingApproval: true},
expectDeniedReason: deniedReasonPendingApproval,
},
{
name: "owner blocked after registering the peer denied",
peerUserID: userID,
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true},
expectDeniedReason: deniedReasonUserBlocked,
},
{
name: "unlinked machine peer stays allowed",
peerUserID: "",
owner: &types.User{Id: userID, AccountID: accountID, Blocked: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &service.Service{Domain: domain, AccountID: accountID}
server := &ProxyServiceServer{
serviceManager: &mockReverseProxyManager{
proxiesByAccount: map[string][]*service.Service{accountID: {svc}},
},
peersManager: &mockTunnelPeersManager{
peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID},
},
usersManager: &mockUsersManager{users: map[string]*types.User{userID: tt.owner}},
}
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
Domain: domain,
TunnelIp: "100.64.0.1",
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
if tt.expectDeniedReason != "" {
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
}
})
}
}
func TestGetAccountProxyByDomain(t *testing.T) {
tests := []struct {
name string

View File

@@ -46,7 +46,6 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
proxyService.SetServiceManager(serviceManager)
createTestProxies(t, ctx, testStore)
createStatusTestUsers(t, ctx, testStore)
return &validateSessionTestSetup{
proxyService: proxyService,
@@ -92,82 +91,6 @@ func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store)
},
}
require.NoError(t, testStore.CreateService(ctx, restrictedProxy))
// Distributed to the account's "All" group, the configuration that hands a
// service to every user in the account.
allUsersProxy := &service.Service{
ID: "allUsersProxyId",
AccountID: "testAccountId",
Name: "All Users Proxy",
Domain: "all-users-proxy.example.com",
Enabled: true,
SessionPrivateKey: privKey,
SessionPublicKey: pubKey,
Auth: service.AuthConfig{
BearerAuth: &service.BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{allUsersGroupID},
},
},
}
require.NoError(t, testStore.CreateService(ctx, allUsersProxy))
}
const (
allUsersGroupID = "allUsersGroupId"
pendingUserID = "pendingUserId"
blockedUserID = "blockedUserId"
pendingAllUsersID = "pendingAllUsersUserId"
)
// createStatusTestUsers adds the users whose account status must keep them out
// of a proxy session. A user awaiting approval is persisted as both blocked and
// pending approval, the way the approval flow stores one.
func createStatusTestUsers(t *testing.T, ctx context.Context, testStore store.Store) {
t.Helper()
require.NoError(t, testStore.CreateGroup(ctx, &types.Group{
ID: allUsersGroupID,
AccountID: "testAccountId",
Name: "All",
Issued: types.GroupIssuedAPI,
}))
users := []*types.User{
{
Id: pendingUserID,
AccountID: "testAccountId",
Role: types.UserRoleUser,
AutoGroups: []string{"allowedGroupId"},
Blocked: true,
PendingApproval: true,
Issued: "api",
CreatedAt: time.Now(),
},
{
Id: pendingAllUsersID,
AccountID: "testAccountId",
Role: types.UserRoleUser,
AutoGroups: []string{allUsersGroupID},
Blocked: true,
PendingApproval: true,
Issued: "api",
CreatedAt: time.Now(),
},
{
Id: blockedUserID,
AccountID: "testAccountId",
Role: types.UserRoleUser,
AutoGroups: []string{"allowedGroupId"},
Blocked: true,
PendingApproval: false,
Issued: "api",
CreatedAt: time.Now(),
},
}
for _, user := range users {
require.NoError(t, testStore.SaveUser(ctx, user))
}
}
func generateSessionKeyPair(t *testing.T) (string, string) {
@@ -226,114 +149,6 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
}
// TestValidateSession_PendingApprovalUserDenied covers a user who is a member of
// the service's distribution group but is still waiting for an administrator to
// approve the account. Group membership alone must not open the service.
func TestValidateSession_PendingApprovalUserDenied(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
require.NoError(t, err)
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
Domain: "restricted-proxy.example.com",
SessionToken: token,
})
require.NoError(t, err)
assert.False(t, resp.Valid, "User pending approval should be denied")
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
assert.Equal(t, pendingUserID, resp.UserId, "Denial should identify the user it applies to")
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
assert.Equal(t, []string{"Allowed Group"}, resp.GetPeerGroupNames(), "PeerGroupNames must pair with PeerGroupIds on denial")
}
// TestValidateSession_PendingApprovalUserInAllUsersGroupDenied covers the same
// user against a service distributed to the account's "All" group, where every
// user of the account is a member by default.
func TestValidateSession_PendingApprovalUserInAllUsersGroupDenied(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "allUsersProxyId")
require.NoError(t, err)
token := createSessionToken(t, proxy.SessionPrivateKey, pendingAllUsersID, "all-users-proxy.example.com")
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
Domain: "all-users-proxy.example.com",
SessionToken: token,
})
require.NoError(t, err)
assert.False(t, resp.Valid, "User pending approval should be denied even in the All Users group")
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
assert.Equal(t, pendingAllUsersID, resp.UserId, "Denial should identify the user it applies to")
assert.Equal(t, []string{allUsersGroupID}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
}
// TestValidateSession_BlockedUserDenied covers a user blocked after having been
// approved, so PendingApproval is false and only the blocked flag is set.
func TestValidateSession_BlockedUserDenied(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
require.NoError(t, err)
token := createSessionToken(t, proxy.SessionPrivateKey, blockedUserID, "restricted-proxy.example.com")
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
Domain: "restricted-proxy.example.com",
SessionToken: token,
})
require.NoError(t, err)
assert.False(t, resp.Valid, "Blocked user should be denied")
assert.Equal(t, deniedReasonUserBlocked, resp.DeniedReason, "Denied reason should name the blocked state")
assert.Equal(t, blockedUserID, resp.UserId, "Denial should identify the user it applies to")
}
// TestValidateSession_UserAllowedAfterApproval walks the same session token
// through the approval transition: denied while pending, allowed once an
// administrator clears both flags.
func TestValidateSession_UserAllowedAfterApproval(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
ctx := context.Background()
proxy, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
require.NoError(t, err)
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
req := &proto.ValidateSessionRequest{
Domain: "restricted-proxy.example.com",
SessionToken: token,
}
resp, err := setup.proxyService.ValidateSession(ctx, req)
require.NoError(t, err)
require.False(t, resp.Valid, "User pending approval should be denied before approval")
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
user, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, pendingUserID)
require.NoError(t, err)
user.PendingApproval = false
user.Blocked = false
require.NoError(t, setup.store.SaveUser(ctx, user))
resp, err = setup.proxyService.ValidateSession(ctx, req)
require.NoError(t, err)
assert.True(t, resp.Valid, "Approved user should be allowed access")
assert.Empty(t, resp.DeniedReason)
assert.Equal(t, pendingUserID, resp.UserId, "Approved user should be identified in the response")
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the approved user's group memberships")
}
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()

View File

@@ -33,7 +33,6 @@ import (
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/affectedpeers"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/geolocation"
@@ -1627,8 +1626,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
var removeOldGroups []string
var hasChanges bool
var user *types.User
var change affectedpeers.Change
var snap *affectedpeers.Snapshot
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
if err != nil {
@@ -1667,25 +1664,14 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
return fmt.Errorf("error saving user: %w", err)
}
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
// The user's auto-groups changed, so the SSH rules authorizing them ship a new
// group -> user mapping even when no peer moves between groups.
change.UserGroupIDs = allGroupChanges
// The user's peers are the changed entity in every scenario the sync can
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
// together with every peer they can connect to, like on a regular peer update.
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
if err != nil {
return fmt.Errorf("error getting user peers: %w", err)
}
for _, peer := range userPeers {
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
}
// Propagate changes to peers if group propagation is enabled
if settings.GroupsPropagationEnabled {
for _, peer := range userPeers {
peers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
if err != nil {
return fmt.Errorf("error getting user peers: %w", err)
}
for _, peer := range peers {
for _, g := range addNewGroups {
if err := transaction.AddPeerToGroup(ctx, userAuth.AccountId, peer.ID, g); err != nil {
return fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, g, err)
@@ -1698,8 +1684,7 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
}
}
change.LinkGroups = allGroupChanges
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
@@ -1709,10 +1694,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
}
}
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
return err
}
return nil
})
if err != nil {
@@ -1749,17 +1730,20 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
}
}
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
bgCtx := context.WithoutCancel(ctx)
go func() {
affectedPeerIDs := snap.Expand(bgCtx, userAuth.AccountId, change)
if len(affectedPeerIDs) == 0 {
return
}
if err := am.networkMapController.BufferUpdateAffectedPeers(bgCtx, userAuth.AccountId, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}); err != nil {
log.WithContext(bgCtx).Errorf("failed to update affected peers after JWT group sync for account %s: %v", userAuth.AccountId, err)
}
}()
removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups)
if err != nil {
return err
}
newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups)
if err != nil {
return err
}
if removedGroupAffectsPeers || newGroupsAffectsPeers {
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
}
return nil
}
@@ -2442,24 +2426,30 @@ func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Contex
return fmt.Errorf("get account settings: %w", err)
}
if !ipv6ReconcileNeeded(settings, groupIDs) {
if len(settings.IPv6EnabledGroups) == 0 {
return nil
}
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
for _, gid := range settings.IPv6EnabledGroups {
enabledSet[gid] = struct{}{}
}
affected := false
for _, gid := range groupIDs {
if _, ok := enabledSet[gid]; ok {
affected = true
break
}
}
if !affected {
return nil
}
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
}
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
// reconciliation.
func ipv6ReconcileNeeded(settings *types.Settings, groupIDs []string) bool {
for _, groupID := range groupIDs {
if slices.Contains(settings.IPv6EnabledGroups, groupID) {
return true
}
}
return false
}
func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error {
if settings.NetworkRangeV6.IsValid() {
network.NetV6 = net.IPNet{

View File

@@ -1757,7 +1757,6 @@ func TestAccount_Copy(t *testing.T) {
AccountID: "account1",
},
},
PostureValidation: map[string]map[string]bool{"1": {"1": true}},
}
err := hasNilField(account)
if err != nil {

View File

@@ -1,179 +0,0 @@
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/management/server/affectedpeers"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/auth"
)
// A user's auto-group change refreshes the destinations of the SSH rules authorizing
// that group — they carry the group -> user mapping — even though no peer moved
// between groups.
func TestAffectedPeers_UserGroupChange_RefreshesSSHAuthorizedDestinations(t *testing.T) {
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
ctx := context.Background()
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
Enabled: true,
Rules: []*types.PolicyRule{
{
Enabled: true,
Sources: []string{groupIDs[0]},
Destinations: []string{groupIDs[1]},
Protocol: types.PolicyRuleProtocolNetbirdSSH,
Action: types.PolicyTrafficActionAccept,
AuthorizedGroups: map[string][]string{groupIDs[3]: {"root"}},
},
},
}, true)
require.NoError(t, err)
result := resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[3]}})
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
"only the SSH rule's destination peers carry the changed group -> user mapping")
result = resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[4]}})
assert.Empty(t, result, "a group no SSH rule authorizes affects nobody")
}
// Creating, blocking or unblocking a user changes the account's allowed-user set, which
// reaches only the destinations of the SSH rules that ship it.
func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) {
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
ctx := context.Background()
// Ships the allowed-user set: an SSH rule naming no groups and no user.
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
Enabled: true,
Rules: []*types.PolicyRule{{
Enabled: true,
Sources: []string{groupIDs[0]},
Destinations: []string{groupIDs[1]},
Protocol: types.PolicyRuleProtocolNetbirdSSH,
Action: types.PolicyTrafficActionAccept,
}},
}, true)
require.NoError(t, err)
// Does not ship it: an SSH rule that authorizes a specific group.
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
Enabled: true,
Rules: []*types.PolicyRule{{
Enabled: true,
Sources: []string{groupIDs[2]},
Destinations: []string{groupIDs[3]},
Protocol: types.PolicyRuleProtocolNetbirdSSH,
Action: types.PolicyTrafficActionAccept,
AuthorizedGroups: map[string][]string{groupIDs[0]: {"root"}},
}},
}, true)
require.NoError(t, err)
result := resolveAffected(t, s, accountID, affectedpeers.Change{AllowedUsersChanged: true})
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
"only the destinations of the rule shipping the allowed-user set refresh")
}
// TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated verifies that a JWT
// 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
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))
}
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}}))
_, 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)
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)
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")
})
}

View File

@@ -1,170 +0,0 @@
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/management/server/activity"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
)
// 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
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)
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))
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,
},
},
}, 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"},
})
require.NoError(t, err)
peerShouldReceiveUpdate(t, updTarget)
peerShouldReceiveUpdate(t, upd2)
peerShouldNotReceiveUpdate(t, 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"}, Name: "renamed",
})
require.NoError(t, err)
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)
})
t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) {
account, err := manager.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
account.Settings.IPv6EnabledGroups = []string{"ug-v6"}
require.NoError(t, manager.Store.SaveAccount(ctx, account))
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))
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", "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)
})
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))
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)
peerShouldReceiveUpdate(t, upd2)
peerShouldNotReceiveUpdate(t, upd3)
})
}

View File

@@ -18,7 +18,6 @@ import (
"context"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
@@ -84,7 +83,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun
hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0
hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
// the resource<->router bridge can fire for any of these
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject || len(c.UserGroupIDs) > 0 || c.AllowedUsersChanged
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
if needsRoutersResources {
if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
@@ -220,18 +219,6 @@ type Change struct {
// (correct when the peer's own attributes changed, e.g. IP/status).
OutputPeerIDs []string
// UserGroupIDs are groups whose USER membership changed (a user's auto-groups),
// as opposed to their peer membership. Peers ship the group -> user mapping only
// for the groups an SSH rule authorizes, so these refresh the destinations of the
// SSH rules authorizing them — independently of any peer moving between groups.
UserGroupIDs []string
// AllowedUsersChanged marks a change to the set of users allowed to open SSH
// sessions — a user was created, blocked or unblocked. That set is account-wide,
// and peers receive it through the SSH rules that name no group or user of their
// own, so those rules' destinations refresh.
AllowedUsersChanged bool
// LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
// OPPOSITE side — they are never expanded to their own members. Use this when a
// peer's group membership changed: pass the peer in ChangedPeerIDs and its
@@ -253,8 +240,6 @@ func (c Change) isEmpty() bool {
len(c.Resources) == 0 &&
len(c.Networks) == 0 &&
len(c.PostureCheckIDs) == 0 &&
len(c.UserGroupIDs) == 0 &&
!c.AllowedUsersChanged &&
len(c.DistributionGroupIDs) == 0 &&
len(c.RemovedPeersByGroup) == 0 &&
len(c.LinkGroups) == 0 &&
@@ -374,9 +359,6 @@ func (r *resolver) walk() {
r.collectFromProxyServices()
}
r.collectFromSSHAuthorizedGroups()
r.collectFromAllowedUsers()
r.collectFromChangedRoutes(r.change.Routes)
r.collectFromChangedRouters(r.change.Routers)
r.collectFromChangedResources(r.change.Resources)
@@ -829,59 +811,6 @@ func (r *resolver) collectFromNameServers() {
}
}
// collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that
// authorize a group whose user membership changed. Those destination peers carry the
// group -> user mapping for the groups they authorize, so they refresh even when no
// peer moved between groups.
func (r *resolver) collectFromSSHAuthorizedGroups() {
if len(r.change.UserGroupIDs) == 0 {
return
}
changed := toSet(r.change.UserGroupIDs)
for _, policy := range r.policies() {
for _, rule := range policy.Rules {
if !rule.Enabled || rule.Protocol != types.PolicyRuleProtocolNetbirdSSH {
continue
}
if !anyInSet(maps.Keys(rule.AuthorizedGroups), changed) {
continue
}
log.WithContext(r.ctx).Tracef("collectFromSSHAuthorizedGroups: rule %s authorizes a changed user group -> folding its destinations", rule.ID)
r.foldPolicySideForRule(policy, rule, sideDestination)
}
}
}
// collectFromAllowedUsers folds the destinations of the rules that make a peer carry
// the account's allowed-user set, for a change to who is in that set.
func (r *resolver) collectFromAllowedUsers() {
if !r.change.AllowedUsersChanged {
return
}
for _, policy := range r.policies() {
for _, rule := range policy.Rules {
if !rule.Enabled || !ruleShipsAllowedUsers(rule) {
continue
}
log.WithContext(r.ctx).Tracef("collectFromAllowedUsers: rule %s ships the allowed-user set -> folding its destinations", rule.ID)
r.foldPolicySideForRule(policy, rule, sideDestination)
}
}
}
// ruleShipsAllowedUsers reports whether a rule makes its destination peers carry the
// account's allowed-user set. It mirrors the network map's SSH requirements except for
// the destination peer's own SSH flag, which the snapshot does not hold — so it folds a
// superset and never misses a peer.
func ruleShipsAllowedUsers(rule *types.PolicyRule) bool {
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
return len(rule.AuthorizedGroups) == 0 && rule.AuthorizedUser == ""
}
return types.PolicyRuleImpliesLegacySSH(rule)
}
func (r *resolver) collectFromDNSSettings() {
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
return

View File

@@ -85,8 +85,6 @@ func TestChangeIsEmpty(t *testing.T) {
assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
assert.False(t, Change{UserGroupIDs: []string{"g"}}.isEmpty())
assert.False(t, Change{AllowedUsersChanged: true}.isEmpty())
}
func TestPolicyReferencesPostureChecks(t *testing.T) {

View File

@@ -2,7 +2,6 @@ package proxy
import (
"context"
"errors"
"net"
"net/http"
"net/netip"
@@ -109,7 +108,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
redirectURL.Scheme = "https"
query := redirectURL.Query()
query.Set("error", "access_denied")
query.Set("error_description", sessionTokenErrorDescription(err))
query.Set("error_description", "Service configuration error")
redirectURL.RawQuery = query.Encode()
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
return
@@ -125,20 +124,6 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
}
// sessionTokenErrorDescription maps a session token failure to the text the
// proxy renders on its access denied page. Account status denials get a message
// the user can act on, while everything else stays generic so a lookup or
// signing failure does not describe management internals to the browser.
func sessionTokenErrorDescription(err error) string {
if errors.Is(err, nbgrpc.ErrUserPendingApproval) {
return "Your account is pending approval by an administrator"
}
if errors.Is(err, nbgrpc.ErrUserBlocked) {
return "Your account is blocked"
}
return "Service configuration error"
}
func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {

View File

@@ -360,31 +360,6 @@ func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore sto
Issued: "api",
}
require.NoError(t, testStore.SaveUser(ctx, allowedUser))
// A user awaiting approval is stored as blocked and pending approval, and
// carries the same group membership as the approved one.
pendingUser := &types.User{
Id: "pendingUserId",
AccountID: "testAccountId",
Role: types.UserRoleUser,
AutoGroups: []string{"allowedGroupId"},
Blocked: true,
PendingApproval: true,
CreatedAt: time.Now(),
Issued: "api",
}
require.NoError(t, testStore.SaveUser(ctx, pendingUser))
blockedUser := &types.User{
Id: "blockedUserId",
AccountID: "testAccountId",
Role: types.UserRoleUser,
AutoGroups: []string{"allowedGroupId"},
Blocked: true,
CreatedAt: time.Now(),
Issued: "api",
}
require.NoError(t, testStore.SaveUser(ctx, blockedUser))
}
// testServiceManager is a minimal implementation for testing.
@@ -515,58 +490,6 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter")
}
// TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account
// is pending approval or blocked never receives a session token from the OIDC
// callback, and that the redirect carries a description the proxy can render.
func TestAuthCallback_UserDeniedByAccountStatus(t *testing.T) {
tests := []struct {
name string
subject string
expectErrorDesc string
}{
{
name: "pending approval",
subject: "pendingUserId",
expectErrorDesc: "Your account is pending approval by an administrator",
},
{
name: "blocked",
subject: "blockedUserId",
expectErrorDesc: "Your account is blocked",
},
{
name: "unknown to management",
subject: "userMissingFromStoreId",
expectErrorDesc: "Service configuration error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupAuthCallbackTest(t)
defer setup.cleanup()
setup.oidcServer.tokenSubject = tt.subject
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
rec := httptest.NewRecorder()
setup.router.ServeHTTP(rec, req)
require.Equal(t, http.StatusFound, rec.Code)
parsedLocation, err := url.Parse(rec.Header().Get("Location"))
require.NoError(t, err)
require.Empty(t, parsedLocation.Query().Get("session_token"), "Denied user must not receive a session token")
require.Equal(t, "access_denied", parsedLocation.Query().Get("error"))
require.Equal(t, tt.expectErrorDesc, parsedLocation.Query().Get("error_description"))
})
}
}
func TestAuthCallback_ProxyNotFound(t *testing.T) {
setup := setupAuthCallbackTest(t)
defer setup.cleanup()

View File

@@ -91,8 +91,6 @@ type Account struct {
Onboarding AccountOnboarding `gorm:"foreignKey:AccountID;references:id;constraint:OnDelete:CASCADE"`
ReverseProxyFreeDomainNonce string
PostureValidation map[string]map[string]bool `gorm:"-"`
}
// this class is used by gorm only
@@ -876,7 +874,6 @@ func (a *Account) Copy() *Account {
Services: services,
Onboarding: a.Onboarding,
Domains: domains,
PostureValidation: a.PostureValidation,
}
}

View File

@@ -10,8 +10,6 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/zones"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/route"
)
@@ -508,8 +506,8 @@ func (a *Account) getPeersGroupsPoliciesRoutes(
func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
peerInGroups := false
var filteredPeerIDs []string
var seenPeerIds map[string]struct{}
filteredPeerIDs := make([]string, 0, len(groups))
seenPeerIds := make(map[string]struct{}, len(groups))
for _, gid := range groups {
group := a.GetGroup(gid)
@@ -549,17 +547,6 @@ func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerI
return filteredPeerIDs, peerInGroups
}
if seenPeerIds == nil {
totalGroupPeers := 0
for _, g := range groups {
if grp := a.GetGroup(g); grp != nil {
totalGroupPeers += len(grp.Peers)
}
}
filteredPeerIDs = make([]string, 0, totalGroupPeers)
seenPeerIds = make(map[string]struct{}, totalGroupPeers)
}
for _, pid := range group.Peers {
if _, seen := seenPeerIds[pid]; seen {
continue
@@ -602,109 +589,21 @@ func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sour
}
for _, postureChecksID := range sourcePostureChecksID {
if valid, cached := a.cachedPostureCheckResult(postureChecksID, peerID); cached {
if !valid {
return false, postureChecksID
}
continue
}
postureChecks := a.GetPostureChecks(postureChecksID)
if postureChecks == nil {
continue
}
if !peerPassesPostureChecks(ctx, postureChecks.GetChecks(), peer) {
return false, postureChecksID
for _, check := range postureChecks.GetChecks() {
isValid, _ := check.Check(ctx, *peer)
if !isValid {
return false, postureChecksID
}
}
}
return true, ""
}
// PrecomputePostureValidation evaluates every posture check referenced by an enabled
// policy once against the peers of that policy's source groups and stores the results,
// so the per-peer network map calculations that follow look them up instead of
// re-evaluating checks for every peer pair. It must be called before the account is
// shared across goroutines; lookups not covered by the precomputed results fall back
// to direct evaluation.
func (a *Account) PrecomputePostureValidation(ctx context.Context) {
if len(a.PostureChecks) == 0 {
a.PostureValidation = nil
return
}
checkPeerIDs := make(map[string]map[string]struct{})
for _, policy := range a.Policies {
if !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
continue
}
peerIDs := a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
for _, rule := range policy.Rules {
if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
peerIDs = append(peerIDs, rule.SourceResource.ID)
}
}
for _, postureChecksID := range policy.SourcePostureChecks {
set := checkPeerIDs[postureChecksID]
if set == nil {
set = make(map[string]struct{}, len(peerIDs))
checkPeerIDs[postureChecksID] = set
}
for _, pid := range peerIDs {
set[pid] = struct{}{}
}
}
}
results := make(map[string]map[string]bool, len(checkPeerIDs))
for postureChecksID, peerIDs := range checkPeerIDs {
results[postureChecksID] = a.evaluatePostureChecksForPeers(ctx, postureChecksID, peerIDs)
}
a.PostureValidation = results
}
func (a *Account) evaluatePostureChecksForPeers(ctx context.Context, postureChecksID string, peerIDs map[string]struct{}) map[string]bool {
postureChecks := a.GetPostureChecks(postureChecksID)
if postureChecks == nil {
return nil
}
checks := postureChecks.GetChecks()
results := make(map[string]bool, len(peerIDs))
for peerID := range peerIDs {
peer, ok := a.Peers[peerID]
if !ok || peer == nil {
continue
}
results[peerID] = peerPassesPostureChecks(ctx, checks, peer)
}
return results
}
func (a *Account) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) {
results, ok := a.PostureValidation[postureChecksID]
if !ok {
return false, false
}
if results == nil {
return true, true
}
valid, found := results[peerID]
return valid, found
}
func peerPassesPostureChecks(ctx context.Context, checks []posture.Check, peer *nbpeer.Peer) bool {
for _, check := range checks {
isValid, _ := check.Check(ctx, *peer)
if !isValid {
return false
}
}
return true
}
func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
var dest []string
for _, peerID := range inputPeers {

View File

@@ -1,72 +0,0 @@
package types_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/posture"
)
func TestPrecomputePostureValidation_MatchesDirectEvaluation(t *testing.T) {
account, validatedPeers := scalableTestAccount(60, 5)
account.PostureChecks = append(account.PostureChecks, &posture.Checks{
ID: "posture-check-strict", Name: "Strict version",
Checks: posture.ChecksDefinition{
NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.50.0"},
},
})
account.Policies[0].SourcePostureChecks = []string{"posture-check-ver", "posture-check-unknown"}
account.Policies[1].SourcePostureChecks = []string{"posture-check-strict"}
account.Policies[2].SourcePostureChecks = []string{"posture-check-ver"}
account.Policies[2].Enabled = false
ctx := context.Background()
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
type result struct {
peers map[string]struct{}
postureFailedPeers map[string]map[string]struct{}
}
snapshot := func() map[string]result {
results := make(map[string]result, len(account.Peers))
for peerID := range account.Peers {
components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil)
require.NotNil(t, components)
peerSet := make(map[string]struct{}, len(components.Peers))
for id := range components.Peers {
peerSet[id] = struct{}{}
}
results[peerID] = result{peers: peerSet, postureFailedPeers: components.PostureFailedPeers}
}
return results
}
direct := snapshot()
account.PrecomputePostureValidation(ctx)
memoized := snapshot()
require.Equal(t, len(direct), len(memoized))
for peerID, want := range direct {
got := memoized[peerID]
assert.Equal(t, want.peers, got.peers, "visible peers changed for %s", peerID)
assert.Equal(t, want.postureFailedPeers, got.postureFailedPeers, "posture failed peers changed for %s", peerID)
}
}
func TestPrecomputePostureValidation_NoPostureChecks(t *testing.T) {
account, validatedPeers := scalableTestAccount(10, 2)
account.PostureChecks = nil
ctx := context.Background()
account.PrecomputePostureValidation(ctx)
components := account.GetPeerNetworkMapComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil)
require.NotNil(t, components)
assert.NotEmpty(t, components.Peers)
}

View File

@@ -86,43 +86,6 @@ func BenchmarkNetworkMapGeneration_AllPeers(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for range b.N {
account.PrecomputePostureValidation(ctx)
for _, peerID := range peerIDs {
_ = account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
}
}
})
}
}
// BenchmarkNetworkMapGeneration_AllPeersPostureChecks benchmarks the UpdateAccountPeers
// hot path with a posture check attached to the account-wide policy, so posture
// validation runs for every source peer of every target peer's map.
func BenchmarkNetworkMapGeneration_AllPeersPostureChecks(b *testing.B) {
skipCIBenchmark(b)
scales := []benchmarkScale{
{"500peers_20groups", 500, 20},
{"1000peers_50groups", 1000, 50},
}
for _, scale := range scales {
account, validatedPeers := scalableTestAccount(scale.peers, scale.groups)
account.Policies[0].SourcePostureChecks = []string{"posture-check-ver"}
ctx := context.Background()
peerIDs := make([]string, 0, len(account.Peers))
for peerID := range account.Peers {
peerIDs = append(peerIDs, peerID)
}
b.Run("components/"+scale.name, func(b *testing.B) {
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
groupIDToUserIDs := account.GetActiveGroupUsers()
b.ReportAllocs()
b.ResetTimer()
for range b.N {
account.PrecomputePostureValidation(ctx)
for _, peerID := range peerIDs {
_ = account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs)
}

View File

@@ -593,8 +593,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, err
}
var snaps []*affectedpeers.Snapshot
var changes []affectedpeers.Change
var updateAccountPeers bool
var peersToExpire []*nbpeer.Peer
var addUserEvents []func()
var usersToSave = make([]*types.User, 0, len(updates))
@@ -630,25 +629,20 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
change, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
_, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
)
if err != nil {
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
}
updateAccountPeers = true
err = transaction.SaveUser(ctx, updatedUser)
if err != nil {
return fmt.Errorf("failed to save updated user %s: %w", update.Id, err)
}
snap, err := affectedpeers.Load(ctx, transaction, accountID, change)
if err != nil {
return err
}
snaps = append(snaps, snap)
changes = append(changes, change)
usersToSave = append(usersToSave, updatedUser)
addUserEvents = append(addUserEvents, userEvents...)
peersToExpire = append(peersToExpire, userPeersToExpire...)
@@ -689,11 +683,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
return nil, err
}
} else if len(usersToSave) > 0 {
} else if updateAccountPeers {
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)
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
}
return updatedUsersInfo, globalErr
@@ -765,21 +759,19 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac
}
func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group,
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (affectedpeers.Change, *types.User, []*nbpeer.Peer, []func(), error) {
var change affectedpeers.Change
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (bool, *types.User, []*nbpeer.Peer, []func(), error) {
if update == nil {
return change, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
if err != nil {
return change, nil, nil, nil, err
return false, nil, nil, nil, err
}
if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil {
return change, nil, nil, nil, err
return false, nil, nil, nil, err
}
// only auto groups, revoked status, and integration reference can be updated for now
@@ -800,13 +792,13 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
var transferredOwnerRole bool
result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update)
if err != nil {
return change, nil, nil, nil, err
return false, nil, nil, nil, err
}
transferredOwnerRole = result
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, updatedUser.AccountID, update.Id)
if err != nil {
return change, nil, nil, nil, err
return false, nil, nil, nil, err
}
var peersToExpire []*nbpeer.Peer
@@ -815,32 +807,6 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
peersToExpire = userPeers
}
// A user reaches a peer's network map only through the SSH rules: as part of a
// group -> user mapping, and as part of the account's allowed-user set. Creating,
// blocking or unblocking a user adds it to or removes it from both, so every group
// it maps into changes — including the All group that holds every active user.
// Otherwise only the auto-groups it joined or left do.
if isNewUser || oldUser.IsBlocked() != updatedUser.IsBlocked() {
change.AllowedUsersChanged = true
change.UserGroupIDs = slices.Concat(oldUser.AutoGroups, updatedUser.AutoGroups, allGroupIDs(groupsMap))
} else {
change.UserGroupIDs = slices.Concat(
util.Difference(oldUser.AutoGroups, updatedUser.AutoGroups),
util.Difference(updatedUser.AutoGroups, oldUser.AutoGroups),
)
}
// The user's peers are the changed entity in every scenario the update can
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
// together with every peer they can connect to, like on a regular peer update.
// An update that changes neither the auto-groups nor the active-user set has no
// peer-visible effect and refreshes nobody.
if len(change.UserGroupIDs) > 0 || change.AllowedUsersChanged {
for _, peer := range userPeers {
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
}
}
var removedGroups, addedGroups []string
if update.AutoGroups != nil && settings.GroupsPropagationEnabled {
removedGroups = util.Difference(oldUser.AutoGroups, update.AutoGroups)
@@ -848,38 +814,26 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
for _, peer := range userPeers {
for _, groupID := range removedGroups {
if err := transaction.RemovePeerFromGroup(ctx, peer.ID, groupID); err != nil {
return change, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
return false, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
}
}
for _, groupID := range addedGroups {
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
return change, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
return false, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
}
}
}
allGroupChanges := slices.Concat(removedGroups, addedGroups)
change.LinkGroups = allGroupChanges
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
return change, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
}
updateAccountPeers := len(userPeers) > 0
userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction)
return change, updatedUser, peersToExpire, userEventsToAdd, nil
}
// allGroupIDs returns the ID of the account's All group, which every active user maps
// into, as a slice so callers can concatenate it.
func allGroupIDs(groupsMap map[string]*types.Group) []string {
for _, group := range groupsMap {
if group.IsGroupAll() {
return []string{group.ID}
}
}
return nil
return updateAccountPeers, updatedUser, peersToExpire, userEventsToAdd, nil
}
// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist.