Compare commits

..

12 Commits

19 changed files with 307 additions and 178 deletions

View File

@@ -1,20 +1,27 @@
package server
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
const statusCoalesceWindow = 200 * time.Millisecond
// SubscribeStatus pushes a fresh StatusResponse on every connection state
// change. The first message is the current snapshot, so a re-subscribing
// client doesn't need to also call Status. Subsequent messages fire when
// the peer recorder reports any of: connected/disconnected/connecting,
// management or signal flip, address change, or peers list change.
//
// The change channel coalesces bursts to a single tick. If the consumer
// is slow the daemon drops extras (not blocks), and the next snapshot
// the consumer pulls already reflects everything.
// Bursts are coalesced deterministically: the first tick after a quiet
// period is sent immediately, then a short window swallows the rest of the
// burst and a single trailing snapshot covers whatever arrived meanwhile.
// Every send is a full snapshot of the recorder's current state, so
// swallowed ticks lose no information.
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
subID, ch := s.statusRecorder.SubscribeToStateChanges()
defer func() {
@@ -37,12 +44,50 @@ func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonSe
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
pending, open := collectStatusBurst(stream.Context(), ch)
if pending {
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
}
if !open {
return nil
}
case <-stream.Context().Done():
return nil
}
}
}
// collectStatusBurst waits out the coalesce window, absorbing further ticks.
// pending reports whether any tick arrived; open is false when the channel
// closed or the stream context ended.
func collectStatusBurst(ctx context.Context, ch <-chan struct{}) (pending, open bool) {
timer := time.NewTimer(statusCoalesceWindow)
defer timer.Stop()
for {
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
case <-timer.C:
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
default:
}
return pending, true
case <-ctx.Done():
return false, false
}
}
}
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
resp, err := s.buildStatusResponse(stream.Context(), req)
if err != nil {

View File

@@ -103,6 +103,18 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
// Status snapshots go only to visible windows — a snapshot is full state,
// so a hidden webview loses nothing by being skipped; it gets the cached
// one on show (SetShowReplay below). Every other event stays on the bus.
daemonFeed.SetWindowDispatcher(func(st services.Status) {
ev := &application.CustomEvent{Name: services.EventStatusSnapshot, Data: st}
for _, w := range app.Window.GetAll() {
if w == nil || !w.IsVisible() {
continue
}
w.DispatchWailsEvent(ev)
}
})
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
@@ -152,8 +164,31 @@ func main() {
// re-centering on that environment; nil leaves placement to the WM on full
// desktops, macOS, and Windows.
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
// Replay the latest snapshot into a window on (re)show, so a webview that
// was hidden while pushes flowed never paints stale state. ReplayLast keeps
// the feed's status lock across the dispatch, so a racing live push can't
// slip in between and then be overwritten by this older cached snapshot.
windowManager.SetShowReplay(func(w application.Window) {
daemonFeed.ReplayLast(func(st services.Status) {
w.DispatchWailsEvent(&application.CustomEvent{Name: services.EventStatusSnapshot, Data: st})
})
})
app.RegisterService(application.NewService(windowManager))
// 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
}
windowManager.ShowMain()
})
}
// Welcome window, first launch only — Continue flips OnboardingCompleted
// so later launches skip it. ApplicationStarted hook so the Wails window
// machinery is fully up before the window is created.
@@ -377,20 +412,5 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
window.Hide()
})
// 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
}

View File

