diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 85ed5cd3b..a1336cdab 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,5 +14,15 @@ reviews: - "!**/*.ts" - "!**/*.js" - "!**/*.svg" + pre_merge_checks: + custom_checks: + - name: "No attribution trailers" + mode: error + instructions: >- + Fail when the PR description or any commit message carries an + attribution trailer or footer: Co-Authored-By, Claude-Session, + Generated-By, or a "Generated with"/"Generated by" tool line. + Contributors own their contributions (AGENTS.md); ask for the + lines to be removed. chat: auto_reply: true diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 000000000..e5699bd9c --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,26 @@ +#!/bin/bash +# Refuses commit messages that carry attribution trailers. Contributors own +# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution +# trailers"); a trailer spreads that ownership onto a tool or a bystander. + +msg_file="$1" + +# Trailer keys in any casing, with any bullet or emoji in front. +trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):' +# "Generated with/by" footers, including "Generated with by". +footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? ' +# A footer names a product, so a capitalized word must follow the phrase +# itself. Prose such as "generated by the protobuf compiler" stays legal. +tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]' + +offending=$( { + grep -Ein "$trailers" "$msg_file" + grep -Ein "$footer" "$msg_file" | grep -E "$tool" +} | sort -un ) + +if [ -n "$offending" ]; then + echo "commit-msg: attribution trailers are not accepted in this repository:" >&2 + printf '%s\n' "$offending" | sed 's/^/ /' >&2 + echo "Remove them and commit again (see AGENTS.md)." >&2 + exit 1 +fi diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index c17d8e775..dec57dc80 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -46,15 +46,17 @@ jobs: run: git --no-pager diff --exit-code - name: Test - # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # Exclude the client/ui package itself: its main.go uses //go:embed + # all:frontend/dist, which fails to compile until the frontend has been + # built, and its release pipeline runs `pnpm build` before goreleaser. + # The pattern is anchored so the subpackages (services, preferences, + # i18n, authsession) still run: they hold Go-side unit tests and need no + # frontend bundle. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo # root, which has no Go files. - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e '/client/ui$' -e /client/testutil/privileged) - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index f24dfbe9d..449eb14fa 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -160,9 +160,10 @@ jobs: - name: Test # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # which fails to compile until the frontend has been built, and its + # release pipeline runs `pnpm build` before goreleaser. The subpackages + # go with it because this runner's gtk4 is older than the wails runtime + # needs; the Client UI / Unit job below covers them instead. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo @@ -177,6 +178,35 @@ jobs: slug: netbirdio/netbird flags: unit,client + test_client_ui: + name: "Client UI / Unit" + # Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails + # runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10, + # and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot + # move this out from under us. + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev + + - name: Test + # client/ui itself stays out: its main.go embeds all:frontend/dist, + # which only exists after `pnpm build`. The subpackages carry the + # Go-side unit tests, including the window manager re-entrancy + # regression test, and need no frontend bundle. + run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/... + test_client_on_docker: name: "Client (Docker) / Unit" needs: [build-cache] @@ -211,6 +241,9 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Run tests in container + # Unlike the native job above, this one drops all of client/ui including + # the subpackages: the alpine container has no gtk4/webkitgtk, so the + # Wails application package they import would fail to link. env: HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }} HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }} @@ -481,14 +514,32 @@ jobs: if: matrix.store == 'mysql' run: docker pull mlsmaycon/warmed-mysql:8 + # The -json stream goes through tools/gotestsummary so the log shows one + # line per test, the output of failed tests, the head of a timeout panic + # with the still-running tests, and the slowest tests per package. - name: Test + shell: bash run: | + set -o pipefail CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=devcert -coverprofile=coverage.txt \ + go test -json -tags=devcert -coverprofile=coverage.txt \ -exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \ - -timeout 20m ./management/... ./shared/management/... + -timeout 20m ./management/... ./shared/management/... \ + | tee management-test-events.jsonl \ + | go run ./tools/gotestsummary + + # The summary trims long outputs; the raw stream keeps every line for + # the failures that need it. A green run has no use for it. + - name: Upload raw test events + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: management-unit-test-events-${{ matrix.store }} + path: management-test-events.jsonl + if-no-files-found: ignore + retention-days: 14 - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' @@ -738,12 +789,27 @@ jobs: - name: check git status run: git --no-pager diff --exit-code + # Same summary as the unit job: a timeout here names the tests still + # running instead of ending in a goroutine dump. - name: Test + shell: bash run: | + set -o pipefail CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - mage integrationtest:all -gotestflags="-coverprofile=coverage.txt" + mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \ + | tee management-integration-test-events.jsonl \ + | go run ./tools/gotestsummary + + - name: Upload raw test events + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: management-integration-test-events-${{ matrix.store }} + path: management-integration-test-events.jsonl + if-no-files-found: ignore + retention-days: 14 - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index fb7b745d2..ca300f7df 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -66,15 +66,17 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script - # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # Exclude the client/ui package itself: its main.go uses //go:embed + # all:frontend/dist, which fails to compile until the frontend has been + # built, and its release pipeline runs `pnpm build` before goreleaser. + # The pattern is anchored so the subpackages (services, preferences, + # i18n, authsession) still run: they hold Go-side unit tests and need no + # frontend bundle. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the Where-Object pipeline then drops the broken package by # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d3fe3641..51426a7ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,6 +186,11 @@ jobs: run: bash shared/management/http/api/generate.sh - name: check git status run: git --no-pager diff --exit-code + - name: Generate RPM changelog from git tags + # nfpm embeds changelog.yml into the RPM; Red Hat software certification + # requires a changelog. Generated, not committed (see .gitignore). + # chglog is a go.mod tool directive, so go.sum pins it and its deps. + run: bash release_files/rpm-changelog.sh - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx diff --git a/.gitignore b/.gitignore index 305f3cb50..dd7eea76f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ vendor/ /netbird client/netbird-electron/ management/server/types/testdata/ + +# generated by chglog in the release workflow, embedded into the RPM +changelog.yml +.chglog.yml diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 778ccb892..19528f88e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -234,10 +234,33 @@ nfpms: - netbird formats: - rpm + # The client verifies TLS to management and signal against the system trust + # store. Red Hat software certification (RPM Dependency Tracking) also + # rejects packages that declare no dependencies at all. + dependencies: + - ca-certificates + # Generated in CI by chglog from git tags; Red Hat certification requires an + # RPM changelog (RPM Version Handling subtest). + changelog: changelog.yml + # License, documentation and a config file so the RPM Provenance subtest sees + # %license, %doc and %config entries instead of a bare binary. + contents: + - src: LICENSE + dst: /usr/share/licenses/netbird/LICENSE + type: license + - src: README.md + dst: /usr/share/doc/netbird/README.md + type: doc + - src: release_files/netbird.sysconfig + dst: /etc/sysconfig/netbird + type: config|noreplace scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" rpm: + summary: NetBird client + group: Applications/Internet + packager: NetBird signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' dockers_v2: diff --git a/AGENTS.md b/AGENTS.md index 5497acb15..3838a7913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ make lint # golangci-lint on files changed vs origin/main (also the p make lint-all # full-repository lint, matches CI make test-unit # host-safe unit tests, -tags devcert, no sudo make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN -make setup-hooks # wire make lint into .githooks/pre-push +make setup-hooks # wire .githooks: pre-push runs make lint, commit-msg refuses attribution trailers # Narrow runs go test ./client/internal/dns/... diff --git a/CLAUDE.md b/CLAUDE.md index 764f406be..72681748e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,4 @@ -See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository. +The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so +every session loads it in full rather than following a pointer. + +@AGENTS.md diff --git a/Makefile b/Makefile index 0a4fad2f2..26c5b932e 100644 --- a/Makefile +++ b/Makefile @@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT) # Setup git hooks for all developers setup-hooks: @git config core.hooksPath .githooks - @chmod +x .githooks/pre-push - @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + @chmod +x .githooks/pre-push .githooks/commit-msg + @echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers" # Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating). # Runs as a normal user with no sudo and leaves host networking untouched. diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index bf36b944b..d753ee43e 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) { // "none" would blank the UI at the exact moment it should say the session // ended. func (d *Status) GetSessionExpiresAt() time.Time { - d.mux.Lock() - defer d.mux.Unlock() + d.mux.RLock() + defer d.mux.RUnlock() return d.sessionExpiresAt } diff --git a/client/ui/main.go b/client/ui/main.go index 09a589506..74a87b4df 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -101,7 +101,7 @@ func main() { var tray *Tray app := newApplication(func() { if tray != nil { - tray.ShowWindow() + go tray.ShowWindow() } }) diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 1ba2ffdc0..24319dae0 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -22,6 +22,10 @@ type LanguageSubscriber interface { Subscribe() (<-chan preferences.UIPreferences, func()) } +type windowOp func(w *application.WebviewWindow, created bool) + +type windowCloser func(w *application.WebviewWindow) + // EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. const EventTriggerLogin = "trigger-login" @@ -37,6 +41,16 @@ const paintedFallback = 2 * time.Second const headlessTeardownDelay = 2 * time.Second +const ( + windowMain = "main" + windowSettings = "settings" + windowBrowserLogin = "browser-login" + windowSessionExpiration = "session-expiration" + windowInstallProgress = "install-progress" + windowWelcome = "welcome" + windowError = "error" +) + // Window background per effective appearance. Both match the body background // (bg-nb-gray DEFAULT) in globals.css so opaque native pixels and the webview // paint the same surface; keep the three in sync. @@ -202,8 +216,11 @@ type WindowManager struct { // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. hiddenForLogin []application.Window mu sync.Mutex - createMu sync.Mutex newMain func(startURL string) *application.WebviewWindow + creating map[string]bool + pendingOps map[string][]windowOp + pendingClose map[string]windowCloser + restoreGen uint64 ready map[uint]bool showPending map[uint]bool pendingTab map[uint]string @@ -223,6 +240,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo translator: translator, prefs: prefs, linuxIcon: linuxIcon, + creating: map[string]bool{}, + pendingOps: map[string][]windowOp{}, + pendingClose: map[string]windowCloser{}, ready: map[uint]bool{}, showPending: map[uint]bool{}, pendingTab: map[uint]string{}, @@ -252,7 +272,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { a := CurrentAppearance() w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ - Name: "settings", + Name: windowSettings, Title: s.title("window.title.settings"), Width: 900, Height: WindowHeight, @@ -273,6 +293,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -284,63 +305,68 @@ func (s *WindowManager) OpenSettings(tab string) { target = "general" } - w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow) + s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) { + s.mu.Lock() + ready := s.ready[w.ID()] + if !ready { + s.pendingTab[w.ID()] = target + } + s.mu.Unlock() - s.mu.Lock() - ready := s.ready[w.ID()] - if !ready { - s.pendingTab[w.ID()] = target - } - s.mu.Unlock() - - if ready { - s.app.Event.Emit(EventSettingsOpen, target) - } - s.showWhenReady(w) + if ready { + s.app.Event.Emit(EventSettingsOpen, target) + } + s.showWhenReady(w) + }) } // OpenBrowserLogin shows the SSO popup, creating it on first use. func (s *WindowManager) OpenBrowserLogin(uri string) { - s.mu.Lock() - defer s.mu.Unlock() - if s.browserLogin == nil { - startURL := "/#/dialog/browser-login" - if uri != "" { - startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) - } - s.hideOtherWindowsLocked("browser-login") - opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) - // Not always-on-top: it would obscure the browser tab the user logs in through. - opts.AlwaysOnTop = false - opts.InitialPosition = application.WindowCentered - // Open on the active (where users cursor is) display, like the session-expiration dialog. - opts.Screen = s.getScreenBasedOnCursorPosition() - s.browserLogin = s.app.Window.NewWithOptions(opts) - bl := s.browserLogin - bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() - // Only a live user red-X still has this registered; programmatic closers - // nil s.browserLogin first and clean up themselves. Guarding here stops a - // stale close event from wiping a replacement popup's state. - userClosed := s.browserLogin == bl - if userClosed { - s.browserLogin = nil - s.restoreHiddenWindowsLocked() - } - s.mu.Unlock() - if userClosed { - s.app.Event.Emit(EventBrowserLoginCancel) - } - }) - s.centerOnCursorScreen(s.browserLogin) - return - } + startURL := "/#/dialog/browser-login" if uri != "" { - s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } - s.centerOnCursorScreen(s.browserLogin) - s.browserLogin.Show() - s.browserLogin.Focus() + s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow { + return s.newBrowserLoginWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if created { + s.centerOnCursorScreen(w) + return + } + if uri != "" { + w.SetURL(startURL) + } + s.centerOnCursorScreen(w) + w.Show() + w.Focus() + }) +} + +func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow { + s.hideOtherWindows(windowBrowserLogin) + opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon) + // Not always-on-top: it would obscure the browser tab the user logs in through. + opts.AlwaysOnTop = false + opts.InitialPosition = application.WindowCentered + // Open on the active (where users cursor is) display, like the session-expiration dialog. + opts.Screen = s.getScreenBasedOnCursorPosition() + w := s.app.Window.NewWithOptions(opts) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == w + if userClosed { + s.browserLogin = nil + } + s.mu.Unlock() + if userClosed { + s.restoreHiddenWindows() + s.app.Event.Emit(EventBrowserLoginCancel) + } + }) + return w } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -360,71 +386,62 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { } func (s *WindowManager) CloseBrowserLogin() { - s.mu.Lock() - w := s.browserLogin - s.browserLogin = nil // The WindowClosing hook no-ops on a programmatic close, so restore here — // but only if a popup was actually open. The frontend calls this even when no // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, // or connection.ts's catch path), and hiddenForLogin is shared with // OpenInstallProgress, so an unconditional restore could re-show windows a // still-running install-progress is hiding. - if w != nil { - s.restoreHiddenWindowsLocked() - } - s.mu.Unlock() - if w != nil { - w.Close() - } + s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds // the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog // compares renewal snapshots against. Singleton, destroyed on close. func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { - s.mu.Lock() - defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) if deadlineUnixMilli > 0 { startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) } - if s.sessionExpiration == nil { - opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) - opts.Screen = s.getScreenBasedOnCursorPosition() - opts.InitialPosition = application.WindowCentered - s.sessionExpiration = s.app.Window.NewWithOptions(opts) - s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() + s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow { + return s.newSessionExpirationWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if created { + s.centerOnCursorScreen(w) + return + } + w.SetURL(startURL) + s.centerOnCursorScreen(w) + w.Show() + w.Focus() + }) +} + +func (s *WindowManager) newSessionExpirationWindow(startURL string) *application.WebviewWindow { + opts := DialogWindowOptions(windowSessionExpiration, s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + w := s.app.Window.NewWithOptions(opts) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.sessionExpiration == w { s.sessionExpiration = nil - s.mu.Unlock() - }) - s.centerOnCursorScreen(s.sessionExpiration) - return - } - s.sessionExpiration.SetURL(startURL) - s.centerOnCursorScreen(s.sessionExpiration) - s.sessionExpiration.Show() - s.sessionExpiration.Focus() + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseSessionExpiration() { - s.mu.Lock() - w := s.sessionExpiration - s.sessionExpiration = nil - s.mu.Unlock() - if w != nil { - w.Close() - } + s.closeWindow(windowSessionExpiration, &s.sessionExpiration, closeOnly) } // CloseRenewFlow tears down the SSO session-renewal UI in a single call: it // closes the browser-login popup and the session-expiration window together. func (s *WindowManager) CloseRenewFlow() { s.mu.Lock() - bl := s.browserLogin - se := s.sessionExpiration - s.browserLogin = nil - s.sessionExpiration = nil + bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) + se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly) if se != nil { kept := s.hiddenForLogin[:0] for _, w := range s.hiddenForLogin { @@ -434,9 +451,9 @@ func (s *WindowManager) CloseRenewFlow() { } s.hiddenForLogin = kept } - s.restoreHiddenWindowsLocked() s.mu.Unlock() + s.restoreHiddenWindows() // Close after unlock so the re-entrant handlers can take s.mu. if bl != nil { bl.Close() @@ -449,73 +466,70 @@ func (s *WindowManager) CloseRenewFlow() { // OpenInstallProgress shows the install-progress window and hides the rest for the duration // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { - s.mu.Lock() - defer s.mu.Unlock() startURL := "/#/dialog/install-progress" if version != "" { startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) } - if s.installProgress == nil { - s.hideOtherWindowsLocked("install-progress") - s.installProgress = s.app.Window.NewWithOptions( - DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon), - ) - s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() + s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow { + return s.newInstallProgressWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(startURL) + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newInstallProgressWindow(startURL string) *application.WebviewWindow { + s.hideOtherWindows(windowInstallProgress) + w := s.app.Window.NewWithOptions( + DialogWindowOptions(windowInstallProgress, s.title("window.title.updating"), startURL, s.linuxIcon), + ) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.installProgress == w { s.installProgress = nil - s.restoreHiddenWindowsLocked() - s.mu.Unlock() - }) - s.centerWhenReady(s.installProgress) - return - } - s.installProgress.SetURL(startURL) - s.installProgress.Show() - s.installProgress.Focus() - s.centerWhenReady(s.installProgress) + } + s.mu.Unlock() + s.restoreHiddenWindows() + }) + return w } func (s *WindowManager) CloseInstallProgress() { - s.mu.Lock() - w := s.installProgress - s.installProgress = nil - s.mu.Unlock() - if w != nil { - w.Close() - } + s.closeWindow(windowInstallProgress, &s.installProgress, closeOnly) } // OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. func (s *WindowManager) OpenWelcome() { - s.mu.Lock() - defer s.mu.Unlock() - if s.welcome == nil { - opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) - opts.Width = 420 - opts.InitialPosition = application.WindowCentered - s.welcome = s.app.Window.NewWithOptions(opts) - w := s.welcome - w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() + s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) { + if !created { + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { + opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + opts.InitialPosition = application.WindowCentered + w := s.app.Window.NewWithOptions(opts) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.welcome == w { s.welcome = nil - s.mu.Unlock() - }) - s.centerWhenReady(s.welcome) - return - } - s.welcome.Show() - s.welcome.Focus() - s.centerWhenReady(s.welcome) + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseWelcome() { - s.mu.Lock() - w := s.welcome - s.welcome = nil - s.mu.Unlock() - if w != nil { - w.Close() - } + s.closeWindow(windowWelcome, &s.welcome, closeOnly) } // OpenError shows the custom error dialog; title/message/command are pre-localised @@ -526,35 +540,35 @@ func (s *WindowManager) OpenError(title, message, command string) { if ShuttingDown() { return } - s.mu.Lock() - defer s.mu.Unlock() startURL := errorDialogURL(title, message, command) - if s.errorDialog == nil { - s.errorDialog = s.app.Window.NewWithOptions( - DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), - ) - s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { - s.mu.Lock() + s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow { + return s.newErrorWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(startURL) + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWindow { + w := s.app.Window.NewWithOptions( + DialogWindowOptions(windowError, s.title("window.title.error"), startURL, s.linuxIcon), + ) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.errorDialog == w { s.errorDialog = nil - s.mu.Unlock() - }) - s.centerWhenReady(s.errorDialog) - return - } - s.errorDialog.SetURL(startURL) - s.errorDialog.Show() - s.errorDialog.Focus() - s.centerWhenReady(s.errorDialog) + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseError() { - s.mu.Lock() - w := s.errorDialog - s.errorDialog = nil - s.mu.Unlock() - if w != nil { - w.Close() - } + s.closeWindow(windowError, &s.errorDialog, closeOnly) } // OpenMain brings the main window forward; the welcome handoff uses it instead of the tray. @@ -565,65 +579,171 @@ 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() { - s.showWhenReady(s.MainWindow()) + s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { + s.showWhenReady(w) + }) } // ShowMainAndEmit brings the main window forward and emits event once its frontend is ready. func (s *WindowManager) ShowMainAndEmit(event string) { - w := s.MainWindow() - if w == nil { - return - } + s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.pendingEmits[id] = append(s.pendingEmits[id], event) + } + s.mu.Unlock() - id := w.ID() - s.mu.Lock() - ready := s.ready[id] - if !ready { - s.pendingEmits[id] = append(s.pendingEmits[id], event) - } - s.mu.Unlock() - - s.showWhenReady(w) - if ready { - s.app.Event.Emit(event) - } + s.showWhenReady(w) + if ready { + s.app.Event.Emit(event) + } + }) } func (s *WindowManager) MainWindow() *application.WebviewWindow { - w, _ := s.ensureMain("/") - return w + s.mu.Lock() + defer s.mu.Unlock() + return s.mainWindow } -func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) { +func (s *WindowManager) ensureMain(startURL string, op windowOp) { s.mu.Lock() factory := s.newMain s.mu.Unlock() if factory == nil { - return s.ensureWindow(&s.mainWindow, nil) + s.withWindow(windowMain, &s.mainWindow, nil, op) + return } - return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow { - return factory(startURL) - }) + s.withWindow(windowMain, &s.mainWindow, func() *application.WebviewWindow { + w := factory(startURL) + s.armReady(w) + return w + }, op) } -func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) { - s.createMu.Lock() - defer s.createMu.Unlock() - +func (s *WindowManager) withWindow(name string, slot **application.WebviewWindow, factory func() *application.WebviewWindow, op windowOp) { s.mu.Lock() - w := *slot - s.mu.Unlock() - if w != nil || factory == nil { - return w, false + if s.creating[name] { + s.pendingOps[name] = append(s.pendingOps[name], op) + s.mu.Unlock() + return } + if w := *slot; w != nil { + s.mu.Unlock() + op(w, false) + return + } + if factory == nil { + s.mu.Unlock() + return + } + s.creating[name] = true + s.mu.Unlock() - w = factory() - s.armReady(w) + w := s.createWindow(name, slot, factory) + if w == nil { + return + } + s.finishCreation(name, slot, w, op) +} +func (s *WindowManager) createWindow(name string, slot **application.WebviewWindow, factory func() *application.WebviewWindow) *application.WebviewWindow { + created := false + defer func() { + if created { + return + } + s.mu.Lock() + s.releaseCreationLocked(name) + s.mu.Unlock() + }() + + w := factory() + if w == nil { + return nil + } s.mu.Lock() *slot = w s.mu.Unlock() - return w, true + created = true + return w +} + +func (s *WindowManager) finishCreation(name string, slot **application.WebviewWindow, w *application.WebviewWindow, op windowOp) { + finished := false + defer func() { + if finished { + return + } + s.mu.Lock() + s.releaseCreationLocked(name) + s.mu.Unlock() + }() + + created := true + for { + s.mu.Lock() + if closer := s.pendingClose[name]; closer != nil { + if *slot == w { + *slot = nil + } + s.releaseCreationLocked(name) + finished = true + s.mu.Unlock() + closer(w) + return + } + var next windowOp + switch { + case created: + next = op + case len(s.pendingOps[name]) > 0: + next = s.pendingOps[name][0] + s.pendingOps[name] = s.pendingOps[name][1:] + default: + s.releaseCreationLocked(name) + finished = true + s.mu.Unlock() + return + } + s.mu.Unlock() + next(w, created) + created = false + } +} + +func (s *WindowManager) closeWindow(name string, slot **application.WebviewWindow, closer windowCloser) { + s.mu.Lock() + w := s.takeWindowLocked(name, slot, closer) + s.mu.Unlock() + if w != nil { + closer(w) + } +} + +func (s *WindowManager) takeWindowLocked(name string, slot **application.WebviewWindow, closer windowCloser) *application.WebviewWindow { + if s.creating[name] { + if s.pendingClose[name] == nil { + s.pendingClose[name] = closer + } + return nil + } + w := *slot + *slot = nil + return w +} + +func (s *WindowManager) releaseCreationLocked(name string) { + delete(s.creating, name) + delete(s.pendingOps, name) + delete(s.pendingClose, name) +} + +func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) { + s.restoreHiddenWindows() + w.Close() } func (s *WindowManager) armReady(w *application.WebviewWindow) { @@ -663,24 +783,21 @@ func (s *WindowManager) watchTriggerLogin() { return } - w, created := s.ensureMain("/") - if w == nil { - return - } + s.ensureMain("/", func(w *application.WebviewWindow, created bool) { + s.mu.Lock() + if created { + s.headlessMain = true + } + pending := !s.ready[w.ID()] + if pending { + s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) + } + s.mu.Unlock() - s.mu.Lock() - if created { - s.headlessMain = true - } - pending := !s.ready[w.ID()] - if pending { - s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) - } - s.mu.Unlock() - - if !pending { - s.app.Event.Emit(EventTriggerLogin) - } + if !pending { + s.app.Event.Emit(EventTriggerLogin) + } + }) }) s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) { @@ -751,9 +868,9 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() switch name { - case "main": + case windowMain: return s.mainWindow - case "settings": + case windowSettings: return s.settings default: return nil @@ -828,14 +945,12 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) { } func (s *WindowManager) ShowMainAt(url string) { - w, created := s.ensureMain(url) - if w == nil { - return - } - if !created { - w.SetURL(url) - } - s.showWhenReady(w) + s.ensureMain(url, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(url) + } + s.showWhenReady(w) + }) } func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) { @@ -955,39 +1070,61 @@ func (s *WindowManager) retitleAll() { } } -// hideOtherWindowsLocked hides every visible window except keepName, recording -// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. -func (s *WindowManager) hideOtherWindowsLocked(keepName string) { +func (s *WindowManager) hideOtherWindows(keepName string) { + s.mu.Lock() + gen := s.restoreGen + s.mu.Unlock() + + var hidden []application.Window for _, w := range s.app.Window.GetAll() { - if w == nil || w.Name() == keepName { - continue - } - if !w.IsVisible() { + if w == nil || w.Name() == keepName || !w.IsVisible() { continue } w.Hide() - s.hiddenForLogin = append(s.hiddenForLogin, w) + hidden = append(hidden, w) + } + if len(hidden) == 0 { + return + } + + s.mu.Lock() + restored := s.restoreGen != gen + if !restored { + s.hiddenForLogin = append(s.hiddenForLogin, hidden...) + } + s.mu.Unlock() + if !restored { + return + } + for _, w := range hidden { + w.Show() } } -// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked -// (caller holds s.mu). If the main window was among them, raiseToForeground -// lifts it above the SSO browser, which still owns the foreground — a plain -// Show/Focus would be demoted to a taskbar flash and leave it stranded behind. -func (s *WindowManager) restoreHiddenWindowsLocked() { +// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main +// window was among them, raiseToForeground lifts it above the SSO browser, which +// still owns the foreground — a plain Show/Focus would be demoted to a taskbar +// flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindows() { + s.mu.Lock() + hidden := s.hiddenForLogin + s.hiddenForLogin = nil + s.restoreGen++ + mainWindow := s.mainWindow + s.mu.Unlock() + mainRestored := false - for _, w := range s.hiddenForLogin { + for _, w := range hidden { if w == nil { continue } w.Show() - if w == s.mainWindow { + if w == mainWindow { mainRestored = true } } - s.hiddenForLogin = nil - if mainRestored && s.mainWindow != nil { - raiseToForeground(s.mainWindow) + if mainRestored && mainWindow != nil { + raiseToForeground(mainWindow) } } @@ -1002,8 +1139,11 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { return sc } } - if s.mainWindow != nil { - if sc, err := s.mainWindow.GetScreen(); err == nil { + s.mu.Lock() + mainWindow := s.mainWindow + s.mu.Unlock() + if mainWindow != nil { + if sc, err := mainWindow.GetScreen(); err == nil { return sc } } @@ -1031,3 +1171,5 @@ func errorDialogURL(title, message, command string) string { // u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. func u32ptr(v uint32) *uint32 { return &v } + +func closeOnly(w *application.WebviewWindow) { w.Close() } diff --git a/client/ui/services/windowmanager_test.go b/client/ui/services/windowmanager_test.go new file mode 100644 index 000000000..13c8548ab --- /dev/null +++ b/client/ui/services/windowmanager_test.go @@ -0,0 +1,350 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/wailsapp/wails/v3/pkg/application" +) + +func newTestWindowManager() *WindowManager { + return &WindowManager{ + creating: map[string]bool{}, + pendingOps: map[string][]windowOp{}, + pendingClose: map[string]windowCloser{}, + } +} + +func waitDone(t *testing.T, done <-chan struct{}, msg string) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal(msg) + } +} + +func TestWithWindowReusesExistingWindow(t *testing.T) { + s := newTestWindowManager() + existing := &application.WebviewWindow{} + slot := existing + factoryCalls := 0 + var got *application.WebviewWindow + created := true + s.withWindow(windowMain, &slot, func() *application.WebviewWindow { + factoryCalls++ + return &application.WebviewWindow{} + }, func(w *application.WebviewWindow, c bool) { + got, created = w, c + }) + require.Equal(t, 0, factoryCalls) + require.Same(t, existing, got) + require.False(t, created) +} + +func TestWithWindowNilFactoryWithoutWindowSkipsOp(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + opCalls := 0 + s.withWindow(windowMain, &slot, nil, func(*application.WebviewWindow, bool) { + opCalls++ + }) + require.Equal(t, 0, opCalls) + require.Nil(t, slot) +} + +func TestWithWindowReentrantCallDuringCreationIsQueued(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + factoryCalls := 0 + var order []string + var factory func() *application.WebviewWindow + factory = func() *application.WebviewWindow { + factoryCalls++ + // Simulates the Windows message pump re-entering the tray click handler + // while WebView2 is still initialising the window being created. + s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) { + order = append(order, fmt.Sprintf("reentrant:%v", created)) + }) + return &application.WebviewWindow{} + } + + done := make(chan struct{}) + go func() { + defer close(done) + s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) { + order = append(order, fmt.Sprintf("outer:%v", created)) + }) + }() + waitDone(t, done, "withWindow deadlocked on a re-entrant call during creation") + + require.Equal(t, 1, factoryCalls) + require.Equal(t, []string{"outer:true", "reentrant:false"}, order) + require.NotNil(t, slot) + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) +} + +func TestWithWindowConcurrentCallersShareOneCreation(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + factoryEntered := make(chan struct{}) + release := make(chan struct{}) + var factoryCalls, opCalls atomic.Int32 + factory := func() *application.WebviewWindow { + factoryCalls.Add(1) + close(factoryEntered) + <-release + return &application.WebviewWindow{} + } + op := func(*application.WebviewWindow, bool) { opCalls.Add(1) } + + first := make(chan struct{}) + go func() { + defer close(first) + s.withWindow(windowSettings, &slot, factory, op) + }() + <-factoryEntered + + second := make(chan struct{}) + go func() { + defer close(second) + s.withWindow(windowSettings, &slot, factory, op) + }() + waitDone(t, second, "second caller blocked while the window was being created") + require.Equal(t, int32(0), opCalls.Load()) + + close(release) + waitDone(t, first, "creator did not finish") + + require.Equal(t, int32(1), factoryCalls.Load()) + require.Equal(t, int32(2), opCalls.Load()) + require.NotNil(t, slot) +} + +func TestWithWindowOpsQueuedDuringCreationRunInArrivalOrder(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + var order []string + record := func(label string) windowOp { + return func(_ *application.WebviewWindow, created bool) { + order = append(order, fmt.Sprintf("%s:%v", label, created)) + } + } + var factory func() *application.WebviewWindow + factory = func() *application.WebviewWindow { + s.withWindow(windowMain, &slot, factory, func(w *application.WebviewWindow, created bool) { + record("a")(w, created) + // Arrives while the creator is still draining the queue: it must not + // jump ahead of "b" through the existing-window fast path. + s.withWindow(windowMain, &slot, factory, record("c")) + }) + s.withWindow(windowMain, &slot, factory, record("b")) + return &application.WebviewWindow{} + } + + done := make(chan struct{}) + go func() { + defer close(done) + s.withWindow(windowMain, &slot, factory, record("outer")) + }() + waitDone(t, done, "withWindow deadlocked while draining queued operations") + + require.Equal(t, []string{"outer:true", "a:false", "b:false", "c:false"}, order) + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) +} + +func TestWithWindowFactoryPanicReleasesCreation(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + func() { + defer func() { require.NotNil(t, recover()) }() + s.withWindow(windowMain, &slot, func() *application.WebviewWindow { + panic("factory failed") + }, func(*application.WebviewWindow, bool) {}) + }() + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) + require.Nil(t, slot) + + created := false + s.withWindow(windowMain, &slot, func() *application.WebviewWindow { + return &application.WebviewWindow{} + }, func(_ *application.WebviewWindow, c bool) { + created = c + }) + require.True(t, created) + require.NotNil(t, slot) +} + +func TestWithWindowNilFromFactoryReleasesCreation(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + opCalls := 0 + s.withWindow(windowMain, &slot, func() *application.WebviewWindow { + return nil + }, func(*application.WebviewWindow, bool) { + opCalls++ + }) + require.Equal(t, 0, opCalls) + require.Empty(t, s.creating) + require.Nil(t, slot) +} + +func TestCloseWindowDuringCreationDefersCloseAndSkipsOps(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + created := &application.WebviewWindow{} + opCalls, closeCalls := 0, 0 + var closed *application.WebviewWindow + s.withWindow(windowError, &slot, func() *application.WebviewWindow { + s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) { + closeCalls++ + closed = w + }) + require.Equal(t, 0, closeCalls) + return created + }, func(*application.WebviewWindow, bool) { + opCalls++ + }) + require.Equal(t, 0, opCalls) + require.Equal(t, 1, closeCalls) + require.Same(t, created, closed) + require.Nil(t, slot) + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) + require.Empty(t, s.pendingClose) + + factoryCalls := 0 + reopened := false + s.withWindow(windowError, &slot, func() *application.WebviewWindow { + factoryCalls++ + return &application.WebviewWindow{} + }, func(_ *application.WebviewWindow, c bool) { + reopened = c + }) + require.Equal(t, 1, factoryCalls) + require.True(t, reopened) + require.NotNil(t, slot) +} + +func TestCloseWindowDuringDrainStopsRemainingOps(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + var order []string + closeCalls := 0 + var factory func() *application.WebviewWindow + factory = func() *application.WebviewWindow { + s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) { + order = append(order, "a") + s.closeWindow(windowWelcome, &slot, func(*application.WebviewWindow) { closeCalls++ }) + s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) { + order = append(order, "c") + }) + }) + s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) { + order = append(order, "b") + }) + return &application.WebviewWindow{} + } + s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) { + order = append(order, "outer") + }) + + // "b" was queued before the close and "c" after it; a close supersedes both + // rather than showing a window that is about to be destroyed. + require.Equal(t, []string{"outer", "a"}, order) + require.Equal(t, 1, closeCalls) + require.Nil(t, slot) + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) + require.Empty(t, s.pendingClose) +} + +func TestCloseWindowWithoutWindowSkipsCloser(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + calls := 0 + s.closeWindow(windowBrowserLogin, &slot, func(*application.WebviewWindow) { calls++ }) + require.Equal(t, 0, calls) + require.Nil(t, slot) + require.Empty(t, s.pendingClose) +} + +func TestCloseWindowWithExistingWindowRunsCloser(t *testing.T) { + s := newTestWindowManager() + existing := &application.WebviewWindow{} + slot := existing + var got *application.WebviewWindow + s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) { got = w }) + require.Same(t, existing, got) + require.Nil(t, slot) + require.Empty(t, s.pendingClose) +} + +func TestWithWindowNilFromFactoryDropsPendingClose(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + closeCalls := 0 + s.withWindow(windowError, &slot, func() *application.WebviewWindow { + s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ }) + return nil + }, func(*application.WebviewWindow, bool) {}) + require.Equal(t, 0, closeCalls) + require.Nil(t, slot) + require.Empty(t, s.creating) + require.Empty(t, s.pendingClose) +} + +func TestWithWindowFactoryPanicDropsPendingClose(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + closeCalls := 0 + func() { + defer func() { require.NotNil(t, recover()) }() + s.withWindow(windowError, &slot, func() *application.WebviewWindow { + s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ }) + panic("factory failed") + }, func(*application.WebviewWindow, bool) {}) + }() + require.Equal(t, 0, closeCalls) + require.Empty(t, s.creating) + require.Empty(t, s.pendingClose) +} + +func TestCloseWindowKeepsFirstDeferredCloser(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + var ran []string + s.withWindow(windowError, &slot, func() *application.WebviewWindow { + s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "first") }) + s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "second") }) + return &application.WebviewWindow{} + }, func(*application.WebviewWindow, bool) {}) + require.Equal(t, []string{"first"}, ran) + require.Nil(t, slot) + require.Empty(t, s.pendingClose) +} + +func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testing.T) { + s := newTestWindowManager() + s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow { + s.CloseRenewFlow() + // Seeded after the call so the deferred closer, not CloseRenewFlow's own + // immediate restore, is what has to drain it. A nil entry is skipped by + // restoreHiddenWindows, so no Wails window is needed. + s.hiddenForLogin = []application.Window{nil} + return &application.WebviewWindow{} + }, func(*application.WebviewWindow, bool) {}) + + require.Nil(t, s.browserLogin) + require.Empty(t, s.hiddenForLogin) + require.Empty(t, s.creating) + require.Empty(t, s.pendingClose) +} diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go index 17a6dc5df..04c66dd8a 100644 --- a/client/ui/tray_click_windows.go +++ b/client/ui/tray_click_windows.go @@ -4,5 +4,5 @@ package main // Open application window on left click, right click opens the tray menu func bindTrayClick(t *Tray) { - t.tray.OnClick(func() { t.ShowWindow() }) + t.tray.OnClick(func() { go t.ShowWindow() }) } diff --git a/e2e/agentnetwork/agent_config_test.go b/e2e/agentnetwork/agent_config_test.go index 58bfddab3..89e7202e0 100644 --- a/e2e/agentnetwork/agent_config_test.go +++ b/e2e/agentnetwork/agent_config_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/e2e/harness" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -65,16 +66,24 @@ func configProvider(cfg api.AgentNetworkAgentConfig, name string) *api.AgentNetw func TestAgentConfigAllowlistOfDeclaredModels(t *testing.T) { ctx := context.Background() + // Saving a provider makes management verify the credential against the + // upstream, and a real vendor refuses the dummy key and the save with it. + // The providers point at the mock upstream instead: it resolves to a + // private address, which the check declines to dial and treats as + // unverifiable rather than as a failure, so the save goes through. The + // test is about the allowlist, not the upstream. + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + cases := []struct { name string catalogID string - upstream string declared string }{ { name: "plain-declared-id", catalogID: "openai_api", - upstream: "https://api.openai.com", declared: "gpt-4o-mini", }, { @@ -83,7 +92,6 @@ func TestAgentConfigAllowlistOfDeclaredModels(t *testing.T) { // picker copies it as-is. name: "bedrock-declared-id", catalogID: "bedrock_api", - upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com", declared: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", }, } @@ -101,7 +109,7 @@ func TestAgentConfigAllowlistOfDeclaredModels(t *testing.T) { prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ Name: providerName, ProviderId: tc.catalogID, - UpstreamUrl: tc.upstream, + UpstreamUrl: vllm.URL, ApiKey: ptr("sk-dummy-e2e-key"), Enabled: ptr(true), Models: &[]api.AgentNetworkProviderModel{{Id: tc.declared, InputPer1k: 0.001, OutputPer1k: 0.002}}, diff --git a/e2e/agentnetwork/settings_cluster_validation_test.go b/e2e/agentnetwork/settings_cluster_validation_test.go new file mode 100644 index 000000000..82c84dc74 --- /dev/null +++ b/e2e/agentnetwork/settings_cluster_validation_test.go @@ -0,0 +1,178 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestSettingsBootstrapValidatesProxyCluster covers the bootstrap-time check +// on the picked cluster, end to end against a real proxy. +// +// The synthesised gateway service is always private: agents reach it over the +// WireGuard tunnel and are authorised by their peer identity. Only a cluster +// with private capabilities can serve that, and management reports it per +// cluster as the `private` capability — the same supports_private flag the +// dashboard reads to decide which clusters it may offer. The endpoint assigned at bootstrap is immutable, so pinning +// to a cluster that cannot serve it has to be refused up front rather than +// leaving the account with a dead gateway. +// +// One combined server and one cluster address, walked through three states: +// a live centralised proxy (refused), that proxy stopped so nothing in the +// cluster is live any more (still refused — the record of what the cluster is +// outlives its heartbeats), and finally a private-capable proxy (accepted, the +// capability being any-true across the cluster's live proxies). Same account, +// same address, so nothing but the cluster's state accounts for the different +// answers. +func TestSettingsBootstrapValidatesProxyCluster(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + proxyToken, err := fresh.CreateProxyTokenCLI(ctx, "e2e-cluster-validation") + require.NoError(t, err, "mint proxy token via CLI") + + const cluster = harness.AgentNetworkCluster + + // A centralised proxy: connected and serving the cluster, but without + // private capabilities, so it cannot serve a private service. + central, err := harness.StartProxy(ctx, fresh, proxyToken, map[string]string{ + "NB_PROXY_PRIVATE": "false", + }) + require.NoError(t, err, "start centralised proxy") + // Terminated mid-test; the cleanup only covers an early failure. + t.Cleanup(func() { _ = central.Terminate(context.Background()) }) + + waitClusterPrivate(ctx, t, fresh, cluster, false) + + _, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + ProxyAddress: ptr(cluster), + }) + require.Error(t, err, "bootstrap onto a cluster without private capabilities must be refused") + requireClientError(t, err) + assert.Contains(t, err.Error(), "private capabilities", + "the refusal must name what the cluster is missing: %v", err) + + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "settings must still read after a refused bootstrap") + assert.Empty(t, after.Endpoint, "a refused bootstrap must not assign an endpoint") + assert.Empty(t, after.ProxyAddress, "a refused bootstrap must not pin a cluster") + + // Stopping the centralised proxy must not turn the refusal into an + // acceptance: the cluster's proxy rows outlive their heartbeats (only the + // hourly stale reaper removes them), so the cluster is still on record as + // one that cannot serve the gateway. Judging on liveness instead would + // make "wait for the proxy to go quiet" a way to pin the account's + // immutable endpoint to a cluster that can never serve it. + require.NoError(t, central.Terminate(ctx), "stop the centralised proxy") + waitClusterAbsent(ctx, t, fresh, cluster) + + _, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + ProxyAddress: ptr(cluster), + }) + require.Error(t, err, "an offline cluster without private capabilities on record must stay refused") + requireClientError(t, err) + + // Add a private-capable proxy to the same cluster: now it can serve a private + // service, and the very same request must go through. + privateProxy, err := harness.StartProxy(ctx, fresh, proxyToken) + require.NoError(t, err, "start private-capable proxy") + t.Cleanup(func() { _ = privateProxy.Terminate(context.Background()) }) + + waitClusterPrivate(ctx, t, fresh, cluster, true) + + bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + ProxyAddress: ptr(cluster), + }) + require.NoError(t, err, "bootstrap onto a private-capable cluster must succeed") + assert.Equal(t, cluster, bootstrapped.ProxyAddress, "the pinned cluster is the requested one") + assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster), + "the endpoint must hang one label beneath the cluster: %s", bootstrapped.Endpoint) +} + +// waitClusterPrivate polls the domains endpoint — the list the dashboard picks +// its bootstrap cluster from — until the free domain for clusterAddr reports +// supports_private == want. A proxy's capabilities land when it registers, so +// this is the barrier between starting a proxy and asserting on what +// management thinks its cluster can do. +func waitClusterPrivate(ctx context.Context, t *testing.T, c *harness.Combined, clusterAddr string, want bool) { + t.Helper() + + deadline := time.Now().Add(90 * time.Second) + var last string + for time.Now().Before(deadline) { + domains, err := c.API().ReverseProxyDomains.List(ctx) + if err != nil { + last = "list domains: " + err.Error() + } else { + last = "cluster not listed" + for _, d := range domains { + if d.Domain != clusterAddr { + continue + } + if d.SupportsPrivate == nil { + last = "supports_private not reported yet" + break + } + if *d.SupportsPrivate == want { + return + } + last = "supports_private is not the expected value" + break + } + } + if !waitBeforeRetry(ctx, 2*time.Second) { + break + } + } + t.Fatalf("cluster %s never reported supports_private=%v: %s", clusterAddr, want, last) +} + +// waitClusterAbsent polls the domains endpoint until clusterAddr is no longer +// offered, i.e. management sees no live proxy in it. The free-domain list is +// built from the active clusters, so this is how a proxy going away becomes +// observable — while the cluster's rows, and so its capability record, remain. +// +// The budget has to clear the active window, not just the disconnect: a proxy +// that closes its stream cleanly is marked disconnected at once, but one that +// dies without that is only dropped when its last heartbeat ages past +// proxyActiveThreshold (2 minutes), so a 90s deadline could fail the test on +// the slow path alone. +func waitClusterAbsent(ctx context.Context, t *testing.T, c *harness.Combined, clusterAddr string) { + t.Helper() + + deadline := time.Now().Add(3 * time.Minute) + var last string + for time.Now().Before(deadline) { + domains, err := c.API().ReverseProxyDomains.List(ctx) + if err != nil { + last = "list domains: " + err.Error() + } else { + listed := false + for _, d := range domains { + if d.Domain == clusterAddr { + listed = true + break + } + } + if !listed { + return + } + last = "cluster still listed as active" + } + if !waitBeforeRetry(ctx, 2*time.Second) { + break + } + } + t.Fatalf("cluster %s never dropped out of the active list: %s", clusterAddr, last) +} diff --git a/go.mod b/go.mod index 25ac4f748..585a3d9bc 100644 --- a/go.mod +++ b/go.mod @@ -162,6 +162,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect @@ -193,13 +194,18 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/crowdsecurity/go-cs-lib v0.0.25 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.2 // indirect github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -220,6 +226,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -227,6 +234,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/goreleaser/chglog v0.7.4 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -243,12 +251,14 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.4 // indirect @@ -284,12 +294,14 @@ require ( github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect github.com/pion/dtls/v3 v3.0.9 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/transport/v2 v2.2.4 // indirect github.com/pion/turn/v4 v4.1.1 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pquerna/otp v1.5.0 // indirect @@ -298,9 +310,16 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/viper v1.21.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -308,7 +327,9 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zeebo/blake3 v0.2.3 // indirect + gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect @@ -323,6 +344,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect rsc.io/qr v0.2.0 // indirect ) @@ -331,7 +353,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 @@ -345,4 +367,7 @@ replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 -tool go.uber.org/mock/mockgen +tool ( + github.com/goreleaser/chglog/cmd/chglog + go.uber.org/mock/mockgen +) diff --git a/go.sum b/go.sum index eec6e4700..0481e60d8 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,11 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= @@ -44,6 +47,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= @@ -138,6 +143,8 @@ github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy1 github.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA= github.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4= github.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -160,6 +167,10 @@ github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEM github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA= github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0= github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -181,6 +192,14 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= @@ -248,6 +267,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -298,6 +319,10 @@ github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gopacket/gopacket v1.4.0 h1:cr1OlFpzksCkZHNO0eLjaSSOrMQnpPXg0j6qHIY3y2U= github.com/gopacket/gopacket v1.4.0/go.mod h1:EpvsxINeehp5qj4YMKMLf2/dekdhKn2IIAO/ZOifS7o= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/goreleaser/chglog v0.7.4 h1:3pnNt/XCrUcAOq+KC91Azlgp5CRv4GHo1nl8Aws7OzI= +github.com/goreleaser/chglog v0.7.4/go.mod h1:dTVoZZagTz7hHdWaZ9OshHntKiF44HbWIHWxYJQ/h0Y= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -349,6 +374,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -377,9 +404,13 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= @@ -492,8 +523,8 @@ github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= -github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= -github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a h1:Nt8BgkTkI56LGBPPEBywM406MVKJDqeDIVdgsZyYs80= +github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -560,6 +591,8 @@ github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -599,16 +632,31 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -616,6 +664,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -623,6 +673,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -634,6 +685,8 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= @@ -668,6 +721,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -682,6 +737,8 @@ github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -727,6 +784,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -761,6 +819,7 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= @@ -795,6 +854,7 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -802,11 +862,14 @@ golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -841,6 +904,7 @@ golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -914,6 +978,8 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 701598a60..e88436e84 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -808,9 +808,8 @@ server: # Trust X-Forwarded-* only from the Traefik container's static address. Both # keys must stay in step with the ipv4_address pinned in docker-compose.yml: - # trustedPeers decides whether forwarded headers are read at all. Leaving it - # unset trusts nothing and records Traefik's own address as every peer's - # connection IP. + # trustedPeers restricts which sources may supply forwarded headers. Leaving + # it unset trusts all IPv4 and IPv6 sources. reverseProxy: trustedPeers: - "${TRAEFIK_IP}/32" diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index afbc5c282..d5c6d9dc9 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -586,9 +586,9 @@ configure_reverse_proxy() { TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-}" if [[ -z "$TRUSTED_PEERS" ]]; then echo "" > /dev/stderr - echo "Note: reverseProxy.trustedPeers is unset, so NetBird will use the address your" > /dev/stderr - echo "proxy connects from as each peer's connection IP. To record real client IPs," > /dev/stderr - echo "set NETBIRD_TRUSTED_PEERS to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr + echo "Warning: reverseProxy.trustedPeers is unset, so all IPv4 and IPv6 sources" > /dev/stderr + echo "are trusted to provide forwarded client-IP headers. Set NETBIRD_TRUSTED_PEERS" > /dev/stderr + echo "to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr echo "" > /dev/stderr fi fi diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go index 2665c9777..19acd8751 100644 --- a/management/internals/modules/agentnetwork/handlers/handlers_test.go +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "testing" + "time" "go.uber.org/mock/gomock" "github.com/gorilla/mux" @@ -17,6 +18,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/internals/modules/permissions" @@ -29,6 +31,9 @@ import ( const ( testAccountID = "acc-1" testUserID = "user-bob" + // testClusterAddress is the shared proxy cluster the settings tests pin + // their gateway to; the fixture seeds a connected private-capable proxy for it. + testClusterAddress = "eu.proxy.netbird.io" ) // agentNetworkHandlerFixture builds a real agentnetwork.Manager with @@ -75,6 +80,12 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture { manager := agentnetwork.NewManager(st, perms, accounts, nil) h := &handler{manager: manager} + // The labeled bootstrap validates its proxy_address against the live + // clusters, so seed the shared cluster these tests pin to as a real, + // private-capable one — the wire-shape assertions then run through the + // validated path rather than the "nothing connected yet" carve-out. + seedSharedPrivateCluster(t, st, testClusterAddress) + router := mux.NewRouter() router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST") router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET") @@ -268,3 +279,21 @@ func TestConsumptionHandler_PopulatedAccountListsRows(t *testing.T) { assert.Equal(t, groupRow.WindowStartUtc, userRow.WindowStartUtc, "rows recorded in the same window must share the aligned window_start_utc") } + +// seedSharedPrivateCluster registers a connected, NetBird-operated proxy +// with private capabilities (the `private` capability) so +// clusterAddr is a cluster any account may pin its agent-network gateway to. +func seedSharedPrivateCluster(t *testing.T, st store.Store, clusterAddr string) { + t.Helper() + private := true + now := time.Now().UTC() + require.NoError(t, st.SaveProxy(context.Background(), &rpproxy.Proxy{ + ID: "shared-proxy-" + clusterAddr, + SessionID: "shared-session", + ClusterAddress: clusterAddr, + LastSeen: now, + ConnectedAt: &now, + Status: rpproxy.StatusConnected, + Capabilities: rpproxy.Capabilities{Private: &private}, + }), "seeding the shared proxy cluster must succeed") +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index a6ff44c54..ad572916e 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -1036,6 +1036,18 @@ func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *type if err != nil { return status.Errorf(status.InvalidArgument, "invalid endpoint: %s", err) } + if err := m.requireHostNotForeign(ctx, settings.AccountID, hostname); err != nil { + return err + } + // Another account's labeled pin beneath this hostname makes it their + // cluster: a proxy serving them there would never serve this endpoint. + // The domain unique index already arbitrates two endpoints on one name. + if err := m.requireNotClaimedByOtherAccount(ctx, settings.AccountID, hostname, m.store.HasGatewayClusterPinnedByOtherAccount); err != nil { + return err + } + if err := m.validateGatewayCluster(ctx, settings.AccountID, hostname); err != nil { + return err + } settings.Domain = hostname settings.ProxyAddress = hostname @@ -1054,6 +1066,99 @@ func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *type return nil } +// validateGatewayCluster rejects a bootstrap pinned to a cluster that cannot +// serve the account's gateway — a labeled endpoint beneath the cluster and a +// self-addressed one on the very address a proxy declares alike, since the +// service behind either is the same private one. +// +// The synthesised gateway service is unconditionally private +// (buildAccountService): agents reach it over the WireGuard tunnel and are +// authorised by ValidateTunnelPeer against the policies' source groups, and +// its single target is the cluster itself with DirectUpstream. Only a cluster +// with private capabilities can serve that. Management reports it per cluster +// as the `private` capability, the same flag the dashboard renders as +// supports_private when it gates NetBird-only services. +// +// Without this check the bootstrap happily pins to any cluster the caller +// names, including one without private capabilities — and the endpoint it +// allocates is immutable, so the account is left with a dead gateway that only +// a DeleteSettings/re-bootstrap can undo. +// +// Whether management knows the cluster is decided on the proxy rows +// themselves, never on how fresh their heartbeats are: a cluster's rows +// outlive its proxies' liveness (only the stale-proxy reaper removes them), so +// a cluster that exists stays judged as one. Judging on liveness instead would +// make the same centralised cluster pass or fail depending on whether its +// proxies happened to have heartbeated in the last couple of minutes. +// +// The single opening left is a cluster management holds no proxy row for at +// all: pinning ahead of a proxy's first connection is a legitimate order — the +// dedicated path claims an address the same way, before any proxy declares it. +func (m *managerImpl) validateGatewayCluster(ctx context.Context, accountID, clusterAddr string) error { + declared, err := m.accountClusterSpellings(ctx, accountID, clusterAddr) + if err != nil { + return err + } + if len(declared) == 0 { + // No proxy has ever declared this address: an address-first pin. + return nil + } + + // A cluster management knows has to prove it can serve the gateway, and + // only a live proxy reporting the capability proves that. Both an explicit false and an + // unreported capability (nothing live in the cluster, or proxies predating + // capability reporting) fail here: unusable and unproven are the same + // answer for a decision that cannot be revisited later. + // + // The capability is read per declared spelling and taken as any-true, the + // same way it aggregates over a cluster's proxies: the store matches + // cluster_address exactly, so a host two proxies spelled differently must + // not come back unproven just because it was asked about under one of them. + for _, address := range declared { + if private := m.store.GetClusterSupportsPrivate(ctx, address); private != nil && *private { + return nil + } + } + + return status.Errorf(status.InvalidArgument, + "proxy cluster %s has no private capabilities: the agent network gateway requires a reverse proxy cluster "+ + "with private capabilities", clusterAddr) +} + +// accountClusterSpellings returns every proxy cluster address in the account's +// view — its own (BYOP) clusters plus the shared ones — that names the same +// host as clusterAddr. Empty means management holds no proxy row for that host +// in this account's view. +// +// A proxy declares its cluster address as the operator spelled it, so identity +// is compared on the normalised form rather than byte-equal — an in-memory pass +// over the account's clusters, not a query. What comes back is the stored +// spelling, because the capability lookup matches cluster_address exactly and +// would silently find nothing under a spelling the store never held. The +// cluster listing is not gated on heartbeats, so this answer does not change +// while a cluster's proxies are merely offline. +func (m *managerImpl) accountClusterSpellings(ctx context.Context, accountID, clusterAddr string) ([]string, error) { + clusters, err := m.store.GetProxyClusters(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("list proxy clusters: %w", err) + } + + var spellings []string + for _, cluster := range clusters { + normalized, err := types.NormalizeHostname(cluster.Address) + if err != nil { + // An address declared in a shape we cannot normalise is not one an + // endpoint can be allocated beneath. + log.WithContext(ctx).Debugf("skipping unusable proxy cluster address %q: %s", cluster.Address, err) + continue + } + if normalized == clusterAddr { + spellings = append(spellings, cluster.Address) + } + } + return spellings, nil +} + // bootstrapLabeled allocates a labeled endpoint one label beneath the given // cluster address: Domain =