@@ -5,6 +5,7 @@ package services
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -167,6 +168,14 @@ type DaemonFeed struct {
cancel context.CancelFunc
streamWg sync.WaitGroup
// statusSubs are Go-side snapshot consumers (the tray), fed directly so
// they don't ride the window event bus. Callbacks run synchronously on
// the stream goroutine, so pushes arrive in order.
statusSubsMu sync.Mutex
statusSubs []func(Status)
lastStatus *Status
windowDispatcher func(Status)
switchMu sync.Mutex
switchInProgress bool
switchInProgressUntil time.Time
@@ -188,6 +197,55 @@ func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Hold
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
}
// OnStatus registers a Go-side status subscriber. Not for the frontend —
// React consumers subscribe to EventStatusSnapshot on the event bus.
func (s *DaemonFeed) OnStatus(cb func(Status)) {
s.statusSubsMu.Lock()
s.statusSubs = append(s.statusSubs, cb)
s.statusSubsMu.Unlock()
}
// SetWindowDispatcher installs the frontend push path: it receives every
// snapshot and decides which webview windows get it (visible ones). While
// unset, pushStatus falls back to the event-bus broadcast.
func (s *DaemonFeed) SetWindowDispatcher(fn func(Status)) {
s.statusSubsMu.Lock()
s.windowDispatcher = fn
s.statusSubsMu.Unlock()
}
// pushStatus delivers a snapshot to the Go-side subscribers and the frontend,
// and caches it for ReplayLast. Hidden windows are skipped by the
// window dispatcher; they catch up via the show replay (WindowManager).
func (s *DaemonFeed) pushStatus(st Status) {
s.statusSubsMu.Lock()
s.lastStatus = &st
subs := slices.Clone(s.statusSubs)
dispatch := s.windowDispatcher
s.statusSubsMu.Unlock()
for _, cb := range subs {
cb(st)
}
if dispatch != nil {
dispatch(st)
return
}
s.emitter.Emit(EventStatusSnapshot, st)
}
// ReplayLast feeds the most recently pushed snapshot to dispatch; no-op before
// the first push. The status lock is held across the dispatch so a concurrent
// pushStatus cannot deliver a newer snapshot in between the read and the
// dispatch — the replayed value is never older than anything already delivered.
func (s *DaemonFeed) ReplayLast(dispatch func(Status)) {
s.statusSubsMu.Lock()
defer s.statusSubsMu.Unlock()
if s.lastStatus == nil {
return
}
dispatch(*s.lastStatus)
}
// BeginProfileSwitch arms suppression for a switch from Connected/Connecting,
// where the daemon emits stale Connected updates during Down's teardown then an
// Idle before the new Up; statusStreamLoop drops those, and a synthetic
@@ -201,7 +259,7 @@ func (s *DaemonFeed) BeginProfileSwitch() {
s.switchLoginWatch = true
s.switchLoginWatchUntil = now.Add(30 * time.Second)
s.switchMu.Unlock()
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting})
s.pushStatus(Status{Status: StatusConnecting})
}
// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting):
@@ -343,7 +401,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
return
}
unavailable = true
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
s.pushStatus(Status{Status: StatusDaemonUnavailable})
}
op := func() error {
@@ -403,7 +461,7 @@ func (s *DaemonFeed) emitStatus(st Status) {
log.Debugf("suppressing status=%q during profile switch", st.Status)
return
}
s.emitter.Emit(EventStatusSnapshot, st)
s.pushStatus(st)
if triggerLogin {
s.emitter.Emit(EventTriggerLogin)
}

View File

@@ -115,6 +115,9 @@ type WindowManager struct {
// 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
// showReplay fires whenever a live-but-hidden window is (re)shown, so the
// caller can replay the latest status snapshot into its webview.
showReplay func(application.Window)
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
@@ -174,6 +177,7 @@ func (s *WindowManager) OpenSettings(tab string) {
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
s.notifyShown(s.settings)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
}
@@ -220,6 +224,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.notifyShown(s.browserLogin)
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -280,6 +285,7 @@ func (s *WindowManager) OpenSessionExpiration(seconds int) {
s.centerOnCursorScreen(s.sessionExpiration)
s.sessionExpiration.Show()
s.sessionExpiration.Focus()
s.notifyShown(s.sessionExpiration)
}
func (s *WindowManager) CloseSessionExpiration() {
@@ -347,6 +353,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
s.installProgress.SetURL(startURL)
s.installProgress.Show()
s.installProgress.Focus()
s.notifyShown(s.installProgress)
s.centerWhenReady(s.installProgress)
}
@@ -380,6 +387,7 @@ func (s *WindowManager) OpenWelcome() {
}
s.welcome.Show()
s.welcome.Focus()
s.notifyShown(s.welcome)
s.centerWhenReady(s.welcome)
}
@@ -417,6 +425,7 @@ func (s *WindowManager) OpenError(title, message string) {
s.errorDialog.SetURL(startURL)
s.errorDialog.Show()
s.errorDialog.Focus()
s.notifyShown(s.errorDialog)
s.centerWhenReady(s.errorDialog)
}
@@ -443,6 +452,7 @@ func (s *WindowManager) ShowMain() {
}
s.mainWindow.Show()
s.mainWindow.Focus()
s.notifyShown(s.mainWindow)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
}
@@ -452,6 +462,17 @@ func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
s.recenterOnShow = pred
}
// SetShowReplay installs the shown-window hook (see the showReplay field).
func (s *WindowManager) SetShowReplay(fn func(application.Window)) {
s.showReplay = fn
}
func (s *WindowManager) notifyShown(w application.Window) {
if s.showReplay != nil && w != nil {
s.showReplay(w)
}
}
// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it
// returns so it never fights a user-moved window. On GTK4 an inline Center()
// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync
@@ -572,6 +593,7 @@ func (s *WindowManager) restoreHiddenWindowsLocked() {
continue
}
w.Show()
s.notifyShown(w)
if w == s.mainWindow {
mainRestored = true
}

View File

@@ -67,9 +67,8 @@ type Tray struct {
loc *Localizer
// menu and the *Item/*Submenu fields below are reassigned by buildMenu
// on every relayout — touch them only with menuMu held. Exceptions:
// the Connect/Disconnect OnClick closures capture their own item, and
// refreshSessionExpiresLabel snapshots its item under menuMu.
// on every relayout, which destroys the replaced tree — locate items and
// call them with menuMu held, so a relayout can't destroy one mid-call.
menu *application.Menu
statusItem *application.MenuItem
// sessionExpiresItem shows the SSO deadline as a remaining-time label,
@@ -172,7 +171,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, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }, func() { t.showMainWindow() })
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).
@@ -196,7 +195,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// menu (e.g. GNOME Shell AppIndicator).
bindTrayClick(t)
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
svc.DaemonFeed.OnStatus(t.applyStatus)
app.Event.On(services.EventDaemonNotification, t.onSystemEvent)
// Refresh the Profiles submenu on ProfileSwitcher's change event. A
// switch on an idle daemon drives no status transition, so without this
@@ -253,6 +252,19 @@ func (t *Tray) ShowWindow() {
t.window.Focus()
}
// showMainWindow brings the main window forward through the WindowManager so
// the status replay and re-centering apply; falls back to a bare Show in tests.
func (t *Tray) showMainWindow() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if t.window != nil {
t.window.Show()
t.window.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current
// language. Wails dispatches menu/tray APIs onto the UI thread internally, so
// calling them from the Localizer's background goroutine is safe; profileLoadMu
@@ -286,6 +298,7 @@ func (t *Tray) relayoutMenu() {
t.menuMu.Lock()
defer t.menuMu.Unlock()
old := t.menu
t.menu = t.buildMenu()
t.statusMu.Lock()
@@ -356,6 +369,10 @@ func (t *Tray) relayoutMenu() {
// Single push of the whole tree: on Linux one LayoutUpdated with fresh
// container ids; on darwin an NSMenu rebuild against the cached pointer.
t.tray.SetMenu(t.menu)
if old != nil {
old.Destroy()
}
}
func (t *Tray) buildMenu() *application.Menu {
@@ -371,13 +388,11 @@ func (t *Tray) buildMenu() *application.Menu {
menu.AddSeparator()
// The OnClick closures capture the local item because t.upItem/t.downItem
// are menuMu-guarded and must not be read from the click goroutine.
upItem := menu.Add(t.loc.T("tray.menu.connect"))
upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) })
upItem.OnClick(func(*application.Context) { t.handleConnect() })
t.upItem = upItem
downItem := menu.Add(t.loc.T("tray.menu.disconnect"))
downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) })
downItem.OnClick(func(*application.Context) { t.handleDisconnect() })
downItem.SetHidden(true)
t.downItem = downItem
@@ -469,9 +484,7 @@ func (t *Tray) handleQuit() {
t.app.Quit()
}
// handleConnect receives the clicked item from the buildMenu closure —
// t.upItem is menuMu-guarded and must not be read here.
func (t *Tray) handleConnect(upItem *application.MenuItem) {
func (t *Tray) handleConnect() {
// NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they
// need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so
// the React startLogin() (which owns the BrowserLogin popup) drives it;
@@ -485,7 +498,7 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
t.app.Event.Emit(services.EventTriggerLogin)
return
}
upItem.SetEnabled(false)
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, false)
// Arm the SSO auto-handoff: Up() is async and the daemon may flip to
// NeedsLogin on an SSO peer with no cached token. applyStatus consumes the
// flag on that transition to trigger browser-login without a second Connect
@@ -500,18 +513,25 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
t.statusMu.Lock()
t.pendingConnectLogin = false
t.statusMu.Unlock()
upItem.SetEnabled(true)
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, true)
}
}()
}
func (t *Tray) setItemEnabled(get func() *application.MenuItem, enabled bool) {
t.menuMu.Lock()
defer t.menuMu.Unlock()
if item := get(); item != nil {
item.SetEnabled(enabled)
}
}
// handleDisconnect aborts any in-flight profile switch before sending Down —
// otherwise the switcher's queued Up would reconnect right after, making the
// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's
// Idle push paints through instead of being swallowed by the suppression filter.
// Receives the clicked item from the buildMenu closure (see handleConnect).
func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
downItem.SetEnabled(false)
func (t *Tray) handleDisconnect() {
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, false)
t.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
@@ -523,7 +543,7 @@ func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
if err := t.svc.Connection.Down(context.Background()); err != nil {
log.Errorf("disconnect: %v", err)
t.notifyError(t.loc.T("notify.error.disconnect"))
downItem.SetEnabled(true)
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, true)
}
}()
}

View File

@@ -5,6 +5,7 @@ package main
import (
"context"
"fmt"
"slices"
"sort"
log "github.com/sirupsen/logrus"
@@ -59,10 +60,11 @@ func (t *Tray) loadConfig() {
t.profileMu.Unlock()
}
// loadProfiles fetches the profile list and relayouts the menu. Also called
// from applyStatus to catch flips from another channel (CLI, autoconnect),
// since the daemon emits no active-profile event. Full relayout (not
// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment.
// loadProfiles fetches the profile list and relayouts the menu when the rows
// changed. Also called from applyStatus to catch flips from another channel
// (CLI, autoconnect), since the daemon emits no active-profile event. Full
// relayout (not Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's
// doc comment.
func (t *Tray) loadProfiles() {
t.profileLoadMu.Lock()
defer t.profileLoadMu.Unlock()
@@ -80,11 +82,14 @@ func (t *Tray) loadProfiles() {
}
t.profilesMu.Lock()
changed := username != t.profilesUser || !slices.Equal(profiles, t.profiles)
t.profiles = profiles
t.profilesUser = username
t.profilesMu.Unlock()
t.relayoutMenu()
if changed {
t.relayoutMenu()
}
}
// fillProfileSubmenu paints cached profile rows into the freshly built submenu.

View File

@@ -30,11 +30,11 @@ const (
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
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.SetURL("/#/login")
t.window.Show()
t.window.Focus()
if t.window == nil {
return
}
t.window.SetURL("/#/login")
t.showMainWindow()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -104,21 +104,19 @@ func sessionRefreshInterval(remaining time.Duration) time.Duration {
}
// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu.
// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout.
// menuMu is held across the SetLabel so a relayout can't destroy the item mid-call.
func (t *Tray) refreshSessionExpiresLabel() {
t.menuMu.Lock()
item := t.sessionExpiresItem
t.menuMu.Unlock()
if item == nil {
return
}
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return
}
item.SetLabel(t.sessionRowLabel(deadline))
t.menuMu.Lock()
defer t.menuMu.Unlock()
if t.sessionExpiresItem != nil {
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(deadline))
}
}
func (t *Tray) sessionRowLabel(deadline time.Time) string {
@@ -310,8 +308,7 @@ func (t *Tray) openSessionExtendFlow() {
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
t.showMainWindow()
}
return
}

View File

@@ -5,19 +5,9 @@ package main
import (
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped
// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus
// bursts during health probes that would otherwise spam Shell_NotifyIcon.

View File

@@ -28,6 +28,9 @@ type trayUpdater struct {
// About submenu, which KDE/Plasma caches on first open and never re-fetches
// on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints.
onMenuChange func()
// showMain brings the main window forward via the WindowManager (status
// replay + centering); nil falls back to a bare window.Show.
showMain func()
mu sync.Mutex
item *application.MenuItem
@@ -36,7 +39,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange, onMenuChange, showMain func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
@@ -45,6 +48,7 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd
loc: loc,
onIconChange: onIconChange,
onMenuChange: onMenuChange,
showMain: showMain,
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from cached state to cover an event that fired before wiring completed.
@@ -193,6 +197,10 @@ func (u *trayUpdater) openProgressWindow(version string) {
url += "?version=" + version
}
u.window.SetURL(url)
if u.showMain != nil {
u.showMain()
return
}
u.window.Show()
u.window.Focus()
}

View File

@@ -3,10 +3,9 @@ package labelgen
import (
"fmt"
"math/rand"
"sort"
"sync"
"github.com/netbirdio/netbird/management/server/util"
)
// pickAttempts caps the random retries before falling back to the
@@ -41,15 +40,16 @@ func uniqueWords() []string {
// PickUnique selects a label not already in `taken`. It tries up to
// pickAttempts random picks; on exhaustion it scans the deduplicated
// wordlist for any remaining free entry, and if none is left appends
// `-<fallbackSuffix>` to a random word and returns.
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
// is responsible for seeding rng (math/rand).
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
pool := uniqueWords()
if len(pool) == 0 {
return fallbackSuffix
}
for i := 0; i < pickAttempts; i++ {
w := pool[util.RandIntn(len(pool))]
w := pool[rng.Intn(len(pool))]
if _, ok := taken[w]; !ok {
return w
}
@@ -61,6 +61,6 @@ func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
}
}
w := pool[util.RandIntn(len(pool))]
w := pool[rng.Intn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
}

View File

@@ -1,7 +1,7 @@
package labelgen
import (
"slices"
"math/rand"
"strings"
"testing"
@@ -9,12 +9,19 @@ import (
"github.com/stretchr/testify/require"
)
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
// taken set is always drawn verbatim from the wordlist.
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
got := PickUnique(map[string]struct{}{}, "abcd")
// TestPickUnique_DeterministicWithSeededRng locks the property the
// caller relies on: same seed + same taken set → same pick. Without
// that, the bootstrap flow can't reproduce a label across retries.
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
taken := map[string]struct{}{}
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
rngA := rand.New(rand.NewSource(42))
rngB := rand.New(rand.NewSource(42))
a := PickUnique(rngA, taken, "abcd")
b := PickUnique(rngB, taken, "abcd")
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
}
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
@@ -39,7 +46,8 @@ func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
taken[w] = struct{}{}
}
got := PickUnique(taken, "abcd")
rng := rand.New(rand.NewSource(7))
got := PickUnique(rng, taken, "abcd")
_, isFree := free[got]
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
@@ -57,7 +65,8 @@ func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
taken[w] = struct{}{}
}
got := PickUnique(taken, "abcd")
rng := rand.New(rand.NewSource(99))
got := PickUnique(rng, taken, "abcd")
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"slices"
"strings"
"sync"
@@ -127,6 +128,11 @@ type managerImpl struct {
// accountID, then by synthesised service ID.
reconcileMu sync.Mutex
reconcileCache map[string]map[string]*proto.ProxyMapping
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// state; concurrent provider creates would otherwise race.
labelRngMu sync.Mutex
labelRng *rand.Rand
}
// NewManager constructs the persistent Agent Network manager. The
@@ -146,6 +152,7 @@ func NewManager(
permissionsManager: permissionsManager,
proxyController: proxyController,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -651,7 +658,9 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
suffix = suffix[:4]
}
subdomain := labelgen.PickUnique(taken, suffix)
m.labelRngMu.Lock()
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
m.labelRngMu.Unlock()
now := time.Now().UTC()
settings := &types.Settings{

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"net"
"net/netip"
"os"
@@ -62,7 +63,7 @@ const (
type userLoggedInOnce bool
func cacheEntryExpiration() time.Duration {
r := util.RandIntn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
r := rand.Intn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
return time.Duration(r) * time.Millisecond
}
@@ -2458,7 +2459,8 @@ func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transacti
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
}
if network.NetV6.IP == nil {
network.NetV6 = types.AllocateIPv6Subnet()
r := rand.New(rand.NewSource(time.Now().UnixNano()))
network.NetV6 = types.AllocateIPv6Subnet(r)
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
ones, _ := network.NetV6.Mask.Size()

View File

@@ -2,6 +2,7 @@ package server
import (
"context"
"math/rand"
"testing"
"time"
@@ -27,7 +28,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
require.NoError(t, err)
// Allocate IPv6 subnet for the account
account.Network.NetV6 = types.AllocateIPv6Subnet()
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
require.NoError(t, am.Store.SaveAccount(ctx, account))
// Create setup key

View File

@@ -2,12 +2,11 @@ package idp
import (
"encoding/json"
"math/rand"
"net/url"
"os"
"strings"
"time"
"github.com/netbirdio/netbird/management/server/util"
)
var (
@@ -34,32 +33,31 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int)
//Set special character
for i := 0; i < minSpecialChar; i++ {
random := util.RandIntn(len(specialCharSet))
random := rand.Intn(len(specialCharSet))
password.WriteString(string(specialCharSet[random]))
}
//Set numeric
for i := 0; i < minNum; i++ {
random := util.RandIntn(len(numberSet))
random := rand.Intn(len(numberSet))
password.WriteString(string(numberSet[random]))
}
//Set uppercase
for i := 0; i < minUpperCase; i++ {
random := util.RandIntn(len(upperCharSet))
random := rand.Intn(len(upperCharSet))
password.WriteString(string(upperCharSet[random]))
}
remainingLength := passwordLength - minSpecialChar - minNum - minUpperCase
for i := 0; i < remainingLength; i++ {
random := util.RandIntn(len(allCharSet))
random := rand.Intn(len(allCharSet))
password.WriteString(string(allCharSet[random]))
}
inRune := []rune(password.String())
for i := len(inRune) - 1; i > 0; i-- {
j := util.RandIntn(i + 1)
rand.Shuffle(len(inRune), func(i, j int) {
inRune[i], inRune[j] = inRune[j], inRune[i]
}
})
return string(inRune)
}

View File

@@ -2,6 +2,7 @@ package types
import (
"context"
"math/rand"
"net"
"net/netip"
@@ -85,8 +86,8 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
}
func AllocateIPv6Subnet() net.IPNet {
return sharedtypes.AllocateIPv6Subnet()
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
return sharedtypes.AllocateIPv6Subnet(r)
}
func NewNetwork() *Network {

View File

@@ -1,20 +1,5 @@
package util
import (
"crypto/rand"
"math/big"
)
// RandIntn returns a uniformly distributed int in [0, n) sourced from
// crypto/rand. It panics if n <= 0 or the platform randomness source fails.
func RandIntn(n int) int {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
panic(err)
}
return int(v.Int64())
}
// Difference returns the elements in `a` that aren't in `b`.
func Difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))

View File

@@ -1,20 +1,20 @@
package types
import (
"crypto/rand"
"encoding/binary"
"fmt"
"math/rand"
"net"
"net/netip"
"slices"
"sync"
"time"
"github.com/c-robinson/iplib"
"github.com/rs/xid"
"golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
@@ -171,12 +171,14 @@ func NewNetwork() *Network {
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
sub, _ := n.Subnet(SubnetSize)
intn := util.RandIntn(len(sub))
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
intn := r.Intn(len(sub))
return &Network{
Identifier: xid.New().String(),
Net: sub[intn].IPNet,
NetV6: AllocateIPv6Subnet(),
NetV6: AllocateIPv6Subnet(r),
Dns: "",
Serial: 0,
}
@@ -186,13 +188,18 @@ func NewNetwork() *Network {
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
func AllocateIPv6Subnet() net.IPNet {
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
ip := make(net.IP, 16)
ip[0] = 0xfd
// Bytes 1-5: 40-bit random Global ID, bytes 6-7: 16-bit random Subnet ID
if _, err := rand.Read(ip[1:8]); err != nil {
panic(err)
}
// Bytes 1-5: 40-bit random Global ID
ip[1] = byte(r.Intn(256))
ip[2] = byte(r.Intn(256))
ip[3] = byte(r.Intn(256))
ip[4] = byte(r.Intn(256))
ip[5] = byte(r.Intn(256))
// Bytes 6-7: 16-bit random Subnet ID
ip[6] = byte(r.Intn(256))
ip[7] = byte(r.Intn(256))
return net.IPNet{
IP: ip,
@@ -226,22 +233,10 @@ func (n *Network) Copy() *Network {
}
}
// validateIPv4Prefix ensures the prefix is an IPv4 network with assignable host addresses.
func validateIPv4Prefix(prefix netip.Prefix) error {
if !prefix.IsValid() || !prefix.Addr().Is4() || prefix.Bits() < 1 || prefix.Bits() >= 31 {
return fmt.Errorf("invalid IPv4 subnet: %s", prefix.String())
}
return nil
}
// AllocatePeerIP picks an available IP from a netip.Prefix.
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
if err := validateIPv4Prefix(prefix); err != nil {
return netip.Addr{}, err
}
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
@@ -252,17 +247,15 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
for _, ip := range takenIps {
if !ip.Is4() {
continue
}
ab := ip.As4()
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
maxAttempts := (int(totalIPs) - len(taken)) / 100
for i := 0; i < maxAttempts; i++ {
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
candidate := baseIP + offset
if _, exists := taken[candidate]; !exists {
return uint32ToIP(candidate), nil
@@ -281,16 +274,13 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
if err := validateIPv4Prefix(prefix); err != nil {
return netip.Addr{}, err
}
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
totalIPs := uint32(1 << hostBits)
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
candidate := baseIP + offset
return uint32ToIP(candidate), nil
@@ -306,26 +296,23 @@ func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
ip := prefix.Addr().As16()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Determine which byte the host bits start in
firstHostByte := ones / 8
// If the prefix doesn't end on a byte boundary, handle the partial byte
partialBits := ones % 8
var rnd [16]byte
if _, err := rand.Read(rnd[firstHostByte:]); err != nil {
return netip.Addr{}, err
}
if partialBits > 0 {
// Keep the network bits in the partial byte, randomize the rest
hostMask := byte(0xff >> partialBits)
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (rnd[firstHostByte] & hostMask)
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
firstHostByte++
}
// Randomize remaining full host bytes
for i := firstHostByte; i < 16; i++ {
ip[i] = rnd[i]
ip[i] = byte(rng.Intn(256))
}
// Avoid all-zeros and all-ones host parts by checking only host bits.

View File

@@ -143,34 +143,6 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
}
}
func TestAllocateIPv4InvalidPrefixes(t *testing.T) {
prefixes := []netip.Prefix{
{},
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("192.168.1.0/31"),
netip.MustParsePrefix("192.168.1.1/32"),
netip.MustParsePrefix("fd12:3456:7890:abcd::/64"),
}
for _, prefix := range prefixes {
t.Run(prefix.String(), func(t *testing.T) {
_, err := AllocatePeerIP(prefix, nil)
assert.Error(t, err)
_, err = AllocateRandomPeerIP(prefix)
assert.Error(t, err)
})
}
}
func TestAllocatePeerIPIgnoresNonIPv4TakenIPs(t *testing.T) {
prefix := netip.MustParsePrefix("192.168.1.0/29")
ip, err := AllocatePeerIP(prefix, []netip.Addr{netip.MustParseAddr("fd12:3456:7890:abcd::1")})
require.NoError(t, err)
assert.True(t, prefix.Contains(ip))
}
func TestGenerateIPs(t *testing.T) {
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})