mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 22:29:08 +02:00
Merge remote-tracking branch 'origin/main' into refactor/permissions-manager
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+26
@@ -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 <emoji> 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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <dev@netbird.io>
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
dockers_v2:
|
||||
|
||||
@@ -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/...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ func main() {
|
||||
var tray *Tray
|
||||
app := newApplication(func() {
|
||||
if tray != nil {
|
||||
tray.ShowWindow()
|
||||
go tray.ShowWindow()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+391
-249
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 = <label>.<proxyAddress>, served by whichever proxy
|
||||
// declares the parent. Labels are adjective-noun tuples; a candidate is
|
||||
@@ -1065,6 +1170,20 @@ func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Sett
|
||||
if err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid proxy_address: %s", err)
|
||||
}
|
||||
if err := m.requireHostNotForeign(ctx, settings.AccountID, parent); err != nil {
|
||||
return err
|
||||
}
|
||||
// Another account's endpoint at this exact hostname means the proxy that
|
||||
// declares it is theirs, so nothing would serve a label beneath it. Other
|
||||
// accounts' labeled pins under the same cluster are not asked about: a
|
||||
// shared cluster carries many of them by design.
|
||||
if err := m.requireNotClaimedByOtherAccount(ctx, settings.AccountID, parent, m.store.HasGatewayEndpointByOtherAccount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.validateGatewayCluster(ctx, settings.AccountID, parent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxDomainAllocationAttempts; attempt++ {
|
||||
label := labelgen.PickTuple()
|
||||
@@ -1111,6 +1230,41 @@ func (m *managerImpl) bootstrapLabeled(ctx context.Context, settings *types.Sett
|
||||
return fmt.Errorf("allocate agent network endpoint for account %s: %d attempts exhausted", settings.AccountID, maxDomainAllocationAttempts)
|
||||
}
|
||||
|
||||
// requireHostNotForeign refuses to pin the account's gateway onto a host that
|
||||
// another account's proxy declares. The pin's proxy_address is what selects
|
||||
// the proxy that serves the endpoint, and an account-scoped proxy only ever
|
||||
// receives its own account's mappings, so such a pin could never be served —
|
||||
// and the endpoint it assigns is immutable. Shared proxies are not foreign, and
|
||||
// a host no proxy has declared stays pinnable: claiming the address before the
|
||||
// proxy's first connection is the documented order.
|
||||
func (m *managerImpl) requireHostNotForeign(ctx context.Context, accountID, host string) error {
|
||||
foreign, err := m.store.HasForeignAccountProxyAtHost(ctx, host, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check proxy host ownership: %w", err)
|
||||
}
|
||||
if foreign {
|
||||
return errHostNotAvailable(host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireNotClaimedByOtherAccount refuses the pin when another account's
|
||||
// gateway settings already claim the host in the shape claimed answers for.
|
||||
func (m *managerImpl) requireNotClaimedByOtherAccount(ctx context.Context, accountID, host string, claimed func(context.Context, string, string) (bool, error)) error {
|
||||
taken, err := claimed(ctx, host, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check agent network gateway claims at host: %w", err)
|
||||
}
|
||||
if taken {
|
||||
return errHostNotAvailable(host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errHostNotAvailable(host string) error {
|
||||
return status.Errorf(status.InvalidArgument, "proxy cluster %s is not available to this account", host)
|
||||
}
|
||||
|
||||
// isUniqueConstraintError reports whether err is a database unique-constraint
|
||||
// violation, matched on the driver message because CreateAgentNetworkSettings
|
||||
// deliberately returns the driver error unwrapped.
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
|
||||
@@ -70,6 +72,57 @@ func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID
|
||||
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
|
||||
}
|
||||
|
||||
func ptrTo[T any](v T) *T { return &v }
|
||||
|
||||
// seedProxy registers a proxy in clusterAddr, heartbeating now, so the labeled
|
||||
// bootstrap path has a real cluster to validate against. accountID empty makes
|
||||
// it a shared (NetBird-operated) cluster; private mirrors the capability an
|
||||
// proxy with private capabilities reports, nil an unreported one.
|
||||
func (f *bootstrapFixture) seedProxy(t *testing.T, proxyID, accountID, clusterAddr string, private *bool) {
|
||||
t.Helper()
|
||||
f.seedProxyAt(t, proxyID, accountID, clusterAddr, private, time.Now().UTC())
|
||||
}
|
||||
|
||||
// seedProxyAt is seedProxy with an explicit last-seen, for cases that need a
|
||||
// proxy whose heartbeat has aged past the active window while its row (and so
|
||||
// its cluster) is still on record.
|
||||
func (f *bootstrapFixture) seedProxyAt(t *testing.T, proxyID, accountID, clusterAddr string, private *bool, lastSeen time.Time) {
|
||||
t.Helper()
|
||||
p := &proxy.Proxy{
|
||||
ID: proxyID,
|
||||
ClusterAddress: clusterAddr,
|
||||
Status: proxy.StatusConnected,
|
||||
LastSeen: lastSeen,
|
||||
Capabilities: proxy.Capabilities{Private: private},
|
||||
}
|
||||
if accountID != "" {
|
||||
p.AccountID = &accountID
|
||||
}
|
||||
require.NoError(t, f.store.SaveProxy(context.Background(), p), "seeding a proxy must succeed")
|
||||
}
|
||||
|
||||
// seedPrivateCluster is the common case: a shared cluster with a connected
|
||||
// proxy that has private capabilities, which is what a bootstrap requires.
|
||||
func (f *bootstrapFixture) seedPrivateCluster(t *testing.T, clusterAddr string) {
|
||||
t.Helper()
|
||||
f.seedProxy(t, "proxy-"+clusterAddr, "", clusterAddr, ptrTo(true))
|
||||
}
|
||||
|
||||
// requireForeignClusterRefusal asserts the refusal a pin onto another
|
||||
// account's host gets, and that it left no row behind.
|
||||
func (f *bootstrapFixture) requireForeignClusterRefusal(t *testing.T, err error, accountID string) {
|
||||
t.Helper()
|
||||
require.Error(t, err, "another account's host must be refused")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
||||
assert.Contains(t, err.Error(), "not available to this account",
|
||||
"the error must say the host is not the account's to use")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(context.Background(), store.LockingStrengthNone, accountID)
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
}
|
||||
|
||||
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
|
||||
// account's immutable endpoint, a settings write requiring the settings
|
||||
// Create permission — and a denial leaves no row behind.
|
||||
@@ -94,6 +147,7 @@ func TestCreateSettingsRequiresPermission(t *testing.T) {
|
||||
func TestCreateSettingsLabeled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedPrivateCluster(t, "cluster1.example.com")
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
|
||||
@@ -167,6 +221,7 @@ func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
|
||||
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedPrivateCluster(t, "cluster1.example.com")
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
||||
@@ -230,3 +285,279 @@ func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "provider create must not conjure a settings row")
|
||||
}
|
||||
|
||||
// TestCreateSettingsRejectsOfflineCluster is the guard against deciding on
|
||||
// heartbeat freshness. A centralised cluster is refused while its proxies are
|
||||
// live; the same cluster must stay refused once they stop heartbeating, which
|
||||
// takes only a couple of minutes (proxyActiveThreshold). Judging on liveness
|
||||
// would turn "wait for the proxy to go quiet" into a way to pin the account's
|
||||
// immutable endpoint to a cluster that can never serve it.
|
||||
func TestCreateSettingsRejectsOfflineCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
notPrivate := false
|
||||
|
||||
cases := map[string]*bool{
|
||||
"centralised proxy gone quiet": ¬Private,
|
||||
// A cluster that could serve the gateway still has to have something
|
||||
// live in it to prove so at bootstrap: refusing is the safe direction
|
||||
// (reconnect the proxy and retry) where accepting is permanent.
|
||||
"private proxy gone quiet": ptrTo(true),
|
||||
}
|
||||
for name, private := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxyAt(t, "proxy1", "", "offline.example.com", private,
|
||||
time.Now().UTC().Add(-time.Hour))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "offline.example.com", "")
|
||||
require.Error(t, err, "a known cluster with nothing live in it must be rejected")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
||||
assert.Contains(t, err.Error(), "private capabilities",
|
||||
"the error must say private capabilities are what is missing")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSettingsRequiresPrivateCluster pins the capability gate: the
|
||||
// synthesised gateway service is always private, so a live cluster whose
|
||||
// proxies lack private capabilities cannot serve it and must not
|
||||
// become the account's immutable endpoint.
|
||||
func TestCreateSettingsRequiresPrivateCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
notPrivate := false
|
||||
f.seedProxy(t, "proxy1", "", "central.example.com", ¬Private)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "central.example.com", "")
|
||||
require.Error(t, err, "a cluster without private capabilities must be rejected")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
||||
assert.Contains(t, err.Error(), "private capabilities", "the error must name what the cluster is missing")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
}
|
||||
|
||||
// TestCreateSettingsAcceptsOwnPrivateCluster pins the BYOP happy path: the
|
||||
// account's own cluster with a connected private-capable proxy is a valid pin.
|
||||
func TestCreateSettingsAcceptsOwnPrivateCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "proxy1", "account1", "byop.account1.example.com", ptrTo(true))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "byop.account1.example.com", "")
|
||||
require.NoError(t, err, "the account's own private cluster must be accepted")
|
||||
assert.Equal(t, "byop.account1.example.com", created.ProxyAddress)
|
||||
}
|
||||
|
||||
// TestCreateSettingsMatchesClusterCasing pins that a cluster spelled with
|
||||
// capitals in the store is still recognised as the same cluster the normalised
|
||||
// proxy_address names, in both directions: a private cluster is accepted and a
|
||||
// centralised one is refused, whatever the casing. The comparison is in memory
|
||||
// over the account's cluster list; the capability lookup is still asked under
|
||||
// the spelling the store actually holds, which is what an exact match needs.
|
||||
func TestCreateSettingsMatchesClusterCasing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("own private cluster is found", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "proxy1", "", "EU.Proxy.Example.com", ptrTo(true))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "eu.proxy.example.com", "")
|
||||
require.NoError(t, err, "a private cluster declared with capitals must still be accepted")
|
||||
assert.Equal(t, "eu.proxy.example.com", created.ProxyAddress)
|
||||
})
|
||||
|
||||
t.Run("non-private cluster is still refused", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "proxy1", "", "Central.Example.com", ptrTo(false))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "central.example.com", "")
|
||||
require.Error(t, err, "casing must not become a way past the capability check")
|
||||
assert.Contains(t, err.Error(), "private capabilities")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateSettingsRejectsForeignCluster pins tenant consistency on the pin:
|
||||
// an account may not pin its gateway onto a host another account's proxy
|
||||
// declares. That proxy only ever receives its own account's mappings, so the
|
||||
// pin could never be served, and the endpoint it assigns is immutable.
|
||||
// Ownership is decided on the proxy rows, not on heartbeat freshness — a
|
||||
// cluster whose proxies are merely offline is still somebody's — and on the
|
||||
// normalised host, since proxies declare their address as the operator
|
||||
// spelled it.
|
||||
func TestCreateSettingsRejectsForeignCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cases := map[string]struct {
|
||||
spelling string
|
||||
lastSeen time.Time
|
||||
}{
|
||||
"live": {"byop.account2.example.com", time.Now().UTC()},
|
||||
"offline": {"byop.account2.example.com", time.Now().UTC().Add(-time.Hour)},
|
||||
"spelled in caps": {"BYOP.Account2.Example.com", time.Now().UTC()},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run("labeled "+name, func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxyAt(t, "proxy1", "account2", tc.spelling, ptrTo(true), tc.lastSeen)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "byop.account2.example.com", "")
|
||||
f.requireForeignClusterRefusal(t, err, "account1")
|
||||
})
|
||||
t.Run("self-addressed "+name, func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxyAt(t, "proxy1", "account2", tc.spelling, ptrTo(true), tc.lastSeen)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "", "byop.account2.example.com")
|
||||
f.requireForeignClusterRefusal(t, err, "account1")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSettingsSharedClusterStaysPinnable pins the constraint the
|
||||
// ownership check must respect: a shared (NetBird-operated) cluster is not
|
||||
// anybody's, so any number of accounts pin their gateways to it — including
|
||||
// an account that also runs a proxy of its own elsewhere.
|
||||
func TestCreateSettingsSharedClusterStaysPinnable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "shared", "", "eu.proxy.netbird.io", ptrTo(true))
|
||||
f.seedProxy(t, "own", "account1", "byop.account1.example.com", ptrTo(true))
|
||||
|
||||
for _, account := range []string{"account1", "account2"} {
|
||||
f.expectPermission(account, "user", modules.AgentNetworkSettings, operations.Create, true)
|
||||
created, err := f.createSettings(ctx, account, "user", "eu.proxy.netbird.io", "")
|
||||
require.NoError(t, err, "a shared cluster must stay pinnable by %s", account)
|
||||
assert.Equal(t, "eu.proxy.netbird.io", created.ProxyAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSettingsOwnClusterIsPinnable is the BYOP order in both directions:
|
||||
// the account's own proxy is not a competing claim, whether the pin is labeled
|
||||
// beneath its cluster or self-addressed onto the very host it declares.
|
||||
func TestCreateSettingsOwnClusterIsPinnable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("labeled", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "own", "account1", "byop.account1.example.com", ptrTo(true))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "byop.account1.example.com", "")
|
||||
require.NoError(t, err, "the account's own cluster must be pinnable")
|
||||
assert.True(t, strings.HasSuffix(created.Domain, ".byop.account1.example.com"))
|
||||
})
|
||||
t.Run("self-addressed", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "own", "account1", "gw.account1.example.com", ptrTo(true))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "", "gw.account1.example.com")
|
||||
require.NoError(t, err, "the host the account's own proxy declares must be pinnable")
|
||||
assert.Equal(t, "gw.account1.example.com", created.ProxyAddress)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateSettingsUnknownHostIsPinnable pins the address-first order: a host
|
||||
// no proxy has ever declared is nobody's, so the pin goes through and the
|
||||
// proxy is deployed after.
|
||||
func TestCreateSettingsUnknownHostIsPinnable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "future.example.com", "")
|
||||
require.NoError(t, err, "a host no proxy has declared must stay pinnable")
|
||||
assert.Equal(t, "future.example.com", created.ProxyAddress)
|
||||
}
|
||||
|
||||
// TestCreateSettingsRejectsHostAnotherAccountPinned covers claims made by pins
|
||||
// rather than proxies, which the proxy-row check cannot see. A labeled pin
|
||||
// beneath a host makes that host the other account's cluster, so a
|
||||
// self-addressed endpoint on it would never be served; a self-addressed
|
||||
// endpoint on a host makes the proxy declaring it theirs, so a label beneath
|
||||
// it would never be served either. Neither is a shared-cluster shape: many
|
||||
// labeled pins under one cluster are asked about in neither direction.
|
||||
func TestCreateSettingsRejectsHostAnotherAccountPinned(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("self-addressed onto another account's cluster", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err := f.createSettings(ctx, "account2", "user2", "gw.example.com", "")
|
||||
require.NoError(t, err, "account2's labeled pin beneath the host must go through first")
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
||||
f.requireForeignClusterRefusal(t, err, "account1")
|
||||
})
|
||||
|
||||
t.Run("labeled beneath another account's endpoint", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err := f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
|
||||
require.NoError(t, err, "account2's self-addressed endpoint must go through first")
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account1", "user1", "gw.example.com", "")
|
||||
f.requireForeignClusterRefusal(t, err, "account1")
|
||||
})
|
||||
|
||||
t.Run("labeled beside another account's labeled pin stays allowed", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
for _, account := range []string{"account1", "account2"} {
|
||||
f.expectPermission(account, "user", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err := f.createSettings(ctx, account, "user", "eu.proxy.netbird.io", "")
|
||||
require.NoError(t, err, "labeled pins under one cluster are the shared-cluster shape and must not refuse each other")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateSettingsSelfAddressedRequiresPrivateCluster pins that the
|
||||
// capability gate applies to a self-addressed endpoint too: the service behind
|
||||
// it is the same private one, so a proxy that already declares the hostname
|
||||
// must have private capabilities, whether the account's own or a shared cluster's. A
|
||||
// hostname no proxy declares yet stays claimable (TestCreateSettingsSelfAddressed).
|
||||
func TestCreateSettingsSelfAddressedRequiresPrivateCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("centralised proxy at the hostname is refused", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "central", "", "gw.example.com", ptrTo(false))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
||||
require.Error(t, err, "a self-addressed endpoint on a centralised proxy can never be served")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type())
|
||||
assert.Contains(t, err.Error(), "private capabilities")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
})
|
||||
|
||||
t.Run("private proxy at the hostname is accepted", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.seedProxy(t, "private", "", "gw.example.com", ptrTo(true))
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "gw.example.com", created.ProxyAddress)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,21 +366,24 @@ func streamInterceptor(
|
||||
return handler(srv, wrapped)
|
||||
}
|
||||
|
||||
// realIPOptions builds the real-IP middleware options from the reverse proxy config.
|
||||
// realIPOptions builds the real-IP middleware options.
|
||||
//
|
||||
// TrustedPeers controls which transport peers are allowed to supply forwarded-IP
|
||||
// headers. If empty, forwarded headers are ignored and the transport peer address
|
||||
// is used directly. Operators terminating connections at a reverse proxy should
|
||||
// configure TrustedPeers with that proxy's address or network.
|
||||
// Empty TrustedPeers trusts all IPv4 and IPv6 sources. Configure TrustedPeers
|
||||
// with the reverse proxy address or network.
|
||||
//
|
||||
// Only X-Forwarded-For is trusted. X-Real-IP contains a single client-supplied
|
||||
// address with no proxy chain to validate, and none of the reverse proxies we ship
|
||||
// use it on the gRPC path.
|
||||
// X-Forwarded-For takes precedence over X-Real-IP.
|
||||
func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option {
|
||||
if idx := slices.IndexFunc(cfg.TrustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 {
|
||||
trustedPeers := cfg.TrustedPeers
|
||||
if len(trustedPeers) == 0 {
|
||||
trustedPeers = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
}
|
||||
}
|
||||
if idx := slices.IndexFunc(trustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 {
|
||||
log.WithContext(context.Background()).Warnf("TrustedPeers contains the default route %s, which trusts "+
|
||||
"X-Forwarded-For from every client and allows connection IP spoofing. Set TrustedPeers to the address "+
|
||||
"of your reverse proxy, or leave it empty to use the connection's source address.", cfg.TrustedPeers[idx])
|
||||
"of your reverse proxy.", trustedPeers[idx])
|
||||
}
|
||||
if cfg.TrustedHTTPProxiesCount > 0 {
|
||||
log.WithContext(context.Background()).Warn(
|
||||
@@ -390,9 +393,9 @@ func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option {
|
||||
}
|
||||
|
||||
return []realip.Option{
|
||||
realip.WithTrustedPeers(cfg.TrustedPeers),
|
||||
realip.WithTrustedPeers(trustedPeers),
|
||||
realip.WithTrustedProxies(cfg.TrustedHTTPProxies),
|
||||
realip.WithTrustedProxiesCount(cfg.TrustedHTTPProxiesCount),
|
||||
realip.WithHeaders([]string{realip.XForwardedFor}),
|
||||
realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ func assertRealIP(t *testing.T, cfg nbconfig.ReverseProxy, want string, kv ...st
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealIPDefaultIgnoresClientForwardedHeaders(t *testing.T) {
|
||||
assertRealIP(t, nbconfig.ReverseProxy{}, "127.0.0.1",
|
||||
func TestRealIPDefaultTrustsForwardedHeaders(t *testing.T) {
|
||||
assertRealIP(t, nbconfig.ReverseProxy{}, "203.0.113.44",
|
||||
realip.XForwardedFor, "203.0.113.44",
|
||||
realip.XRealIp, "203.0.113.44",
|
||||
)
|
||||
@@ -161,11 +161,19 @@ func TestRealIPTrustedPeerHonoursForwardedHeaders(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestRealIPIgnoresXRealIPWhenProxyCountIsSet(t *testing.T) {
|
||||
func TestRealIPReadsXRealIPWhenProxyCountSkipsForwardedFor(t *testing.T) {
|
||||
cfg := nbconfig.ReverseProxy{
|
||||
TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
||||
TrustedHTTPProxiesCount: 1,
|
||||
}
|
||||
|
||||
assertRealIP(t, cfg, "127.0.0.1", realip.XRealIp, "203.0.113.44")
|
||||
t.Run("no X-Forwarded-For", func(t *testing.T) {
|
||||
assertRealIP(t, cfg, "203.0.113.44", realip.XRealIp, "203.0.113.44")
|
||||
})
|
||||
t.Run("single-entry X-Forwarded-For", func(t *testing.T) {
|
||||
assertRealIP(t, cfg, "198.51.100.7",
|
||||
realip.XForwardedFor, "203.0.113.44",
|
||||
realip.XRealIp, "198.51.100.7",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3517,7 +3517,13 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb
|
||||
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Everything built here watches this context; cancelling it on cleanup stops
|
||||
// the metrics flushers, caches and controllers instead of leaking them for
|
||||
// the rest of the package run.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -3536,8 +3542,6 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb
|
||||
Return(nil).
|
||||
AnyTimes()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
|
||||
// Bootstrap is an explicit settings create; providers have no settings
|
||||
// side effects anymore.
|
||||
seedPrivateProxyCluster(t, am.Store, clusterAddr)
|
||||
before, err := mgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the row")
|
||||
require.Equal(t, clusterAddr, before.ProxyAddress, "proxy address pinned at bootstrap")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
@@ -92,6 +93,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
|
||||
// UpdateAccountPeers, which is the path under test.
|
||||
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
|
||||
|
||||
seedPrivateProxyCluster(t, am.Store, clusterAddr)
|
||||
_, err = agentMgr.CreateSettings(ctx, adminUserID, agenttypes.DefaultSettings(accountID), clusterAddr, "")
|
||||
require.NoError(t, err, "CreateSettings must bootstrap the endpoint")
|
||||
// The bootstrap itself reconciles and queues updates on both channels;
|
||||
@@ -222,3 +224,22 @@ func synthZoneRData(sync *nbproto.SyncResponse, clusterAddr, fqdn string) string
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// seedPrivateProxyCluster registers a connected proxy with private capabilities in a
|
||||
// netbird client for clusterAddr, matching what a real deployment looks like
|
||||
// when the account bootstraps: the agent-network gateway service is always
|
||||
// private, so its cluster has to be one that can serve private services.
|
||||
func seedPrivateProxyCluster(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: "agent-net-proxy-" + clusterAddr,
|
||||
SessionID: "agent-net-session",
|
||||
ClusterAddress: clusterAddr,
|
||||
LastSeen: now,
|
||||
ConnectedAt: &now,
|
||||
Status: rpproxy.StatusConnected,
|
||||
Capabilities: rpproxy.Capabilities{Private: &private},
|
||||
}), "seeding the proxy cluster must succeed")
|
||||
}
|
||||
|
||||
@@ -3154,7 +3154,12 @@ func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewSqlStore(ctx, db, types.MysqlStoreEngine, metrics, skipMigration)
|
||||
store, err := NewSqlStore(ctx, db, types.MysqlStoreEngine, metrics, skipMigration)
|
||||
if err != nil {
|
||||
closeGormDB(db)
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func getGormConfig() *gorm.Config {
|
||||
@@ -3213,23 +3218,20 @@ func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, data
|
||||
|
||||
// NewPostgresqlStoreFromSqlStore restores a store from SqlStore and stores Postgres DB.
|
||||
func NewPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) {
|
||||
store, err := NewPostgresqlStoreForTests(ctx, dsn, metrics, false)
|
||||
return newPostgresqlStoreFromSqlStore(ctx, sqliteStore, dsn, metrics, false)
|
||||
}
|
||||
|
||||
func newPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
||||
store, err := NewPostgresqlStoreForTests(ctx, dsn, metrics, skipMigration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = store.SaveInstallationID(ctx, sqliteStore.GetInstallationID())
|
||||
if err != nil {
|
||||
if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil {
|
||||
closeStore(ctx, store)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, account := range sqliteStore.GetAllAccounts(ctx) {
|
||||
err := store.SaveAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = copyZonesAndRecords(sqliteStore, store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3245,11 +3247,14 @@ func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemet
|
||||
}
|
||||
pool, err := connectToPgDbForTests(context.Background(), dsn)
|
||||
if err != nil {
|
||||
closeGormDB(db)
|
||||
return nil, err
|
||||
}
|
||||
store, err := NewSqlStore(ctx, db, types.PostgresStoreEngine, metrics, skipMigration)
|
||||
if err != nil {
|
||||
// Release the sessions, or the caller cannot drop the database.
|
||||
pool.Close()
|
||||
closeGormDB(db)
|
||||
return nil, err
|
||||
}
|
||||
store.pool = pool
|
||||
@@ -3283,22 +3288,42 @@ func connectToPgDbForTests(ctx context.Context, dsn string) (*pgxpool.Pool, erro
|
||||
|
||||
// NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB.
|
||||
func NewMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) {
|
||||
store, err := NewMysqlStore(ctx, dsn, metrics, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, metrics, false)
|
||||
}
|
||||
|
||||
err = store.SaveInstallationID(ctx, sqliteStore.GetInstallationID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// seedFromSqliteStore copies the installation ID and the accounts of the
|
||||
// sqlite seed store into a freshly created engine store.
|
||||
func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error {
|
||||
if err := store.SaveInstallationID(ctx, sqliteStore.GetInstallationID()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, account := range sqliteStore.GetAllAccounts(ctx) {
|
||||
err := store.SaveAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err := store.SaveAccount(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeStore releases a store that is not handed to the caller, so a failed
|
||||
// seed does not leak its connection and pool.
|
||||
func closeStore(ctx context.Context, store *SqlStore) {
|
||||
store.Close(ctx)
|
||||
if store.pool != nil {
|
||||
store.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) {
|
||||
store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil {
|
||||
closeStore(ctx, store)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = copyZonesAndRecords(sqliteStore, store); err != nil {
|
||||
return nil, err
|
||||
@@ -6469,6 +6494,25 @@ func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddre
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// HasForeignAccountProxyAtHost reports whether a proxy owned by a different
|
||||
// account declares this host. Shared proxies (account_id IS NULL) are not
|
||||
// foreign: a shared cluster is what most accounts pin their agent network
|
||||
// gateway to. The match folds case because proxies declare their address as
|
||||
// the operator spelled it while the caller's host is normalised; that costs a
|
||||
// scan of the proxies table, taken once per account when its gateway is
|
||||
// bootstrapped, not on the per-connect path IsClusterAddressConflicting serves.
|
||||
func (s *SqlStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&proxy.Proxy{}).
|
||||
Where("LOWER(cluster_address) = LOWER(?) AND account_id IS NOT NULL AND account_id != ?", host, accountID).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, status.Errorf(status.Internal, "check proxy host ownership: %v", result.Error)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
|
||||
result := s.db.
|
||||
Where("cluster_address = ? AND account_id = ?", clusterAddress, accountID).
|
||||
|
||||
@@ -315,6 +315,36 @@ func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// HasGatewayClusterPinnedByOtherAccount reports whether another account has a
|
||||
// labeled agent network gateway pinned beneath host, making host its cluster.
|
||||
// A self-addressed endpoint on the very same hostname is not counted: that
|
||||
// collision is the domain unique index's to refuse, as a conflict. Case-folded,
|
||||
// since a settings row written before hostnames were normalised may carry
|
||||
// capitals; one row per account, so the scan is cheap.
|
||||
func (s *SqlStore) HasGatewayClusterPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
return s.countGatewayRowsByOtherAccount(ctx, "LOWER(proxy_address) = LOWER(?) AND LOWER(domain) <> LOWER(proxy_address)", host, accountID)
|
||||
}
|
||||
|
||||
// HasGatewayEndpointByOtherAccount reports whether host is another account's
|
||||
// agent network endpoint hostname (domain). Case-folded for the same reason as
|
||||
// HasGatewayClusterPinnedByOtherAccount.
|
||||
func (s *SqlStore) HasGatewayEndpointByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
return s.countGatewayRowsByOtherAccount(ctx, "LOWER(domain) = LOWER(?)", host, accountID)
|
||||
}
|
||||
|
||||
func (s *SqlStore) countGatewayRowsByOtherAccount(ctx context.Context, predicate, host, accountID string) (bool, error) {
|
||||
var count int64
|
||||
result := s.db.
|
||||
Model(&agentNetworkTypes.Settings{}).
|
||||
Where(predicate+" AND account_id != ?", host, accountID).
|
||||
Count(&count)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to check agent network gateway claims at host: %v", result.Error)
|
||||
return false, status.Errorf(status.Internal, "check agent network gateway claims at host")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkSettingsByProxyAddress returns every Settings row whose
|
||||
// gateway is served by the proxy declaring the given cluster address. Used by
|
||||
// cluster-scoped synthesis to find the accounts a shared proxy serves.
|
||||
|
||||
@@ -4,6 +4,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -340,6 +342,9 @@ type Store interface {
|
||||
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
|
||||
IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error)
|
||||
HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error)
|
||||
HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error)
|
||||
HasGatewayClusterPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error)
|
||||
HasGatewayEndpointByOtherAccount(ctx context.Context, host, accountID string) (bool, error)
|
||||
DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error
|
||||
|
||||
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
|
||||
@@ -732,6 +737,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
store.Close(ctx)
|
||||
return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err)
|
||||
}
|
||||
|
||||
@@ -758,14 +764,15 @@ func addAllGroupToAccount(ctx context.Context, store Store) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine) (Store, func(), error) {
|
||||
func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (Store, func(), error) {
|
||||
store := sqliteStore
|
||||
var cleanup func()
|
||||
var err error
|
||||
switch kind {
|
||||
case types.PostgresStoreEngine:
|
||||
store, cleanup, err = newReusedPostgresStore(ctx, store, kind)
|
||||
store, cleanup, err = newReusedPostgresStore(ctx, sqliteStore, kind)
|
||||
case types.MysqlStoreEngine:
|
||||
store, cleanup, err = newReusedMysqlStore(ctx, store, kind)
|
||||
store, cleanup, err = newReusedMysqlStore(ctx, sqliteStore, kind)
|
||||
default:
|
||||
cleanup = func() {
|
||||
// sqlite doesn't need to be cleaned up
|
||||
@@ -781,6 +788,11 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine)
|
||||
if store.pool != nil {
|
||||
store.pool.Close()
|
||||
}
|
||||
if store != sqliteStore {
|
||||
// The sqlite store only seeded the engine under test; without this
|
||||
// every test leaks its connection and the opener goroutines.
|
||||
sqliteStore.Close(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
return store, closeConnection, nil
|
||||
@@ -805,19 +817,23 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng
|
||||
return nil, nil, fmt.Errorf("failed to open postgres connection: %v", err)
|
||||
}
|
||||
|
||||
dsn, cleanup, err := createRandomDB(dsn, db, kind)
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
template, err := postgresSchemaTemplate(ctx, dsn, db)
|
||||
if err != nil {
|
||||
closeGormDB(db)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
dsn, cleanup, err := createRandomDB(dsn, db, kind, template)
|
||||
|
||||
closeGormDB(db)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err = NewPostgresqlStoreFromSqlStore(ctx, store, dsn, nil)
|
||||
store, err = newPostgresqlStoreFromSqlStore(ctx, store, dsn, nil, true)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -850,7 +866,13 @@ func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
dsn, cleanup, err := createRandomDB(dsn, db, kind)
|
||||
tableDDL, err := mysqlSchemaTemplate(ctx, dsn, db)
|
||||
if err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
dsn, cleanup, err := createRandomDB(dsn, db, kind, "")
|
||||
|
||||
sqlDB.Close()
|
||||
|
||||
@@ -858,14 +880,200 @@ func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err = NewMysqlStoreFromSqlStore(ctx, store, dsn, nil)
|
||||
if err := cloneMysqlSchema(ctx, dsn, tableDDL); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err = newMysqlStoreFromSqlStore(ctx, store, dsn, nil, true)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return store, cleanup, nil
|
||||
}
|
||||
|
||||
// schemaTemplates remembers, per engine and server, a database that went
|
||||
// through the full migration once in this process. Every later test database
|
||||
// is cloned from it, so a test pays for CREATE DATABASE and a schema copy
|
||||
// instead of the 40-table AutoMigrate plus every pre and post migration, which
|
||||
// is what made each MySQL test store cost well over a second in CI.
|
||||
var (
|
||||
schemaTemplatesMu sync.Mutex
|
||||
schemaTemplates = map[string]*schemaTemplate{}
|
||||
)
|
||||
|
||||
type schemaTemplate struct {
|
||||
dbName string
|
||||
// tableDDL holds the CREATE TABLE statements of the template. MySQL has no
|
||||
// server-side database template, so the schema is replayed statement by
|
||||
// statement into each test database.
|
||||
tableDDL []string
|
||||
}
|
||||
|
||||
func schemaTemplateKey(engine types.Engine, dsn string) string {
|
||||
return string(engine) + "|" + dsn
|
||||
}
|
||||
|
||||
func newTestDBName(prefix string) string {
|
||||
return fmt.Sprintf("%s_%s", prefix, strings.ReplaceAll(uuid.New().String(), "-", "_"))
|
||||
}
|
||||
|
||||
// postgresSchemaTemplate returns the name of a fully migrated database that
|
||||
// CREATE DATABASE ... TEMPLATE can copy, creating it on first use.
|
||||
func postgresSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) (string, error) {
|
||||
schemaTemplatesMu.Lock()
|
||||
defer schemaTemplatesMu.Unlock()
|
||||
|
||||
key := schemaTemplateKey(types.PostgresStoreEngine, baseDSN)
|
||||
if tpl, ok := schemaTemplates[key]; ok {
|
||||
return tpl.dbName, nil
|
||||
}
|
||||
|
||||
name := newTestDBName("test_template")
|
||||
if err := admin.Exec(fmt.Sprintf("CREATE DATABASE %s", name)).Error; err != nil {
|
||||
return "", fmt.Errorf("create postgres template database: %w", err)
|
||||
}
|
||||
|
||||
tplStore, err := NewPostgresqlStoreForTests(ctx, replaceDBName(baseDSN, name), nil, false)
|
||||
if err != nil {
|
||||
dropDatabase(admin, name)
|
||||
return "", fmt.Errorf("migrate postgres template database: %w", err)
|
||||
}
|
||||
// TEMPLATE refuses a source that still has sessions, so release both handles
|
||||
// before the first clone.
|
||||
tplStore.Close(ctx)
|
||||
if tplStore.pool != nil {
|
||||
tplStore.pool.Close()
|
||||
}
|
||||
|
||||
schemaTemplates[key] = &schemaTemplate{dbName: name}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// mysqlSchemaTemplate returns the CREATE TABLE statements of a fully migrated
|
||||
// database, migrating one on first use.
|
||||
func mysqlSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) ([]string, error) {
|
||||
schemaTemplatesMu.Lock()
|
||||
defer schemaTemplatesMu.Unlock()
|
||||
|
||||
key := schemaTemplateKey(types.MysqlStoreEngine, baseDSN)
|
||||
if tpl, ok := schemaTemplates[key]; ok {
|
||||
return tpl.tableDDL, nil
|
||||
}
|
||||
|
||||
name := newTestDBName("test_template")
|
||||
if err := admin.Exec(fmt.Sprintf("CREATE DATABASE %s", name)).Error; err != nil {
|
||||
return nil, fmt.Errorf("create mysql template database: %w", err)
|
||||
}
|
||||
|
||||
tplStore, err := NewMysqlStore(ctx, replaceDBName(baseDSN, name), nil, false)
|
||||
if err != nil {
|
||||
dropDatabase(admin, name)
|
||||
return nil, fmt.Errorf("migrate mysql template database: %w", err)
|
||||
}
|
||||
tableDDL, err := mysqlTableDDL(ctx, tplStore.db, name)
|
||||
tplStore.Close(ctx)
|
||||
if err != nil {
|
||||
dropDatabase(admin, name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schemaTemplates[key] = &schemaTemplate{dbName: name, tableDDL: tableDDL}
|
||||
return tableDDL, nil
|
||||
}
|
||||
|
||||
func mysqlTableDDL(ctx context.Context, db *gorm.DB, dbName string) ([]string, error) {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tables, err := mysqlTableNames(ctx, sqlDB, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableDDL := make([]string, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
var name, createStmt string
|
||||
row := sqlDB.QueryRowContext(ctx, fmt.Sprintf("SHOW CREATE TABLE %s.%s", dbName, table))
|
||||
if err := row.Scan(&name, &createStmt); err != nil {
|
||||
return nil, fmt.Errorf("read create statement of %s: %w", table, err)
|
||||
}
|
||||
tableDDL = append(tableDDL, createStmt)
|
||||
}
|
||||
return tableDDL, nil
|
||||
}
|
||||
|
||||
func mysqlTableNames(ctx context.Context, sqlDB *sql.DB, dbName string) ([]string, error) {
|
||||
rows, err := sqlDB.QueryContext(ctx, fmt.Sprintf("SHOW TABLES FROM %s", dbName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list template tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var table string
|
||||
if err := rows.Scan(&table); err != nil {
|
||||
return nil, fmt.Errorf("scan template table name: %w", err)
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list template tables: %w", err)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// cloneMysqlSchema replays the template's CREATE TABLE statements into the
|
||||
// database the DSN points at.
|
||||
func cloneMysqlSchema(ctx context.Context, dsn string, tableDDL []string) error {
|
||||
db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to test database: %w", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
// The statements come out of SHOW TABLES in name order, not dependency
|
||||
// order, and their foreign keys reference tables of the session's default
|
||||
// database. Pin a single connection so the session setting below covers
|
||||
// every statement, and connect straight to the new database so unqualified
|
||||
// references land there.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if _, err := sqlDB.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0"); err != nil {
|
||||
return fmt.Errorf("disable foreign key checks: %w", err)
|
||||
}
|
||||
for _, stmt := range tableDDL {
|
||||
if _, err := sqlDB.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("replay table definition: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropDatabase removes a template that never became usable, so a failed setup
|
||||
// does not leave it behind on a shared server. The server may still be tearing
|
||||
// down the sessions the failed migration held, so the drop retries while
|
||||
// Postgres reports the database as in use.
|
||||
func dropDatabase(admin *gorm.DB, name string) {
|
||||
if err := execWithTemplateRetry(admin, fmt.Sprintf("DROP DATABASE IF EXISTS %s", name)); err != nil {
|
||||
log.Warnf("failed to drop template database %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeGormDB(db *gorm.DB) {
|
||||
if sqlDB, _ := db.DB(); sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, error) {
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
@@ -891,10 +1099,16 @@ func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func createRandomDB(dsn string, db *gorm.DB, engine types.Engine) (string, func(), error) {
|
||||
dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_"))
|
||||
// createRandomDB creates a uniquely named database for one test. On postgres a
|
||||
// non-empty template is copied server-side with CREATE DATABASE ... TEMPLATE.
|
||||
func createRandomDB(dsn string, db *gorm.DB, engine types.Engine, template string) (string, func(), error) {
|
||||
dbName := newTestDBName("test_db")
|
||||
|
||||
if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil {
|
||||
createStmt := fmt.Sprintf("CREATE DATABASE %s", dbName)
|
||||
if template != "" && engine == types.PostgresStoreEngine {
|
||||
createStmt = fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", dbName, template)
|
||||
}
|
||||
if err := execWithTemplateRetry(db, createStmt); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
@@ -960,6 +1174,20 @@ func createRandomDB(dsn string, db *gorm.DB, engine types.Engine) (string, func(
|
||||
return replaceDBName(dsn, dbName), cleanup, nil
|
||||
}
|
||||
|
||||
// execWithTemplateRetry runs a statement, retrying briefly when postgres still
|
||||
// sees the template's just-closed sessions and refuses to copy it.
|
||||
func execWithTemplateRetry(db *gorm.DB, stmt string) error {
|
||||
var err error
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
err = db.Exec(stmt).Error
|
||||
if err == nil || !strings.Contains(err.Error(), "is being accessed by other users") {
|
||||
return err
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func replaceDBName(dsn, newDBName string) string {
|
||||
re := regexp.MustCompile(`(?P<pre>[:/@])(?P<dbname>[^/?]+)(?P<post>\?|$)`)
|
||||
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
|
||||
|
||||
@@ -3065,6 +3065,51 @@ func (mr *MockStoreMockRecorder) HasActiveProxyAtClusterAddress(ctx, clusterAddr
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasActiveProxyAtClusterAddress", reflect.TypeOf((*MockStore)(nil).HasActiveProxyAtClusterAddress), ctx, clusterAddress)
|
||||
}
|
||||
|
||||
// HasForeignAccountProxyAtHost mocks base method.
|
||||
func (m *MockStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasForeignAccountProxyAtHost", ctx, host, accountID)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasForeignAccountProxyAtHost indicates an expected call of HasForeignAccountProxyAtHost.
|
||||
func (mr *MockStoreMockRecorder) HasForeignAccountProxyAtHost(ctx, host, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasForeignAccountProxyAtHost", reflect.TypeOf((*MockStore)(nil).HasForeignAccountProxyAtHost), ctx, host, accountID)
|
||||
}
|
||||
|
||||
// HasGatewayClusterPinnedByOtherAccount mocks base method.
|
||||
func (m *MockStore) HasGatewayClusterPinnedByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasGatewayClusterPinnedByOtherAccount", ctx, host, accountID)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasGatewayClusterPinnedByOtherAccount indicates an expected call of HasGatewayClusterPinnedByOtherAccount.
|
||||
func (mr *MockStoreMockRecorder) HasGatewayClusterPinnedByOtherAccount(ctx, host, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGatewayClusterPinnedByOtherAccount", reflect.TypeOf((*MockStore)(nil).HasGatewayClusterPinnedByOtherAccount), ctx, host, accountID)
|
||||
}
|
||||
|
||||
// HasGatewayEndpointByOtherAccount mocks base method.
|
||||
func (m *MockStore) HasGatewayEndpointByOtherAccount(ctx context.Context, host, accountID string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasGatewayEndpointByOtherAccount", ctx, host, accountID)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasGatewayEndpointByOtherAccount indicates an expected call of HasGatewayEndpointByOtherAccount.
|
||||
func (mr *MockStoreMockRecorder) HasGatewayEndpointByOtherAccount(ctx, host, accountID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGatewayEndpointByOtherAccount", reflect.TypeOf((*MockStore)(nil).HasGatewayEndpointByOtherAccount), ctx, host, accountID)
|
||||
}
|
||||
|
||||
// IncrementAgentNetworkConsumption mocks base method.
|
||||
func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -37,6 +37,18 @@ func CreateMysqlTestContainer() (func(), string, error) {
|
||||
mysql.WithDatabase("testing"),
|
||||
mysql.WithUsername("root"),
|
||||
mysql.WithPassword("testing"),
|
||||
// Every test creates and drops a database with about 40 tables, so with
|
||||
// the server defaults the run is dominated by durability work: each
|
||||
// CREATE TABLE fsyncs the redo log, the binary log and the doublewrite
|
||||
// buffer. None of it protects anything in a container that is discarded
|
||||
// after the run. Tables stay in per-table files on purpose: in the shared
|
||||
// system tablespace the cost of every CREATE and DROP grew with the number
|
||||
// of databases the run had already created.
|
||||
testcontainers.WithCmd("mysqld",
|
||||
"--innodb-flush-log-at-trx-commit=0",
|
||||
"--innodb-doublewrite=OFF",
|
||||
"--skip-log-bin",
|
||||
),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("/usr/sbin/mysqld: ready for connections").
|
||||
WithOccurrence(1).WithStartupTimeout(15*time.Second).WithPollInterval(100*time.Millisecond),
|
||||
|
||||
@@ -285,6 +285,25 @@ func (u *User) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaskEmail(email string) string {
|
||||
local, domain, found := strings.Cut(email, "@")
|
||||
if !found || local == "" || domain == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Runes, not bytes, so a non-ASCII local part is not cut mid-character.
|
||||
runes := []rune(local)
|
||||
|
||||
// Keeping the first two and the last needs a local part of at least four to
|
||||
// hide anything at all: at three or fewer those are the whole of it, and the
|
||||
// address would be recoverable in full from what is meant to conceal it.
|
||||
if len(runes) < 4 {
|
||||
return "****@" + domain
|
||||
}
|
||||
|
||||
return string(runes[:2]) + "****" + string(runes[len(runes)-1]) + "@" + domain
|
||||
}
|
||||
|
||||
// DecryptSensitiveData decrypts the user's sensitive fields (Email and Name) in place.
|
||||
func (u *User) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
|
||||
@@ -296,3 +296,144 @@ func TestUser_EncryptDecryptRoundTrip(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskEmail(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
email string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "ordinary address keeps the first two, the last, and the domain",
|
||||
email: "admin@example.com",
|
||||
expected: "ad****n@example.com",
|
||||
},
|
||||
{
|
||||
name: "four characters is the shortest local part that reveals anything",
|
||||
email: "abcd@example.com",
|
||||
expected: "ab****d@example.com",
|
||||
},
|
||||
{
|
||||
name: "three character local part is masked whole, since a lead and tail would be all of it",
|
||||
email: "abc@example.com",
|
||||
expected: "****@example.com",
|
||||
},
|
||||
{
|
||||
name: "two character local part is masked whole",
|
||||
email: "ab@example.com",
|
||||
expected: "****@example.com",
|
||||
},
|
||||
{
|
||||
name: "single character local part is masked whole",
|
||||
email: "a@b.co",
|
||||
expected: "****@b.co",
|
||||
},
|
||||
{
|
||||
name: "mask width does not report the length it stands in for",
|
||||
email: "a.very.long.local.part@example.com",
|
||||
expected: "a.****t@example.com",
|
||||
},
|
||||
{
|
||||
name: "a local part far longer than the mask is still reduced to three characters",
|
||||
email: "finance.department.notifications.owner.account@example.com",
|
||||
expected: "fi****t@example.com",
|
||||
},
|
||||
{
|
||||
name: "plus addressing is masked along with the rest of the local part",
|
||||
email: "admin+netbird@example.com",
|
||||
expected: "ad****d@example.com",
|
||||
},
|
||||
{
|
||||
name: "separators inside the local part are not treated specially",
|
||||
email: "first.last-name_x@example.com",
|
||||
expected: "fi****x@example.com",
|
||||
},
|
||||
{
|
||||
name: "case is preserved rather than normalised",
|
||||
email: "Admin@Example.COM",
|
||||
expected: "Ad****n@Example.COM",
|
||||
},
|
||||
{
|
||||
name: "subdomains stay intact",
|
||||
email: "owner@mail.corp.example.com",
|
||||
expected: "ow****r@mail.corp.example.com",
|
||||
},
|
||||
{
|
||||
name: "german umlauts count as single characters",
|
||||
email: "müller@example.de",
|
||||
expected: "mü****r@example.de",
|
||||
},
|
||||
{
|
||||
name: "cyrillic local part is cut on runes",
|
||||
email: "иванов@example.ru",
|
||||
expected: "ив****в@example.ru",
|
||||
},
|
||||
{
|
||||
name: "cjk local part of three runes is masked whole, counted in runes not bytes",
|
||||
email: "用户名@example.cn",
|
||||
expected: "****@example.cn",
|
||||
},
|
||||
{
|
||||
name: "cjk local part of four runes reveals the first two and the last",
|
||||
email: "用户名字@example.cn",
|
||||
expected: "用户****字@example.cn",
|
||||
},
|
||||
{
|
||||
name: "arabic local part is cut on runes",
|
||||
email: "مستخدم@example.sa",
|
||||
expected: "مس****م@example.sa",
|
||||
},
|
||||
{
|
||||
name: "two rune non-ascii local part is masked whole",
|
||||
email: "ää@example.de",
|
||||
expected: "****@example.de",
|
||||
},
|
||||
{
|
||||
name: "astral plane runes are not split into surrogates",
|
||||
email: "a🎉bc@example.com",
|
||||
expected: "a🎉****c@example.com",
|
||||
},
|
||||
{
|
||||
name: "a non-ascii domain is left alone",
|
||||
email: "admin@münchen.example",
|
||||
expected: "ad****n@münchen.example",
|
||||
},
|
||||
{
|
||||
name: "only the first separator splits, so a second stays in the domain",
|
||||
email: "a@b@example.com",
|
||||
expected: "****@b@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty email has nothing to mask",
|
||||
email: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "value without a separator is not an address",
|
||||
email: "not-an-email",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "missing local part is not an address",
|
||||
email: "@example.com",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "missing domain is not an address",
|
||||
email: "admin@",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "a bare separator is not an address",
|
||||
email: "@",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, MaskEmail(tc.email))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1373,6 +1373,25 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
|
||||
return updateAccountPeers, nil
|
||||
}
|
||||
|
||||
// pendingApprovalError refuses a user awaiting approval, naming the owner who
|
||||
// can approve them when their address resolves. Failing to resolve one is not a
|
||||
// reason to withhold the refusal, so the lookup is best effort.
|
||||
func (am *DefaultAccountManager) pendingApprovalError(ctx context.Context, accountID string) error {
|
||||
owner, err := am.GetOwnerInfo(ctx, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("pending approval refusal: owner of account %s did not resolve: %v", accountID, err)
|
||||
return status.NewUserPendingApprovalError()
|
||||
}
|
||||
|
||||
masked := types.MaskEmail(owner.Email)
|
||||
if masked == "" {
|
||||
log.WithContext(ctx).Debugf("pending approval refusal: no address found for the owner of account %s", accountID)
|
||||
return status.NewUserPendingApprovalError()
|
||||
}
|
||||
|
||||
return status.NewUserPendingApprovalByOwnerError(masked)
|
||||
}
|
||||
|
||||
// GetOwnerInfo retrieves the owner information for a given account ID.
|
||||
func (am *DefaultAccountManager) GetOwnerInfo(ctx context.Context, accountID string) (*types.UserInfo, error) {
|
||||
owner, err := am.Store.GetAccountOwner(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -1430,6 +1449,14 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A user pending approval is blocked too, and the dashboard needs to tell
|
||||
// the two apart: one is a dead end, the other resolves by itself once the
|
||||
// owner acts. Naming that owner needs the address the IdP holds, which is
|
||||
// why this is answered here rather than in the permission gate.
|
||||
if user.IsBlocked() && user.PendingApproval {
|
||||
return nil, am.pendingApprovalError(ctx, user.AccountID)
|
||||
}
|
||||
|
||||
if user.IsBlocked() {
|
||||
return nil, status.NewUserBlockedError()
|
||||
}
|
||||
|
||||
@@ -1826,6 +1826,42 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account2))
|
||||
|
||||
account3 := newAccountWithId(context.Background(), "account3", "account3Owner", "", "owner@example.com", "", false)
|
||||
account3.Users["pending-user"] = &types.User{
|
||||
Id: "pending-user",
|
||||
AccountID: account3.Id,
|
||||
Role: types.UserRoleUser,
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account3))
|
||||
|
||||
// The owner has no address to name, so the refusal falls back to the generic one.
|
||||
account4 := newAccountWithId(context.Background(), "account4", "account4Owner", "", "", "", false)
|
||||
account4.Users["pending-user-without-owner-email"] = &types.User{
|
||||
Id: "pending-user-without-owner-email",
|
||||
AccountID: account4.Id,
|
||||
Role: types.UserRoleUser,
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account4))
|
||||
|
||||
// No user holds the owner role, so the owner lookup itself fails.
|
||||
account5 := newAccountWithId(context.Background(), "account5", "account5Admin", "", "", "", false)
|
||||
account5.Users["account5Admin"].Role = types.UserRoleAdmin
|
||||
account5.Users["pending-user-without-owner"] = &types.User{
|
||||
Id: "pending-user-without-owner",
|
||||
AccountID: account5.Id,
|
||||
Role: types.UserRoleUser,
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
}
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account5))
|
||||
|
||||
account6 := newAccountWithId(context.Background(), "account6", "account6Owner", "", "stranger@example.com", "", false)
|
||||
require.NoError(t, store.SaveAccount(context.Background(), account6))
|
||||
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
am := DefaultAccountManager{
|
||||
Store: store,
|
||||
@@ -1854,6 +1890,34 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) {
|
||||
userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "service-user"},
|
||||
expectedErr: status.NewPermissionDeniedError(),
|
||||
},
|
||||
{
|
||||
name: "pending approval names the owner",
|
||||
userAuth: auth.UserAuth{AccountId: account3.Id, UserId: "pending-user"},
|
||||
expectedErr: status.NewUserPendingApprovalByOwnerError("ow****r@example.com"),
|
||||
},
|
||||
{
|
||||
name: "pending approval without an owner address",
|
||||
userAuth: auth.UserAuth{AccountId: account4.Id, UserId: "pending-user-without-owner-email"},
|
||||
expectedErr: status.NewUserPendingApprovalError(),
|
||||
},
|
||||
{
|
||||
name: "pending approval without an owner",
|
||||
userAuth: auth.UserAuth{AccountId: account5.Id, UserId: "pending-user-without-owner"},
|
||||
expectedErr: status.NewUserPendingApprovalError(),
|
||||
},
|
||||
{
|
||||
// The account claim points at an account the caller is not in. The
|
||||
// owner named has to be the one of the account holding the caller's
|
||||
// own record, never the one the claim asks for.
|
||||
name: "pending approval ignores a mismatched account claim",
|
||||
userAuth: auth.UserAuth{AccountId: account6.Id, UserId: "pending-user"},
|
||||
expectedErr: status.NewUserPendingApprovalByOwnerError("ow****r@example.com"),
|
||||
},
|
||||
{
|
||||
name: "blocked user answers before the account claim is validated",
|
||||
userAuth: auth.UserAuth{AccountId: account6.Id, UserId: "blocked-user"},
|
||||
expectedErr: status.NewUserBlockedError(),
|
||||
},
|
||||
{
|
||||
name: "owner user",
|
||||
userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "account1Owner"},
|
||||
|
||||
@@ -133,7 +133,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
denyPrivate(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
|
||||
clientIP := mw.resolveClientIP(r)
|
||||
if !clientIP.IsValid() {
|
||||
mw.logger.Debugf("IP restriction: cannot resolve client address for %q, denying", r.RemoteAddr)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
denyForbidden(w, config)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -263,10 +263,30 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
|
||||
|
||||
reason := verdict.String()
|
||||
mw.blockIPRestriction(r, reason)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
denyForbidden(w, config)
|
||||
return false
|
||||
}
|
||||
|
||||
// denyForbidden writes a 403, dropping the client connection when the
|
||||
// domain is private so a later retry cannot reuse it.
|
||||
func denyForbidden(w http.ResponseWriter, config DomainConfig) {
|
||||
if config.Private {
|
||||
denyPrivate(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// denyPrivate writes a 403 and closes the connection, so a client refused
|
||||
// before joining the overlay cannot keep retrying on the same warm socket.
|
||||
// Go's HTTP/2 server turns the exact lowercase "close" token into a GOAWAY.
|
||||
func denyPrivate(w http.ResponseWriter) {
|
||||
h := w.Header()
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
|
||||
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
|
||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httptrace"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// switchableTunnelValidator flips the ValidateTunnelPeer verdict between requests.
|
||||
type switchableTunnelValidator struct {
|
||||
mu sync.Mutex
|
||||
valid bool
|
||||
}
|
||||
|
||||
func (s *switchableTunnelValidator) setValid(v bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.valid = v
|
||||
}
|
||||
|
||||
func (s *switchableTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
|
||||
return nil, errors.New("not used in this test")
|
||||
}
|
||||
|
||||
func (s *switchableTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.valid {
|
||||
return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil
|
||||
}
|
||||
return &proto.ValidateTunnelPeerResponse{
|
||||
Valid: true,
|
||||
UserId: "user-1",
|
||||
SessionToken: "tunnel-session-token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testServerHost is the domain key Protect derives from the httptest listener.
|
||||
const testServerHost = "127.0.0.1"
|
||||
|
||||
var testTunnelIP = netip.MustParseAddr("100.90.1.14")
|
||||
|
||||
// startProtectedServer serves mw.Protect and stamps requests as overlay traffic.
|
||||
func startProtectedServer(t *testing.T, mw *Middleware, clientIP netip.Addr, lookup TunnelLookupFunc, h2 bool) *httptest.Server {
|
||||
t.Helper()
|
||||
protected := mw.Protect(newPassthroughHandler())
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(clientIP)
|
||||
ctx := proxy.WithCapturedData(r.Context(), cd)
|
||||
ctx = WithTunnelLookup(ctx, lookup)
|
||||
protected.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
if h2 {
|
||||
srv.EnableHTTP2 = true
|
||||
srv.StartTLS()
|
||||
} else {
|
||||
srv.Start()
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// tracedResponse is what a test observes from one client round trip.
|
||||
type tracedResponse struct {
|
||||
status int
|
||||
protoMajor int
|
||||
close bool
|
||||
connection string
|
||||
cacheControl string
|
||||
reused bool
|
||||
}
|
||||
|
||||
// doTraced GETs url and reports whether the connection that served it was reused.
|
||||
func doTraced(t *testing.T, client *http.Client, url string) tracedResponse {
|
||||
t.Helper()
|
||||
var reused bool
|
||||
trace := &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
|
||||
}
|
||||
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, resp.Body.Close()) }()
|
||||
_, err = io.Copy(io.Discard, resp.Body)
|
||||
require.NoError(t, err)
|
||||
return tracedResponse{
|
||||
status: resp.StatusCode,
|
||||
protoMajor: resp.ProtoMajor,
|
||||
close: resp.Close,
|
||||
connection: resp.Header.Get("Connection"),
|
||||
cacheControl: resp.Header.Get("Cache-Control"),
|
||||
reused: reused,
|
||||
}
|
||||
}
|
||||
|
||||
func acceptAllLookup(_ netip.Addr) (PeerIdentity, bool) {
|
||||
return PeerIdentity{TunnelIP: testTunnelIP}, true
|
||||
}
|
||||
|
||||
func newPrivateMiddleware(t *testing.T, validator SessionValidator, ipRestrictions *restrict.Filter) *Middleware {
|
||||
t.Helper()
|
||||
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
require.NoError(t, mw.AddDomain(testServerHost, nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", ipRestrictions, true, nil))
|
||||
return mw
|
||||
}
|
||||
|
||||
// A rejected tunnel peer must emit the exact lowercase "close" token h2 matches on.
|
||||
func TestProtect_PrivateService_DeniedSetsCloseHeaders(t *testing.T) {
|
||||
mw := newPrivateMiddleware(t, &switchableTunnelValidator{}, nil)
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(testTunnelIP)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
||||
req.RemoteAddr = testTunnelIP.String() + ":5000"
|
||||
req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), acceptAllLookup))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Equal(t, "close", rec.Header().Get("Connection"), "private denial must ask the client to drop the connection")
|
||||
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "private denial must not be cacheable")
|
||||
}
|
||||
|
||||
// A denied client must not keep reusing the warm socket after joining the overlay.
|
||||
func TestPrivateDeny_HTTP1_ClosesConnection(t *testing.T) {
|
||||
validator := &switchableTunnelValidator{}
|
||||
mw := newPrivateMiddleware(t, validator, nil)
|
||||
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, false)
|
||||
client := srv.Client()
|
||||
|
||||
resp := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusForbidden, resp.status)
|
||||
assert.Equal(t, 1, resp.protoMajor, "plain httptest server must speak HTTP/1.1")
|
||||
// The Go client folds "Connection: close" into resp.close and drops the header.
|
||||
assert.True(t, resp.close, "private denial must make the client mark the connection as not reusable")
|
||||
assert.Equal(t, "no-store", resp.cacheControl, "private denial must not be cacheable")
|
||||
|
||||
validator.setValid(true)
|
||||
resp2 := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusOK, resp2.status, "the retry must reach the upstream once the peer is valid")
|
||||
assert.False(t, resp2.reused, "the retry must open a new connection")
|
||||
}
|
||||
|
||||
// On HTTP/2 the header becomes a GOAWAY and the retry must use a new connection.
|
||||
func TestPrivateDeny_HTTP2_SendsGoAway(t *testing.T) {
|
||||
validator := &switchableTunnelValidator{}
|
||||
mw := newPrivateMiddleware(t, validator, nil)
|
||||
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, true)
|
||||
client := srv.Client()
|
||||
|
||||
resp := doTraced(t, client, srv.URL)
|
||||
require.Equal(t, 2, resp.protoMajor, "test client must negotiate HTTP/2")
|
||||
assert.Equal(t, http.StatusForbidden, resp.status)
|
||||
assert.Empty(t, resp.connection, "HTTP/2 must not carry a Connection header on the wire")
|
||||
assert.Equal(t, "no-store", resp.cacheControl)
|
||||
|
||||
validator.setValid(true)
|
||||
resp2 := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, 2, resp2.protoMajor)
|
||||
assert.Equal(t, http.StatusOK, resp2.status, "the retry must reach the upstream once the peer is valid")
|
||||
assert.False(t, resp2.reused, "GOAWAY must retire the connection so the retry opens a new one")
|
||||
}
|
||||
|
||||
// Legitimate private traffic keeps its keep-alive connection.
|
||||
func TestPrivateAllow_KeepsConnection(t *testing.T) {
|
||||
validator := &switchableTunnelValidator{valid: true}
|
||||
mw := newPrivateMiddleware(t, validator, nil)
|
||||
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, false)
|
||||
client := srv.Client()
|
||||
|
||||
resp := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusOK, resp.status)
|
||||
assert.Empty(t, resp.connection, "an allowed private request must not close the connection")
|
||||
|
||||
resp2 := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusOK, resp2.status)
|
||||
assert.True(t, resp2.reused, "allowed private traffic must keep reusing the connection")
|
||||
}
|
||||
|
||||
// Public denials keep the connection open; only private services change.
|
||||
func TestPublicDeny_KeepsConnection(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
|
||||
require.NoError(t, mw.AddDomain(testServerHost, nil, "", 0, "acct-1", "svc-1", filter, false, nil))
|
||||
srv := startProtectedServer(t, mw, netip.MustParseAddr("192.168.1.1"), nil, false)
|
||||
client := srv.Client()
|
||||
|
||||
resp := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusForbidden, resp.status)
|
||||
assert.Empty(t, resp.connection, "public denial must not close the connection")
|
||||
assert.Empty(t, resp.cacheControl, "public denial must not gain cache headers")
|
||||
|
||||
resp2 := doTraced(t, client, srv.URL)
|
||||
assert.Equal(t, http.StatusForbidden, resp2.status)
|
||||
assert.True(t, resp2.reused, "public denials must keep reusing the connection")
|
||||
}
|
||||
|
||||
// IP restriction denials on a private service must close the connection too.
|
||||
func TestCheckIPRestrictions_PrivateDenialClosesConnection(t *testing.T) {
|
||||
filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})
|
||||
mw := newPrivateMiddleware(t, &switchableTunnelValidator{valid: true}, filter)
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
}{
|
||||
{"denied by CIDR", "100.65.5.6:5000"},
|
||||
{"unresolvable client address", "not-an-ip:1234"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Equal(t, "close", rec.Header().Get("Connection"), "private IP-restriction denial must close the connection")
|
||||
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "private IP-restriction denial must not be cacheable")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIPRestrictions_PublicDenialKeepsConnection(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
|
||||
require.NoError(t, mw.AddDomain(testServerHost, nil, "", 0, "acct-1", "svc-1", filter, false, nil))
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
}{
|
||||
{"denied by CIDR", "192.168.1.1:5000"},
|
||||
{"unresolvable client address", "not-an-ip:1234"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("Connection"), "public IP-restriction denial must not close the connection")
|
||||
assert.Empty(t, rec.Header().Get("Cache-Control"), "public IP-restriction denial must not gain cache headers")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
// branch at all), construct the MultiTransport via NewDirectOnly.
|
||||
type MultiTransport struct {
|
||||
embedded http.RoundTripper
|
||||
direct *http.Transport
|
||||
insecure *http.Transport
|
||||
direct *upstreamTransport
|
||||
insecure *upstreamTransport
|
||||
}
|
||||
|
||||
// errNoEmbeddedTransport is returned when a request reaches the
|
||||
@@ -53,7 +53,6 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra
|
||||
}
|
||||
direct := &http.Transport{
|
||||
DialContext: dialWithTimeout(dialer.DialContext),
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: cfg.maxIdleConns,
|
||||
MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: cfg.maxConnsPerHost,
|
||||
@@ -70,8 +69,8 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra
|
||||
|
||||
return &MultiTransport{
|
||||
embedded: embedded,
|
||||
direct: direct,
|
||||
insecure: insecure,
|
||||
direct: newUpstreamTransport(direct, cfg.upstreamHTTPVersion, logger),
|
||||
insecure: newUpstreamTransport(insecure, cfg.upstreamHTTPVersion, logger),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -75,16 +76,71 @@ func TestMultiTransport_AppliesEnvOverridesToDirect(t *testing.T) {
|
||||
|
||||
mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil)
|
||||
|
||||
assert.Equal(t, 42, mt.direct.MaxIdleConns,
|
||||
assert.Equal(t, 42, mt.direct.primary.MaxIdleConns,
|
||||
"NB_PROXY_MAX_IDLE_CONNS must propagate to the direct transport")
|
||||
assert.Equal(t, 11*time.Second, mt.direct.IdleConnTimeout,
|
||||
assert.Equal(t, 11*time.Second, mt.direct.primary.IdleConnTimeout,
|
||||
"NB_PROXY_IDLE_CONN_TIMEOUT must propagate to the direct transport")
|
||||
assert.Equal(t, 7*time.Second, mt.direct.TLSHandshakeTimeout,
|
||||
assert.Equal(t, 7*time.Second, mt.direct.primary.TLSHandshakeTimeout,
|
||||
"NB_PROXY_TLS_HANDSHAKE_TIMEOUT must propagate to the direct transport")
|
||||
assert.Equal(t, 42, mt.insecure.MaxIdleConns,
|
||||
assert.Equal(t, 42, mt.insecure.primary.MaxIdleConns,
|
||||
"env tuning must also apply to the insecure-skip-verify direct transport")
|
||||
}
|
||||
|
||||
// TestMultiTransport_UpstreamHTTPVersion pins the protocol actually
|
||||
// negotiated with an HTTPS upstream that offers both h2 and http/1.1.
|
||||
// The request rides the insecure clone, so this also covers the version
|
||||
// surviving http.Transport.Clone.
|
||||
func TestMultiTransport_UpstreamHTTPVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
wantProto string
|
||||
}{
|
||||
{name: "unset negotiates h2", env: "", wantProto: "HTTP/2.0"},
|
||||
{name: "auto negotiates h2", env: "auto", wantProto: "HTTP/2.0"},
|
||||
{name: "1.1 pins http/1.1", env: "1.1", wantProto: "HTTP/1.1"},
|
||||
{name: "2 negotiates h2", env: "2", wantProto: "HTTP/2.0"},
|
||||
{name: "unsupported value keeps the default", env: "http3", wantProto: "HTTP/2.0"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// t.Setenv registers the restore for whatever the process
|
||||
// inherited; unsetting afterwards lets the default row
|
||||
// exercise a genuinely absent variable.
|
||||
t.Setenv(EnvUpstreamHTTPVersion, tc.env)
|
||||
if tc.env == "" {
|
||||
require.NoError(t, os.Unsetenv(EnvUpstreamHTTPVersion))
|
||||
}
|
||||
|
||||
// The test server's certificate isn't in any root pool, so the
|
||||
// request rides the insecure branch via WithSkipTLSVerify.
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, r.Proto)
|
||||
}))
|
||||
srv.EnableHTTP2 = true
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
mt := NewDirectOnly(nil)
|
||||
ctx := WithSkipTLSVerify(WithDirectUpstream(context.Background()))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := mt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.wantProto, resp.Proto,
|
||||
"client-side protocol must follow %s=%q", EnvUpstreamHTTPVersion, tc.env)
|
||||
assert.Equal(t, tc.wantProto, string(body),
|
||||
"the upstream must see the same protocol the client negotiated")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested guards
|
||||
// against the previous silent fallback: a MultiTransport constructed
|
||||
// without an embedded transport must reject requests that don't
|
||||
|
||||
@@ -82,10 +82,10 @@ type serviceNotification struct {
|
||||
// clientEntry holds an embedded NetBird client and tracks which services use it.
|
||||
type clientEntry struct {
|
||||
client *embed.Client
|
||||
transport *http.Transport
|
||||
transport *upstreamTransport
|
||||
// insecureTransport is a clone of transport with TLS verification disabled,
|
||||
// used when per-target skip_tls_verify is set.
|
||||
insecureTransport *http.Transport
|
||||
insecureTransport *upstreamTransport
|
||||
services map[ServiceKey]serviceInfo
|
||||
createdAt time.Time
|
||||
started bool
|
||||
@@ -414,7 +414,6 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
||||
// not work with reverse proxied requests.
|
||||
transport := &http.Transport{
|
||||
DialContext: dialWithTimeout(client.DialContext),
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: n.transportCfg.maxIdleConns,
|
||||
MaxIdleConnsPerHost: n.transportCfg.maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: n.transportCfg.maxConnsPerHost,
|
||||
@@ -426,15 +425,14 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
||||
ReadBufferSize: n.transportCfg.readBufferSize,
|
||||
DisableCompression: n.transportCfg.disableCompression,
|
||||
}
|
||||
|
||||
insecureTransport := transport.Clone()
|
||||
insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||
|
||||
return &clientEntry{
|
||||
client: client,
|
||||
services: map[ServiceKey]serviceInfo{key: si},
|
||||
transport: transport,
|
||||
insecureTransport: insecureTransport,
|
||||
transport: newUpstreamTransport(transport, n.transportCfg.upstreamHTTPVersion, n.logger),
|
||||
insecureTransport: newUpstreamTransport(insecureTransport, n.transportCfg.upstreamHTTPVersion, n.logger),
|
||||
createdAt: time.Now(),
|
||||
started: false,
|
||||
inflightMap: make(map[backendKey]chan struct{}),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package roundtrip
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -21,6 +24,30 @@ const (
|
||||
EnvReadBufferSize = "NB_PROXY_READ_BUFFER_SIZE"
|
||||
EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION"
|
||||
EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT"
|
||||
EnvUpstreamHTTPVersion = "NB_PROXY_UPSTREAM_HTTP_VERSION"
|
||||
)
|
||||
|
||||
// upstreamHTTPVersion selects the HTTP version the proxy uses towards an
|
||||
// upstream. The explicit values are absolute: they mean the same thing
|
||||
// however the transports are dialled and whatever the default becomes,
|
||||
// so operator configuration survives a change of default.
|
||||
type upstreamHTTPVersion string
|
||||
|
||||
const (
|
||||
// upstreamHTTPAuto leaves the choice to the upstream: h2 is offered
|
||||
// alongside http/1.1 in the TLS handshake and the upstream picks.
|
||||
// An upstream that picks h2 and then fails to serve it is moved to
|
||||
// HTTP/1.1 on its own (see upstreamTransport), which is the part
|
||||
// ALPN cannot express. This is the only value whose meaning tracks
|
||||
// the proxy's default.
|
||||
upstreamHTTPAuto upstreamHTTPVersion = "auto"
|
||||
// upstreamHTTP11 never offers h2, so the upstream sees HTTP/1.1.
|
||||
upstreamHTTP11 upstreamHTTPVersion = "1.1"
|
||||
// upstreamHTTP2 offers h2 in the TLS handshake and keeps it there:
|
||||
// an upstream that negotiates h2 and then breaks is never moved to
|
||||
// HTTP/1.1. Cleartext upstreams stay on HTTP/1.1 regardless: the
|
||||
// proxy speaks no h2c.
|
||||
upstreamHTTP2 upstreamHTTPVersion = "2"
|
||||
)
|
||||
|
||||
// transportConfig holds tunable parameters for the per-account HTTP transport.
|
||||
@@ -37,6 +64,11 @@ type transportConfig struct {
|
||||
disableCompression bool
|
||||
// maxInflight limits per-backend concurrent requests. 0 means unlimited.
|
||||
maxInflight int
|
||||
// upstreamHTTPVersion selects the HTTP version used towards HTTPS
|
||||
// upstreams. The default negotiates it with each upstream; the
|
||||
// explicit values are for backends whose advertised h2 support is
|
||||
// unusable and whose failure mode the negotiation cannot see.
|
||||
upstreamHTTPVersion upstreamHTTPVersion
|
||||
}
|
||||
|
||||
func defaultTransportConfig() transportConfig {
|
||||
@@ -47,6 +79,7 @@ func defaultTransportConfig() transportConfig {
|
||||
idleConnTimeout: 90 * time.Second,
|
||||
tlsHandshakeTimeout: 10 * time.Second,
|
||||
expectContinueTimeout: 1 * time.Second,
|
||||
upstreamHTTPVersion: upstreamHTTPAuto,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +119,9 @@ func loadTransportConfig(logger *log.Logger) transportConfig {
|
||||
if v, ok := envInt(EnvMaxInflight, logger); ok {
|
||||
cfg.maxInflight = v
|
||||
}
|
||||
if v, ok := envUpstreamHTTPVersion(EnvUpstreamHTTPVersion, logger); ok {
|
||||
cfg.upstreamHTTPVersion = v
|
||||
}
|
||||
|
||||
logger.WithFields(log.Fields{
|
||||
"max_idle_conns": cfg.maxIdleConns,
|
||||
@@ -99,11 +135,83 @@ func loadTransportConfig(logger *log.Logger) transportConfig {
|
||||
"read_buffer_size": cfg.readBufferSize,
|
||||
"disable_compression": cfg.disableCompression,
|
||||
"max_inflight": cfg.maxInflight,
|
||||
"upstream_http_version": cfg.upstreamHTTPVersion,
|
||||
}).Debug("backend transport configuration")
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// applyUpstreamHTTPVersion configures t's ALPN offer for the requested
|
||||
// HTTP version. It is the single place that decides which protocols a
|
||||
// transport offers, so changing the proxy's default only touches this
|
||||
// function and leaves every explicit operator setting intact. What
|
||||
// happens when a negotiated h2 upstream then fails belongs to
|
||||
// upstreamTransport, which owns the runtime half of "auto".
|
||||
//
|
||||
// HTTP/1.1 is pinned by clearing ForceAttemptHTTP2 and installing an
|
||||
// empty TLSNextProto, which disables h2 regardless of how the transport
|
||||
// is dialled. Relying on net/http's conservative default (h2 off
|
||||
// whenever a custom dialer is set) would silently start negotiating h2
|
||||
// again the day a transport switches to DialTLSContext.
|
||||
func applyUpstreamHTTPVersion(t *http.Transport, version upstreamHTTPVersion) {
|
||||
if version == upstreamHTTP11 {
|
||||
t.ForceAttemptHTTP2 = false
|
||||
t.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
||||
t.TLSClientConfig = withoutHTTP2ALPN(t.TLSClientConfig)
|
||||
return
|
||||
}
|
||||
t.ForceAttemptHTTP2 = true
|
||||
}
|
||||
|
||||
// withoutHTTP2ALPN drops h2 from the ALPN offer. Configuring h2 makes
|
||||
// net/http append h2 to the transport's TLSClientConfig, so a transport
|
||||
// cloned from one that already served a request carries that offer with
|
||||
// it. Left in place, the upstream would select a protocol this
|
||||
// transport then refuses to speak, and the response would come back as
|
||||
// h2 frames parsed as an HTTP/1.1 message.
|
||||
func withoutHTTP2ALPN(cfg *tls.Config) *tls.Config {
|
||||
// A nil config offers no ALPN at all, which is already HTTP/1.1.
|
||||
if cfg == nil || len(cfg.NextProtos) == 0 {
|
||||
return cfg
|
||||
}
|
||||
|
||||
protos := make([]string, 0, len(cfg.NextProtos))
|
||||
for _, proto := range cfg.NextProtos {
|
||||
if proto == "h2" {
|
||||
continue
|
||||
}
|
||||
protos = append(protos, proto)
|
||||
}
|
||||
if len(protos) == len(cfg.NextProtos) {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Clone rather than edit in place: the caller may share this config
|
||||
// with the transport it was cloned from.
|
||||
stripped := cfg.Clone()
|
||||
stripped.NextProtos = protos
|
||||
|
||||
return stripped
|
||||
}
|
||||
|
||||
// envUpstreamHTTPVersion reads an upstream HTTP version from the
|
||||
// environment. An unrecognised value warns and leaves the default in
|
||||
// place rather than guessing at the operator's intent.
|
||||
func envUpstreamHTTPVersion(key string, logger *log.Logger) (upstreamHTTPVersion, bool) {
|
||||
s := strings.TrimSpace(os.Getenv(key))
|
||||
if s == "" {
|
||||
return "", false
|
||||
}
|
||||
switch v := upstreamHTTPVersion(strings.ToLower(s)); v {
|
||||
case upstreamHTTPAuto, upstreamHTTP11, upstreamHTTP2:
|
||||
return v, true
|
||||
default:
|
||||
logger.Warnf("ignoring unsupported %s=%q, expected one of %q, %q, %q",
|
||||
key, s, upstreamHTTPAuto, upstreamHTTP11, upstreamHTTP2)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(key string, logger *log.Logger) (int, bool) {
|
||||
s := os.Getenv(key)
|
||||
if s == "" {
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
package roundtrip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// upstreamDowngradeTTL is how long an upstream stays pinned to HTTP/1.1
|
||||
// after an h2 failure that only implied it cannot serve h2. Bounded so a
|
||||
// fixed or replaced backend returns to h2 without restarting the proxy.
|
||||
// A pin the upstream asked for itself does not expire — see downgrade.
|
||||
const upstreamDowngradeTTL = 10 * time.Minute
|
||||
|
||||
// downgrade is an upstream's HTTP/1.1 pin.
|
||||
type downgrade struct {
|
||||
// expiry is when the pin lapses and the upstream is offered h2
|
||||
// again. The zero time means it never does: the upstream answered
|
||||
// HTTP_1_1_REQUIRED, which is a statement about how it is
|
||||
// configured, not a fault that may clear on its own. Re-probing
|
||||
// that every upstreamDowngradeTTL would buy nothing but a failed
|
||||
// request per interval, so the pin holds until the transport goes
|
||||
// away with the proxy or the account's client.
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// permanent reports whether the upstream asked for this pin itself.
|
||||
func (d downgrade) permanent() bool {
|
||||
return d.expiry.IsZero()
|
||||
}
|
||||
|
||||
// active reports whether the pin still stands at now.
|
||||
func (d downgrade) active(now time.Time) bool {
|
||||
return d.permanent() || now.Before(d.expiry)
|
||||
}
|
||||
|
||||
// upstreamTransport carries requests to a single upstream family (one
|
||||
// TLS configuration) and implements what upstreamHTTPAuto means.
|
||||
//
|
||||
// ALPN already lets the upstream pick the protocol: primary offers both
|
||||
// h2 and http/1.1 and the server chooses. What ALPN cannot express is
|
||||
// an upstream that selects h2 and then fails to speak it — the case
|
||||
// this type handles. The first h2-level failure for a host pins that
|
||||
// host to fallback, an HTTP/1.1-only clone of primary, and the request
|
||||
// is retried there when it can be replayed.
|
||||
//
|
||||
// The downgrade is per upstream host, not per transport: one broken
|
||||
// backend must not drop every other backend to HTTP/1.1.
|
||||
type upstreamTransport struct {
|
||||
// primary is the configured transport: h2 offered in ALPN for
|
||||
// upstreamHTTPAuto and upstreamHTTP2, HTTP/1.1-only for
|
||||
// upstreamHTTP11.
|
||||
primary *http.Transport
|
||||
// version decides whether a downgrade may happen at all. Only
|
||||
// upstreamHTTPAuto downgrades; the explicit values are absolute.
|
||||
version upstreamHTTPVersion
|
||||
logger *log.Logger
|
||||
|
||||
// fallbackMu guards the lazy fallback clone: most deployments never
|
||||
// hit a broken h2 upstream and should not pay for a second
|
||||
// connection pool.
|
||||
fallbackMu sync.Mutex
|
||||
fallback *http.Transport
|
||||
|
||||
mu sync.RWMutex
|
||||
// downgraded maps an upstream host to its HTTP/1.1 pin.
|
||||
downgraded map[string]downgrade
|
||||
}
|
||||
|
||||
// newUpstreamTransport wraps base for the requested HTTP version. base
|
||||
// must not be used directly afterwards: the wrapper owns it, including
|
||||
// its connection pool.
|
||||
func newUpstreamTransport(base *http.Transport, version upstreamHTTPVersion, logger *log.Logger) *upstreamTransport {
|
||||
if logger == nil {
|
||||
logger = log.StandardLogger()
|
||||
}
|
||||
applyUpstreamHTTPVersion(base, version)
|
||||
|
||||
return &upstreamTransport{
|
||||
primary: base,
|
||||
version: version,
|
||||
logger: logger,
|
||||
downgraded: make(map[string]downgrade),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *upstreamTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.mayDowngrade(req) {
|
||||
return t.primary.RoundTrip(req)
|
||||
}
|
||||
|
||||
host := upstreamKey(req.URL)
|
||||
if t.isDowngraded(host) {
|
||||
return t.http1().RoundTrip(req)
|
||||
}
|
||||
|
||||
resp, err := t.primary.RoundTrip(req)
|
||||
if err == nil || !isHTTP2ProtocolError(err) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// HTTP_1_1_REQUIRED is the upstream saying it will not serve this
|
||||
// request over h2 however often it is asked — IIS answers it for
|
||||
// Windows Authentication and for client-certificate sites, where
|
||||
// the cause is site configuration rather than a passing fault.
|
||||
t.markDowngraded(host, isHTTP11Required(err))
|
||||
|
||||
if !safeToRetry(req, err) {
|
||||
// The upstream may have carried out the request before failing
|
||||
// to answer over h2, and repeating it could duplicate whatever
|
||||
// it did. The host is pinned either way, so the next request
|
||||
// goes out over HTTP/1.1.
|
||||
t.logger.WithFields(log.Fields{
|
||||
"upstream": host,
|
||||
"method": req.Method,
|
||||
}).Debug("not retrying over HTTP/1.1: the upstream may already have applied this request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retry, ok := replayable(req)
|
||||
if !ok {
|
||||
// The body is already consumed and cannot be regenerated, so
|
||||
// this request fails, and the pin carries the next one.
|
||||
return nil, err
|
||||
}
|
||||
return t.http1().RoundTrip(retry)
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes idle connections on both pools.
|
||||
func (t *upstreamTransport) CloseIdleConnections() {
|
||||
t.primary.CloseIdleConnections()
|
||||
if fallback := t.existingHTTP1(); fallback != nil {
|
||||
fallback.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// mayDowngrade reports whether a failed request is a downgrade
|
||||
// candidate. Only upstreamHTTPAuto downgrades, and only for TLS
|
||||
// upstreams: the proxy speaks no h2c, so a cleartext upstream is
|
||||
// already on HTTP/1.1 and an error there says nothing about h2.
|
||||
func (t *upstreamTransport) mayDowngrade(req *http.Request) bool {
|
||||
return t.version == upstreamHTTPAuto && req.URL != nil && req.URL.Scheme == "https"
|
||||
}
|
||||
|
||||
func (t *upstreamTransport) isDowngraded(host string) bool {
|
||||
t.mu.RLock()
|
||||
pin, ok := t.downgraded[host]
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if pin.active(time.Now()) {
|
||||
return true
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// Re-read under the write lock rather than trusting the expired pin
|
||||
// from above: a concurrent request may have re-pinned the host since,
|
||||
// and that pin decides this request too. Reporting the stale read
|
||||
// would send one request back to h2 against a live pin.
|
||||
pin, ok = t.downgraded[host]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if pin.active(time.Now()) {
|
||||
return true
|
||||
}
|
||||
delete(t.downgraded, host)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// markDowngraded pins host to HTTP/1.1. permanent marks a pin the
|
||||
// upstream asked for; anything else lapses after upstreamDowngradeTTL so
|
||||
// a repaired backend is offered h2 again.
|
||||
func (t *upstreamTransport) markDowngraded(host string, permanent bool) {
|
||||
now := time.Now()
|
||||
pin := downgrade{expiry: now.Add(upstreamDowngradeTTL)}
|
||||
if permanent {
|
||||
pin = downgrade{}
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
previous, pinned := t.downgraded[host]
|
||||
// A permanent pin is never weakened back into an expiring one: the
|
||||
// upstream has already said h2 is not on offer.
|
||||
promoted := pinned && !previous.permanent() && permanent
|
||||
if !pinned || !previous.permanent() {
|
||||
t.downgraded[host] = pin
|
||||
}
|
||||
for h, existing := range t.downgraded {
|
||||
if !existing.active(now) {
|
||||
delete(t.downgraded, h)
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
// Log a new pin, and a pin the upstream has since asked to make
|
||||
// permanent — otherwise an operator would only ever see the "for the
|
||||
// next 10m" line and never learn the upstream settled the question.
|
||||
if pinned && !promoted {
|
||||
return
|
||||
}
|
||||
|
||||
entry := t.logger.WithField("upstream", host)
|
||||
if permanent {
|
||||
entry.Warnf("upstream answered HTTP_1_1_REQUIRED, using HTTP/1.1 for it from now on")
|
||||
return
|
||||
}
|
||||
entry.Warnf("upstream negotiated HTTP/2 but failed to serve it, using HTTP/1.1 for the next %s (set %s=1.1 to pin it)",
|
||||
upstreamDowngradeTTL, EnvUpstreamHTTPVersion)
|
||||
}
|
||||
|
||||
// http1 returns the HTTP/1.1-only clone, creating it on first use.
|
||||
func (t *upstreamTransport) http1() *http.Transport {
|
||||
t.fallbackMu.Lock()
|
||||
defer t.fallbackMu.Unlock()
|
||||
|
||||
if t.fallback == nil {
|
||||
fallback := t.primary.Clone()
|
||||
applyUpstreamHTTPVersion(fallback, upstreamHTTP11)
|
||||
t.fallback = fallback
|
||||
}
|
||||
|
||||
return t.fallback
|
||||
}
|
||||
|
||||
// existingHTTP1 returns the fallback transport only if it was already
|
||||
// created, so housekeeping never allocates a second connection pool for
|
||||
// an upstream that never needed one.
|
||||
func (t *upstreamTransport) existingHTTP1() *http.Transport {
|
||||
t.fallbackMu.Lock()
|
||||
defer t.fallbackMu.Unlock()
|
||||
|
||||
return t.fallback
|
||||
}
|
||||
|
||||
// upstreamKey normalizes an authority for use as a pin key, so one
|
||||
// upstream cannot end up with two independent pins. DNS labels compare
|
||||
// case-insensitively, and the default HTTPS port is implied — every
|
||||
// downgrade path is TLS-only, so a bare host and the same host on :443
|
||||
// are the same upstream.
|
||||
func upstreamKey(u *url.URL) string {
|
||||
host := normalizeUpstreamHost(u.Hostname())
|
||||
|
||||
port := u.Port()
|
||||
if port == "" || port == "443" {
|
||||
return host
|
||||
}
|
||||
|
||||
// JoinHostPort rather than concatenation: an IPv6 literal needs its
|
||||
// brackets back after Hostname stripped them.
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// normalizeUpstreamHost folds the spellings of one host onto a single
|
||||
// key. An IP literal goes through netip so that the several textual
|
||||
// forms of one address (case, leading zeroes, a compressed run) collapse
|
||||
// and a v4-mapped address keys as the v4 address it is. A zone
|
||||
// identifier is left exactly as written: it names an interface, and
|
||||
// interface names are case-sensitive on the systems that have them, so
|
||||
// %eth0 and %ETH0 may be different links and must not share a pin.
|
||||
// Anything that is not an IP literal is a DNS name, which compares
|
||||
// case-insensitively.
|
||||
func normalizeUpstreamHost(host string) string {
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.Unmap().String()
|
||||
}
|
||||
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
// safeToRetry reports whether req may be sent a second time over
|
||||
// HTTP/1.1 after err ended its h2 attempt.
|
||||
//
|
||||
// A failure at the h2 layer does not say whether the upstream already
|
||||
// carried out the request, so replaying one that changes state could
|
||||
// duplicate it. Two cases are safe: a request whose repetition is
|
||||
// harmless by definition, and an upstream that told us it processed
|
||||
// nothing on the connection. The second is what makes the IIS case work
|
||||
// for every method — a site requiring HTTP/1.1 refuses at stream 0,
|
||||
// before the request is looked at.
|
||||
func safeToRetry(req *http.Request, err error) bool {
|
||||
return idempotent(req) || upstreamProcessedNothing(err)
|
||||
}
|
||||
|
||||
// idempotent reports whether repeating req is defined to be harmless.
|
||||
// It mirrors net/http's own retry rule (Request.isReplayable): a method
|
||||
// with no side effects, or a caller that promised the upstream
|
||||
// deduplicates by key.
|
||||
func idempotent(req *http.Request) bool {
|
||||
if req.Header.Get("Idempotency-Key") != "" || req.Header.Get("X-Idempotency-Key") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
// An empty method means GET, as in net/http.
|
||||
case "", http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// upstreamProcessedNothing reports whether err describes a GOAWAY that
|
||||
// named this request's stream as one the upstream had not received, so
|
||||
// it cannot have acted on it. A stream error says the opposite: the
|
||||
// stream was open, so the request had been delivered.
|
||||
func upstreamProcessedNothing(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := transportError(err).Error()
|
||||
|
||||
return strings.Contains(msg, goAwayStreamNotReceivedMarker) ||
|
||||
strings.Contains(msg, goAwayNothingProcessedMarker)
|
||||
}
|
||||
|
||||
// replayable returns a request that can be sent a second time, or
|
||||
// ok=false when the body is gone. A RoundTripper consumes and closes
|
||||
// the body it was given, so a retry needs either no body at all or
|
||||
// GetBody to produce a fresh one.
|
||||
func replayable(req *http.Request) (*http.Request, bool) {
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return req, true
|
||||
}
|
||||
if req.GetBody == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
retry := req.Clone(req.Context())
|
||||
retry.Body = body
|
||||
|
||||
return retry, true
|
||||
}
|
||||
|
||||
// http2ErrorMarkers are the substrings that identify an HTTP/2 protocol
|
||||
// failure. net/http bundles its own private copy of the http2 package,
|
||||
// so its errors cannot be matched by type from here: http2.StreamError
|
||||
// and friends in x/net are different types from the ones a
|
||||
// bundled-h2 transport returns. The strings below are the formats those
|
||||
// bundled errors print, and they are specific to h2 framing — a
|
||||
// downgrade must never be triggered by an ordinary network or TLS
|
||||
// error, which retrying on HTTP/1.1 would not fix.
|
||||
var http2ErrorMarkers = []string{
|
||||
// Transport-level h2 failures, e.g.
|
||||
// "http2: server sent GOAWAY and closed the connection".
|
||||
"http2:",
|
||||
// http2.StreamError, e.g. "stream error: stream ID 1; PROTOCOL_ERROR".
|
||||
"stream error: stream ID",
|
||||
// http2.ConnectionError, e.g. "connection error: PROTOCOL_ERROR".
|
||||
"connection error: ",
|
||||
// The code an upstream sends to say the request must be retried
|
||||
// over HTTP/1.1, as a GOAWAY or on the stream.
|
||||
http11RequiredMarker,
|
||||
}
|
||||
|
||||
const (
|
||||
// http11RequiredMarker is the error code an upstream sends to say the
|
||||
// request belongs on HTTP/1.1. Unlike the other markers it is not a
|
||||
// fault: the upstream is describing its own configuration.
|
||||
http11RequiredMarker = "HTTP_1_1_REQUIRED"
|
||||
|
||||
// A GOAWAY carrying NO_ERROR closes a connection without complaint:
|
||||
// a server draining before shutdown, recycling an application pool,
|
||||
// capping requests per connection. The upstream speaks h2 perfectly
|
||||
// well, so this must never pin it. The two spellings are the two
|
||||
// formats the bundled transport prints the code in.
|
||||
goAwayNoErrorEqualsMarker = "ErrCode=NO_ERROR"
|
||||
goAwayNoErrorColonMarker = "ErrCode:NO_ERROR"
|
||||
// gracefulGoAwayMarker is errClientConnGotGoAway, which the bundled
|
||||
// transport raises for a stream the server never received on a
|
||||
// connection it is shutting down gracefully. It normally retries
|
||||
// those itself on a new connection and this never surfaces.
|
||||
gracefulGoAwayMarker = "Transport received Server's graceful shutdown GOAWAY"
|
||||
|
||||
// goAwayStreamNotReceivedMarker is the bundled transport's abort for
|
||||
// the first stream on a connection whose GOAWAY carried a real error
|
||||
// code — the IIS case. It sits in the same "streamID > LastStreamID"
|
||||
// branch as the graceful abort, so the server had not received the
|
||||
// stream (see net/http's h2_bundle.go).
|
||||
goAwayStreamNotReceivedMarker = "Transport received GOAWAY from server ErrCode:"
|
||||
// goAwayNothingProcessedMarker is a GoAwayError naming stream 0 as
|
||||
// the last one received, which says the same thing. The trailing
|
||||
// comma keeps it from matching LastStreamID=10 and the rest.
|
||||
goAwayNothingProcessedMarker = "LastStreamID=0,"
|
||||
)
|
||||
|
||||
// isHTTP11Required reports whether the upstream itself asked for
|
||||
// HTTP/1.1, rather than merely failing at h2.
|
||||
func isHTTP11Required(err error) bool {
|
||||
return err != nil && strings.Contains(transportError(err).Error(), http11RequiredMarker)
|
||||
}
|
||||
|
||||
// isHTTP2ProtocolError reports whether err says the upstream cannot
|
||||
// serve the h2 it negotiated.
|
||||
func isHTTP2ProtocolError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := transportError(err).Error()
|
||||
|
||||
// A graceful GOAWAY is routine connection management, not an
|
||||
// upstream that cannot serve h2.
|
||||
if isGracefulGoAway(msg) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, marker := range http2ErrorMarkers {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isGracefulGoAway reports whether msg describes a GOAWAY sent to close
|
||||
// a healthy connection rather than to report an inability to serve h2.
|
||||
func isGracefulGoAway(msg string) bool {
|
||||
return strings.Contains(msg, goAwayNoErrorEqualsMarker) ||
|
||||
strings.Contains(msg, goAwayNoErrorColonMarker) ||
|
||||
strings.Contains(msg, gracefulGoAwayMarker)
|
||||
}
|
||||
|
||||
// transportError strips a *url.Error wrapper, which prefixes the request
|
||||
// URL to the message. Markers are matched as substrings, so a URL left
|
||||
// in place could classify an ordinary dial or TLS failure as an h2 one
|
||||
// on the strength of the path alone.
|
||||
func transportError(err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
return urlErr.Err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package roundtrip
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// TestUpstreamTransport_AutoFallsBackOnBrokenHTTP2 covers the case ALPN
|
||||
// cannot express: the upstream advertises h2, picks it, and then cannot
|
||||
// serve it. The request must still succeed, over HTTP/1.1, and the
|
||||
// upstream must stay on HTTP/1.1 for the requests that follow.
|
||||
func TestUpstreamTransport_AutoFallsBackOnBrokenHTTP2(t *testing.T) {
|
||||
t.Setenv(EnvUpstreamHTTPVersion, string(upstreamHTTPAuto))
|
||||
srv := startBrokenHTTP2Server(t)
|
||||
|
||||
mt := NewDirectOnly(nil)
|
||||
ctx := WithSkipTLSVerify(WithDirectUpstream(context.Background()))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+srv.addr, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := mt.RoundTrip(req)
|
||||
require.NoError(t, err, "a replayable request must be retried on HTTP/1.1 instead of failing")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "HTTP/1.1", resp.Proto, "the retry must ride the HTTP/1.1 transport")
|
||||
assert.Equal(t, "http/1.1", string(body), "the upstream must see an http/1.1 ALPN offer on the retry")
|
||||
|
||||
assert.True(t, mt.insecure.isDowngraded(srv.addr),
|
||||
"the upstream must stay pinned to HTTP/1.1 after proving it cannot serve h2")
|
||||
mt.insecure.mu.RLock()
|
||||
pin := mt.insecure.downgraded[srv.addr]
|
||||
mt.insecure.mu.RUnlock()
|
||||
assert.True(t, pin.permanent(),
|
||||
"an upstream answering HTTP_1_1_REQUIRED must not be re-probed for h2")
|
||||
|
||||
// The second request must not repeat the h2 attempt: the server
|
||||
// counts h2 handshakes, so a repeat would show up here.
|
||||
h2Attempts := srv.http2Handshakes()
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://"+srv.addr, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err = mt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, "HTTP/1.1", resp.Proto, "a pinned upstream must go straight to HTTP/1.1")
|
||||
assert.Equal(t, h2Attempts, srv.http2Handshakes(),
|
||||
"a pinned upstream must not be probed for h2 again until the pin expires")
|
||||
}
|
||||
|
||||
// TestUpstreamTransport_ExplicitHTTP2NeverDowngrades pins the promise
|
||||
// that the explicit values are absolute: an operator who asked for h2
|
||||
// keeps h2, broken upstream or not.
|
||||
func TestUpstreamTransport_ExplicitHTTP2NeverDowngrades(t *testing.T) {
|
||||
t.Setenv(EnvUpstreamHTTPVersion, string(upstreamHTTP2))
|
||||
srv := startBrokenHTTP2Server(t)
|
||||
|
||||
mt := NewDirectOnly(nil)
|
||||
ctx := WithSkipTLSVerify(WithDirectUpstream(context.Background()))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+srv.addr, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := mt.RoundTrip(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
require.Error(t, err, "NB_PROXY_UPSTREAM_HTTP_VERSION=2 must not fall back to HTTP/1.1")
|
||||
assert.False(t, mt.insecure.isDowngraded(srv.addr), "an explicit version must never pin an upstream")
|
||||
}
|
||||
|
||||
// TestUpstreamTransport_AutoDoesNotReplayUnsafeRequests covers the
|
||||
// other half of the fallback: an h2 failure says nothing about whether
|
||||
// the upstream already applied the request, so a state-changing one is
|
||||
// not replayed. The host is still pinned, so the next request rides
|
||||
// HTTP/1.1 without a second h2 attempt.
|
||||
func TestUpstreamTransport_AutoDoesNotReplayUnsafeRequests(t *testing.T) {
|
||||
t.Setenv(EnvUpstreamHTTPVersion, string(upstreamHTTPAuto))
|
||||
srv := startBrokenHTTP2Server(t)
|
||||
// A stream error means the stream was open, so the upstream had the
|
||||
// request in hand — unlike the GOAWAY at stream 0 the fake server
|
||||
// sends, which states it processed nothing.
|
||||
streamErr := http2.StreamError{StreamID: 1, Code: http2.ErrCodeProtocol}
|
||||
|
||||
mt := NewDirectOnly(nil)
|
||||
transport := mt.insecure
|
||||
ctx := WithSkipTLSVerify(WithDirectUpstream(context.Background()))
|
||||
|
||||
assert.False(t, safeToRetry(newTestRequest(t, ctx, http.MethodPost, srv.addr), streamErr),
|
||||
"a POST must not be replayed after a failure that may have been applied")
|
||||
assert.True(t, safeToRetry(newTestRequest(t, ctx, http.MethodGet, srv.addr), streamErr),
|
||||
"a GET is safe to replay whatever the failure was")
|
||||
|
||||
// The fake server's GOAWAY names stream 0, so even a POST is safe
|
||||
// there and the request must succeed over HTTP/1.1.
|
||||
resp, err := transport.RoundTrip(newTestRequest(t, ctx, http.MethodPost, srv.addr))
|
||||
require.NoError(t, err, "a GOAWAY at stream 0 means the upstream applied nothing, so the POST may be replayed")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http/1.1", string(body), "the retry must reach the upstream over http/1.1")
|
||||
|
||||
h2Attempts := srv.http2Handshakes()
|
||||
resp, err = transport.RoundTrip(newTestRequest(t, ctx, http.MethodPost, srv.addr))
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
assert.Equal(t, h2Attempts, srv.http2Handshakes(),
|
||||
"the pin must carry later requests without another h2 attempt")
|
||||
}
|
||||
|
||||
func newTestRequest(t *testing.T, ctx context.Context, method, addr string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, "https://"+addr, strings.NewReader("payload"))
|
||||
require.NoError(t, err)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUpstreamKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "host", url: "https://backend.invalid/path", want: "backend.invalid"},
|
||||
// DNS is case-insensitive, so these are one upstream and must
|
||||
// share one pin.
|
||||
{name: "mixed case", url: "https://Backend.INVALID/path", want: "backend.invalid"},
|
||||
// The default port is implied on every path that can downgrade.
|
||||
{name: "explicit default port", url: "https://backend.invalid:443/", want: "backend.invalid"},
|
||||
{name: "non-default port", url: "https://backend.invalid:8443/", want: "backend.invalid:8443"},
|
||||
// An IPv6 literal needs its brackets back after Hostname strips
|
||||
// them, or the key is not a dialable authority.
|
||||
{name: "ipv6 default port", url: "https://[2001:db8::1]/", want: "2001:db8::1"},
|
||||
{name: "ipv6 with port", url: "https://[2001:db8::1]:8443/", want: "[2001:db8::1]:8443"},
|
||||
// One address in three spellings: hex case, a leading zero and an
|
||||
// uncompressed zero run are all the same upstream.
|
||||
{name: "ipv6 upper case", url: "https://[2001:DB8::1]/", want: "2001:db8::1"},
|
||||
{name: "ipv6 leading zero", url: "https://[2001:0db8::1]/", want: "2001:db8::1"},
|
||||
{name: "ipv6 uncompressed", url: "https://[2001:db8:0:0:0:0:0:1]/", want: "2001:db8::1"},
|
||||
// A v4-mapped address is the v4 address, not a second upstream.
|
||||
{name: "v4-mapped", url: "https://[::ffff:192.0.2.1]/", want: "192.0.2.1"},
|
||||
// A zone names an interface, and interface names are
|
||||
// case-sensitive, so these two are different links.
|
||||
{name: "ipv6 zone", url: "https://[fe80::1%25eth0]/", want: "fe80::1%eth0"},
|
||||
{name: "ipv6 zone upper case", url: "https://[fe80::1%25ETH0]/", want: "fe80::1%ETH0"},
|
||||
// The address before the zone still normalizes.
|
||||
{name: "ipv6 zone with upper-case address", url: "https://[FE80::1%25eth0]/", want: "fe80::1%eth0"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parsed, err := url.Parse(tc.url)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.want, upstreamKey(parsed))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeToRetry(t *testing.T) {
|
||||
streamErr := http2.StreamError{StreamID: 1, Code: http2.ErrCodeProtocol}
|
||||
goAwayAtZero := errors.New(`http2: server sent GOAWAY and closed the connection; LastStreamID=0, ErrCode=HTTP_1_1_REQUIRED, debug=""`)
|
||||
goAwayLater := errors.New(`http2: server sent GOAWAY and closed the connection; LastStreamID=11, ErrCode=PROTOCOL_ERROR, debug=""`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
headers map[string]string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "get", method: http.MethodGet, err: streamErr, want: true},
|
||||
{name: "head", method: http.MethodHead, err: streamErr, want: true},
|
||||
{name: "options", method: http.MethodOptions, err: streamErr, want: true},
|
||||
{name: "trace", method: http.MethodTrace, err: streamErr, want: true},
|
||||
{name: "post", method: http.MethodPost, err: streamErr, want: false},
|
||||
{name: "put", method: http.MethodPut, err: streamErr, want: false},
|
||||
{name: "patch", method: http.MethodPatch, err: streamErr, want: false},
|
||||
{name: "delete", method: http.MethodDelete, err: streamErr, want: false},
|
||||
// The upstream reported it handled nothing, so repeating the
|
||||
// request cannot duplicate anything.
|
||||
{name: "post with goaway at stream 0", method: http.MethodPost, err: goAwayAtZero, want: true},
|
||||
// What the bundled transport actually raises for the first
|
||||
// stream on a connection the upstream GOAWAYs with a real error
|
||||
// code, which is the shape a real IIS site produces.
|
||||
{
|
||||
name: "post with first-stream goaway abort",
|
||||
method: http.MethodPost,
|
||||
err: errors.New("http2: Transport received GOAWAY from server ErrCode:HTTP_1_1_REQUIRED"),
|
||||
want: true,
|
||||
},
|
||||
// It handled earlier streams, so this one may have been applied.
|
||||
{name: "post with goaway after other streams", method: http.MethodPost, err: goAwayLater, want: false},
|
||||
{
|
||||
name: "post with idempotency key",
|
||||
method: http.MethodPost,
|
||||
headers: map[string]string{"Idempotency-Key": "abc"},
|
||||
err: streamErr,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "post with prefixed idempotency key",
|
||||
method: http.MethodPost,
|
||||
headers: map[string]string{"X-Idempotency-Key": "abc"},
|
||||
err: streamErr,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tc.method, "https://backend.invalid", nil)
|
||||
require.NoError(t, err)
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
assert.Equal(t, tc.want, safeToRetry(req, tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamTransport_MayDowngrade(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version upstreamHTTPVersion
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{name: "auto over TLS", version: upstreamHTTPAuto, url: "https://backend.invalid", want: true},
|
||||
// The proxy speaks no h2c, so a cleartext upstream is already on
|
||||
// HTTP/1.1 and its failures say nothing about h2.
|
||||
{name: "auto cleartext", version: upstreamHTTPAuto, url: "http://backend.invalid", want: false},
|
||||
{name: "explicit 1.1", version: upstreamHTTP11, url: "https://backend.invalid", want: false},
|
||||
{name: "explicit 2", version: upstreamHTTP2, url: "https://backend.invalid", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
transport := newUpstreamTransport(&http.Transport{}, tc.version, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, tc.url, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.want, transport.mayDowngrade(req))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamTransport_DowngradeExpires(t *testing.T) {
|
||||
transport := newUpstreamTransport(&http.Transport{}, upstreamHTTPAuto, nil)
|
||||
transport.markDowngraded("backend.invalid:443", false)
|
||||
require.True(t, transport.isDowngraded("backend.invalid:443"))
|
||||
|
||||
transport.mu.Lock()
|
||||
transport.downgraded["backend.invalid:443"] = downgrade{expiry: time.Now().Add(-time.Second)}
|
||||
transport.mu.Unlock()
|
||||
|
||||
assert.False(t, transport.isDowngraded("backend.invalid:443"),
|
||||
"an expired pin must let the upstream be offered h2 again")
|
||||
transport.mu.RLock()
|
||||
_, stillTracked := transport.downgraded["backend.invalid:443"]
|
||||
transport.mu.RUnlock()
|
||||
assert.False(t, stillTracked, "an expired pin must not be kept around")
|
||||
}
|
||||
|
||||
func TestUpstreamTransport_DowngradeIsPerUpstream(t *testing.T) {
|
||||
transport := newUpstreamTransport(&http.Transport{}, upstreamHTTPAuto, nil)
|
||||
transport.markDowngraded("broken.invalid:443", false)
|
||||
|
||||
assert.True(t, transport.isDowngraded("broken.invalid:443"))
|
||||
assert.False(t, transport.isDowngraded("healthy.invalid:443"),
|
||||
"one broken upstream must not drop the others to HTTP/1.1")
|
||||
}
|
||||
|
||||
// TestUpstreamTransport_HTTP11RequiredPinIsPermanent covers the IIS
|
||||
// case: HTTP_1_1_REQUIRED describes how the upstream is configured
|
||||
// (Windows Authentication, client certificates), so re-probing it every
|
||||
// upstreamDowngradeTTL would only buy a failed request per interval.
|
||||
func TestUpstreamTransport_HTTP11RequiredPinIsPermanent(t *testing.T) {
|
||||
transport := newUpstreamTransport(&http.Transport{}, upstreamHTTPAuto, nil)
|
||||
transport.markDowngraded("iis.invalid:443", true)
|
||||
|
||||
transport.mu.RLock()
|
||||
pin := transport.downgraded["iis.invalid:443"]
|
||||
transport.mu.RUnlock()
|
||||
|
||||
assert.True(t, pin.permanent(), "an upstream that asked for HTTP/1.1 must not be re-probed")
|
||||
assert.True(t, pin.active(time.Now().Add(100*upstreamDowngradeTTL)),
|
||||
"a permanent pin must outlive any TTL")
|
||||
}
|
||||
|
||||
func TestUpstreamTransport_PermanentPinSurvivesLaterFailures(t *testing.T) {
|
||||
transport := newUpstreamTransport(&http.Transport{}, upstreamHTTPAuto, nil)
|
||||
transport.markDowngraded("iis.invalid:443", true)
|
||||
// A later ambiguous failure for the same upstream must not turn the
|
||||
// permanent pin into an expiring one.
|
||||
transport.markDowngraded("iis.invalid:443", false)
|
||||
|
||||
transport.mu.RLock()
|
||||
pin := transport.downgraded["iis.invalid:443"]
|
||||
transport.mu.RUnlock()
|
||||
|
||||
assert.True(t, pin.permanent(), "a permanent pin must never be weakened")
|
||||
}
|
||||
|
||||
func TestIsHTTP11Required(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "goaway",
|
||||
err: errors.New(`http2: server sent GOAWAY and closed the connection; LastStreamID=0, ErrCode=HTTP_1_1_REQUIRED, debug=""`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stream error",
|
||||
err: http2.StreamError{StreamID: 1, Code: http2.ErrCodeHTTP11Required},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "other h2 failure",
|
||||
err: http2.StreamError{StreamID: 1, Code: http2.ErrCodeProtocol},
|
||||
want: false,
|
||||
},
|
||||
{name: "nil", err: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isHTTP11Required(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTP2ProtocolError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "goaway demanding http/1.1",
|
||||
err: errors.New(`http2: server sent GOAWAY and closed the connection; LastStreamID=0, ErrCode=HTTP_1_1_REQUIRED, debug=""`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stream error",
|
||||
err: http2.StreamError{StreamID: 1, Code: http2.ErrCodeProtocol},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "connection error",
|
||||
err: http2.ConnectionError(http2.ErrCodeProtocol),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped h2 error",
|
||||
err: errors.New("Get \"https://backend.invalid\": http2: client connection lost"),
|
||||
want: true,
|
||||
},
|
||||
// A GOAWAY with NO_ERROR is a server draining a connection —
|
||||
// recycling an application pool, capping requests per
|
||||
// connection, shutting down gracefully. It speaks h2 fine.
|
||||
{
|
||||
name: "graceful goaway",
|
||||
err: errors.New(`http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=NO_ERROR, debug=""`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "graceful shutdown abort",
|
||||
err: errors.New("http2: Transport received Server's graceful shutdown GOAWAY"),
|
||||
want: false,
|
||||
},
|
||||
// Markers are substrings, so a URL carried by a *url.Error must
|
||||
// not be able to classify a plain failure as an h2 one.
|
||||
{
|
||||
name: "url error whose path looks like a marker",
|
||||
err: &url.Error{
|
||||
Op: "Get",
|
||||
URL: "https://backend.invalid/http2:/connection error: x",
|
||||
Err: errors.New("dial tcp 10.0.0.1:443: connect: connection refused"),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
// Retrying these on HTTP/1.1 fixes nothing, so they must never
|
||||
// pin an upstream.
|
||||
{name: "dial failure", err: errors.New("dial tcp 10.0.0.1:443: connect: connection refused"), want: false},
|
||||
{name: "tls failure", err: errors.New("tls: failed to verify certificate: x509: certificate signed by unknown authority"), want: false},
|
||||
{name: "context cancelled", err: context.Canceled, want: false},
|
||||
{name: "eof", err: io.EOF, want: false},
|
||||
{name: "nil", err: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isHTTP2ProtocolError(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayable(t *testing.T) {
|
||||
t.Run("bodyless request", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://backend.invalid", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
retry, ok := replayable(req)
|
||||
require.True(t, ok)
|
||||
assert.Same(t, req, retry, "a bodyless request needs no clone")
|
||||
})
|
||||
|
||||
t.Run("request with GetBody", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "https://backend.invalid", strings.NewReader("payload"))
|
||||
require.NoError(t, err)
|
||||
// Consume the body the way a failed RoundTrip would.
|
||||
_, err = io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
retry, ok := replayable(req)
|
||||
require.True(t, ok)
|
||||
body, err := io.ReadAll(retry.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "payload", string(body), "the retry must carry a fresh copy of the body")
|
||||
})
|
||||
|
||||
t.Run("streamed request", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "https://backend.invalid", io.NopCloser(strings.NewReader("payload")))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, req.GetBody, "an opaque reader must not get a GetBody")
|
||||
|
||||
_, ok := replayable(req)
|
||||
assert.False(t, ok, "a body that cannot be regenerated must not be replayed")
|
||||
})
|
||||
}
|
||||
|
||||
// brokenHTTP2Server advertises h2 in ALPN, accepts it, and then refuses
|
||||
// to serve it — the upstream behaviour that motivated the fallback. Over
|
||||
// http/1.1 it answers normally, so a downgraded request succeeds.
|
||||
type brokenHTTP2Server struct {
|
||||
addr string
|
||||
|
||||
handshakes chan struct{}
|
||||
}
|
||||
|
||||
func (s *brokenHTTP2Server) http2Handshakes() int {
|
||||
return len(s.handshakes)
|
||||
}
|
||||
|
||||
func startBrokenHTTP2Server(t *testing.T) *brokenHTTP2Server {
|
||||
t.Helper()
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
||||
Certificates: []tls.Certificate{selfSignedCert(t)},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
srv := &brokenHTTP2Server{
|
||||
addr: ln.Addr().String(),
|
||||
// Buffered well past what the test drives so a stuck server
|
||||
// never blocks the accept loop.
|
||||
handshakes: make(chan struct{}, 64),
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go srv.handle(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *brokenHTTP2Server) handle(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
tlsConn, ok := conn.(*tls.Conn)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
if proto == "h2" {
|
||||
select {
|
||||
case s.handshakes <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
s.refuseHTTP2(tlsConn)
|
||||
return
|
||||
}
|
||||
|
||||
s.serveHTTP1(tlsConn, proto)
|
||||
}
|
||||
|
||||
// refuseHTTP2 completes just enough of the h2 handshake for the client
|
||||
// to accept the connection, then sends the GOAWAY an upstream uses to
|
||||
// say the request belongs on HTTP/1.1.
|
||||
//
|
||||
// The client is still writing its preface and request while the GOAWAY
|
||||
// goes out, so the connection is drained before the caller closes it.
|
||||
// Closing a socket with unread bytes still in its receive buffer makes
|
||||
// the kernel answer with RST, which reaches the client as a write error
|
||||
// rather than the GOAWAY — no h2 error, so no downgrade, and the test
|
||||
// fails on the error the client saw first.
|
||||
func (s *brokenHTTP2Server) refuseHTTP2(conn net.Conn) {
|
||||
framer := http2.NewFramer(conn, conn)
|
||||
if err := framer.WriteSettings(); err != nil {
|
||||
return
|
||||
}
|
||||
if err := framer.WriteGoAway(0, http2.ErrCodeHTTP11Required, nil); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// The client closes its side once it has read the GOAWAY, which ends
|
||||
// the drain; the deadline is only a backstop against a client that
|
||||
// never does.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
}
|
||||
|
||||
// serveHTTP1 answers a single request with the ALPN protocol the
|
||||
// upstream actually settled on, so a test asserting on the body is
|
||||
// checking what the upstream saw rather than a constant.
|
||||
func (s *brokenHTTP2Server) serveHTTP1(conn net.Conn, alpn string) {
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, err := http.ReadRequest(reader); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(conn,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
|
||||
len(alpn), alpn)
|
||||
}
|
||||
|
||||
func selfSignedCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
+23
-11
@@ -17,6 +17,9 @@ ARCH="$(uname -m)"
|
||||
PACKAGE_MANAGER="bin"
|
||||
INSTALL_DIR=""
|
||||
SUDO=""
|
||||
# curl protocol set for --proto / --proto-redir: https and nothing else, so no
|
||||
# request and no redirect in a chain can fall back to plaintext.
|
||||
PROTO_HTTPS="=https"
|
||||
|
||||
|
||||
if command -v sudo > /dev/null && [ "$(id -u)" -ne 0 ]; then
|
||||
@@ -25,6 +28,15 @@ elif command -v doas > /dev/null && [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="doas"
|
||||
fi
|
||||
|
||||
# Downloads are staged in a private directory instead of /tmp. Fixed names in a
|
||||
# shared directory can collide with entries created there beforehand, and the
|
||||
# paths staged here are consumed by the privileged install steps below.
|
||||
NB_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/netbird.XXXXXXXXXX")" || {
|
||||
echo "Unable to create a temporary directory for the downloads"
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -rf "$NB_TMPDIR"' EXIT
|
||||
|
||||
if [ -z ${NETBIRD_RELEASE+x} ]; then
|
||||
NETBIRD_RELEASE=latest
|
||||
fi
|
||||
@@ -73,10 +85,11 @@ download_release_binary() {
|
||||
DOWNLOAD_URL="${BASE_URL}/${VERSION}/${BINARY_NAME}"
|
||||
|
||||
echo "Installing $1 from $DOWNLOAD_URL"
|
||||
ARCHIVE_PATH="${NB_TMPDIR}/${BINARY_NAME}"
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
cd /tmp && curl -H "Authorization: token ${GITHUB_TOKEN}" -LO "$DOWNLOAD_URL"
|
||||
curl -H "Authorization: token ${GITHUB_TOKEN}" -L --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" -o "$ARCHIVE_PATH" "$DOWNLOAD_URL"
|
||||
else
|
||||
cd /tmp && curl -LO "$DOWNLOAD_URL" || curl -LO --dns-servers 8.8.8.8 "$DOWNLOAD_URL"
|
||||
curl -L --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" -o "$ARCHIVE_PATH" "$DOWNLOAD_URL" || curl -L --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" -o "$ARCHIVE_PATH" --dns-servers 8.8.8.8 "$DOWNLOAD_URL"
|
||||
fi
|
||||
|
||||
|
||||
@@ -89,12 +102,12 @@ download_release_binary() {
|
||||
fi
|
||||
|
||||
# Unzip the app and move to INSTALL_DIR
|
||||
unzip -q -o "$BINARY_NAME"
|
||||
mv -v "netbird_ui_${OS_TYPE}/" "$INSTALL_DIR/" || mv -v "netbird_ui_${OS_TYPE}_${ARCH}/" "$INSTALL_DIR/"
|
||||
unzip -q -o "$ARCHIVE_PATH" -d "$NB_TMPDIR"
|
||||
mv -v "${NB_TMPDIR}/netbird_ui_${OS_TYPE}/" "$INSTALL_DIR/" || mv -v "${NB_TMPDIR}/netbird_ui_${OS_TYPE}_${ARCH}/" "$INSTALL_DIR/"
|
||||
else
|
||||
${SUDO} mkdir -p "$INSTALL_DIR"
|
||||
tar -xzvf "$BINARY_NAME"
|
||||
${SUDO} mv "${1%_"${BINARY_BASE_NAME}"}" "$INSTALL_DIR/"
|
||||
tar -xzvf "$ARCHIVE_PATH" -C "$NB_TMPDIR"
|
||||
${SUDO} mv "${NB_TMPDIR}/${1%_"${BINARY_BASE_NAME}"}" "$INSTALL_DIR/"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -110,7 +123,7 @@ add_apt_repo() {
|
||||
/usr/share/keyrings/netbird-archive-keyring.gpg \
|
||||
/usr/share/keyrings/wiretrustee-archive-keyring.gpg
|
||||
|
||||
curl -sSL https://pkgs.netbird.io/debian/public.key \
|
||||
curl -sSL --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" https://pkgs.netbird.io/debian/public.key \
|
||||
| ${SUDO} gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
|
||||
|
||||
# Explicitly set the file permission
|
||||
@@ -183,11 +196,10 @@ install_pkg() {
|
||||
*) echo "Unsupported macOS arch: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
PKG_URL=$(curl -sIL -o /dev/null -w '%{url_effective}' "https://pkgs.netbird.io/macos/${ARCH}")
|
||||
PKG_URL=$(curl -sIL --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" -o /dev/null -w '%{url_effective}' "https://pkgs.netbird.io/macos/${ARCH}")
|
||||
echo "Downloading NetBird macOS installer from https://pkgs.netbird.io/macos/${ARCH}"
|
||||
curl -fsSL -o /tmp/netbird.pkg "${PKG_URL}"
|
||||
${SUDO} installer -pkg /tmp/netbird.pkg -target /
|
||||
rm -f /tmp/netbird.pkg
|
||||
curl -fsSL --proto "$PROTO_HTTPS" --proto-redir "$PROTO_HTTPS" -o "${NB_TMPDIR}/netbird.pkg" "${PKG_URL}"
|
||||
${SUDO} installer -pkg "${NB_TMPDIR}/netbird.pkg" -target /
|
||||
}
|
||||
|
||||
check_use_bin_variable() {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Environment for the netbird client service (netbird.service).
|
||||
# Every "netbird up" flag has an NB_* equivalent; see
|
||||
# https://docs.netbird.io/get-started/cli#environment-variables
|
||||
#
|
||||
# Red Hat Enterprise Linux 9 ships the in-kernel WireGuard module as a
|
||||
# Technology Preview and taints the kernel when it loads. For a fully
|
||||
# supported RHEL 9 configuration run the client with userspace WireGuard:
|
||||
#NB_WG_KERNEL_DISABLED=true
|
||||
#
|
||||
# Self-hosted management server:
|
||||
#NB_MANAGEMENT_URL=https://netbird.example.com
|
||||
#
|
||||
# Peer name shown in the dashboard (defaults to the system hostname):
|
||||
#NB_HOSTNAME=
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Generate the changelog nfpm embeds into the RPM.
|
||||
#
|
||||
# chglog records the whole commit message for each entry. Pull requests are
|
||||
# squashed on merge, so that body is usually the pull request description:
|
||||
# template headings, review checklists, HTML comments and Co-authored-by
|
||||
# trailers. None of that belongs in a package on Red Hat's catalog, and it is
|
||||
# most of the changelog's size. Keep the subject line and drop the rest.
|
||||
|
||||
set -eu
|
||||
|
||||
go tool chglog init
|
||||
|
||||
python3 - changelog.yml <<'PYEOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
lines = open(path, encoding="utf-8").read().split("\n")
|
||||
|
||||
NOTE = re.compile(r"^ note: (.*)$")
|
||||
BLOCK = {"|", "|-", "|+", ">", ">-", ">+"}
|
||||
|
||||
|
||||
def quote(text):
|
||||
"""Render text as a YAML single-quoted scalar."""
|
||||
return " note: '{}'".format(text.replace("'", "''"))
|
||||
|
||||
|
||||
def first_line_of_double_quoted(value):
|
||||
"""Text of a double-quoted scalar up to its first \\n escape."""
|
||||
out = []
|
||||
i = 1
|
||||
while i < len(value):
|
||||
c = value[i]
|
||||
if c == "\\" and i + 1 < len(value):
|
||||
if value[i + 1] == "n":
|
||||
break
|
||||
out.append(value[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if c == '"':
|
||||
break
|
||||
out.append(c)
|
||||
i += 1
|
||||
return "".join(out).replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
out = []
|
||||
seen = 0
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = NOTE.match(line)
|
||||
if not m:
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
seen += 1
|
||||
value = m.group(1)
|
||||
|
||||
if value in BLOCK:
|
||||
# Subject is the first non-empty body line; skip the rest of the block.
|
||||
i += 1
|
||||
subject = None
|
||||
while i < len(lines) and (lines[i] == "" or lines[i].startswith(" ")):
|
||||
if subject is None and lines[i].strip():
|
||||
subject = lines[i][8:]
|
||||
i += 1
|
||||
if subject is None:
|
||||
sys.exit("empty block scalar note near line {}".format(i))
|
||||
out.append(quote(subject))
|
||||
continue
|
||||
|
||||
if value.startswith('"') and value.endswith('"') and len(value) > 1:
|
||||
out.append(quote(first_line_of_double_quoted(value)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if value.startswith("'") and value.endswith("'") and len(value) > 1:
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if not value.startswith(("'", '"')) and value not in BLOCK:
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
sys.exit("unhandled note form at line {}: {!r}".format(i + 1, value))
|
||||
|
||||
open(path, "w", encoding="utf-8").write("\n".join(out))
|
||||
print("changelog entries: {}".format(seen))
|
||||
PYEOF
|
||||
|
||||
# Every note must now be a single line. A multi-line one means the rewrite
|
||||
# missed a form chglog emitted, and the package would ship the pull request
|
||||
# body again.
|
||||
if grep -nE "^ note: [|>]" changelog.yml; then
|
||||
echo "block-scalar notes survived the rewrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -nE '^ note: ".*\\n' changelog.yml; then
|
||||
echo "multi-line notes survived the rewrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -s changelog.yml
|
||||
@@ -135,6 +135,11 @@ func NewUserPendingApprovalError() error {
|
||||
return Errorf(PermissionDenied, "user is pending approval")
|
||||
}
|
||||
|
||||
// NewUserPendingApprovalByOwnerError creates a new Error with PermissionDenied type for a blocked user pending approval, naming the masked address of the owner who can approve them
|
||||
func NewUserPendingApprovalByOwnerError(ownerEmail string) error {
|
||||
return Errorf(PermissionDenied, "user is pending approval by owner %s", ownerEmail)
|
||||
}
|
||||
|
||||
// NewPeerNotRegisteredError creates a new Error with Unauthenticated type unregistered peer
|
||||
func NewPeerNotRegisteredError() error {
|
||||
return Errorf(Unauthenticated, "peer is not registered")
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
// Package main turns a `go test -json` stream into a readable CI log.
|
||||
//
|
||||
// It prints one line per top-level test as it finishes, the captured output of
|
||||
// every failed test, the head of a package-level panic (which is where Go
|
||||
// reports "test timed out" and the list of still-running tests), and ends with
|
||||
// the per-package durations and the slowest tests. The exit code is always zero
|
||||
// unless the input cannot be read; the `go test` exit code is what CI should act
|
||||
// on, so run the two with `set -o pipefail`.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go test -json ./... | go run ./tools/gotestsummary
|
||||
// go run ./tools/gotestsummary -slowest 60 test-output.jsonl
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
modulePrefix = "github.com/netbirdio/netbird/"
|
||||
|
||||
// failedTestOutputLines bounds how much captured output a single failed
|
||||
// test may print, so one noisy failure cannot flood the job log.
|
||||
failedTestOutputLines = 200
|
||||
// panicHeadLines is enough for the "panic: test timed out" header, the
|
||||
// "running tests:" list, and the first few goroutines of the dump.
|
||||
panicHeadLines = 150
|
||||
// bufferedOutputLines bounds the per-test output kept in memory while the
|
||||
// test runs; only the tail is kept once the cap is reached.
|
||||
bufferedOutputLines = 400
|
||||
)
|
||||
|
||||
type event struct {
|
||||
Action string `json:"Action"`
|
||||
Package string `json:"Package"`
|
||||
Test string `json:"Test"`
|
||||
Output string `json:"Output"`
|
||||
Elapsed float64 `json:"Elapsed"`
|
||||
// ImportPath is set instead of Package on build events. It carries a
|
||||
// " [pkg.test]" suffix naming the test binary the package was compiled
|
||||
// for, and the same package can be built for several binaries at once.
|
||||
ImportPath string `json:"ImportPath"`
|
||||
// FailedBuild names the ImportPath whose build failure made the package
|
||||
// fail; go test reports the package fail event after the build-fail one.
|
||||
FailedBuild string `json:"FailedBuild"`
|
||||
}
|
||||
|
||||
type testKey struct {
|
||||
pkg, name string
|
||||
}
|
||||
|
||||
type testResult struct {
|
||||
pkg, name string
|
||||
action string
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
type packageResult struct {
|
||||
pkg string
|
||||
action string
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
type summarizer struct {
|
||||
out io.Writer
|
||||
|
||||
output map[testKey][]string
|
||||
dropped map[testKey]int
|
||||
// pkgOutput keeps what a package printed outside any test, which is where
|
||||
// compiler diagnostics of a failed build end up.
|
||||
pkgOutput map[string][]string
|
||||
// failedBuilds holds the ImportPaths whose build failed and has not been
|
||||
// reported through a package fail event yet.
|
||||
failedBuilds map[string]bool
|
||||
tests []testResult
|
||||
packages []packageResult
|
||||
// panics holds the head of a panic per package. Package streams interleave
|
||||
// in a go test -json run, so one package's dump must not swallow another's
|
||||
// output.
|
||||
panics map[string][]string
|
||||
}
|
||||
|
||||
func newSummarizer(out io.Writer) *summarizer {
|
||||
return &summarizer{
|
||||
out: out,
|
||||
output: make(map[testKey][]string),
|
||||
dropped: make(map[testKey]int),
|
||||
pkgOutput: make(map[string][]string),
|
||||
failedBuilds: make(map[string]bool),
|
||||
panics: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
slowest := flag.Int("slowest", 40, "number of slowest top-level tests to list")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(flag.Arg(0), *slowest); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(path string, slowest int) error {
|
||||
in := os.Stdin
|
||||
if path != "" {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
|
||||
s := newSummarizer(os.Stdout)
|
||||
if err := s.consume(in); err != nil {
|
||||
return fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
s.printSummary(slowest)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *summarizer) consume(r io.Reader) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
var ev event
|
||||
if err := json.Unmarshal(line, &ev); err != nil {
|
||||
// Build errors and other non-JSON lines are passed through untouched.
|
||||
fmt.Fprintln(s.out, string(line))
|
||||
continue
|
||||
}
|
||||
s.handle(ev)
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (s *summarizer) handle(ev event) {
|
||||
if ev.Package == "" {
|
||||
// Keep the full path as the key so concurrent builds of one package for
|
||||
// different test binaries do not share, and delete, each other's state.
|
||||
ev.Package = ev.ImportPath
|
||||
}
|
||||
key := testKey{pkg: ev.Package, name: ev.Test}
|
||||
switch ev.Action {
|
||||
case "run":
|
||||
// Register the test even before it prints anything, so a test that
|
||||
// hangs silently still shows up as unfinished.
|
||||
if ev.Test != "" {
|
||||
if _, ok := s.output[key]; !ok {
|
||||
s.output[key] = []string{}
|
||||
}
|
||||
}
|
||||
case "output":
|
||||
s.handleOutput(key, strings.TrimRight(ev.Output, "\n"))
|
||||
case "build-output":
|
||||
// Compiler output may carry several lines per event and is never test
|
||||
// output, so it skips the panic detection.
|
||||
for _, line := range strings.Split(strings.TrimRight(ev.Output, "\n"), "\n") {
|
||||
s.pkgOutput[key.pkg] = appendBounded(s.pkgOutput[key.pkg], line)
|
||||
}
|
||||
case "build-fail":
|
||||
// The package fail event that follows carries FailedBuild and reports
|
||||
// the compiler output; this only remembers the build in case it never
|
||||
// comes.
|
||||
s.failedBuilds[key.pkg] = true
|
||||
case "pass", "fail", "skip":
|
||||
if ev.Test == "" {
|
||||
s.handlePackageResult(ev)
|
||||
return
|
||||
}
|
||||
s.handleTestResult(key, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *summarizer) handleOutput(key testKey, line string) {
|
||||
if strings.HasPrefix(line, "panic: ") || strings.HasPrefix(line, "fatal error: ") {
|
||||
if _, ok := s.panics[key.pkg]; !ok {
|
||||
s.panics[key.pkg] = []string{}
|
||||
}
|
||||
}
|
||||
if head, ok := s.panics[key.pkg]; ok {
|
||||
// The goroutine dump that follows a panic is kept in the panic head only;
|
||||
// letting it flood the per-test buffers would hide the test's own output.
|
||||
if len(head) < panicHeadLines {
|
||||
s.panics[key.pkg] = append(head, line)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if key.name == "" {
|
||||
s.pkgOutput[key.pkg] = appendBounded(s.pkgOutput[key.pkg], line)
|
||||
return
|
||||
}
|
||||
if len(s.output[key]) >= bufferedOutputLines {
|
||||
s.dropped[key]++
|
||||
}
|
||||
s.output[key] = appendBounded(s.output[key], line)
|
||||
}
|
||||
|
||||
// appendBounded keeps the most recent bufferedOutputLines lines.
|
||||
func appendBounded(buf []string, line string) []string {
|
||||
if len(buf) >= bufferedOutputLines {
|
||||
buf = buf[1:]
|
||||
}
|
||||
return append(buf, line)
|
||||
}
|
||||
|
||||
func (s *summarizer) handleTestResult(key testKey, ev event) {
|
||||
elapsed := time.Duration(ev.Elapsed * float64(time.Second))
|
||||
s.tests = append(s.tests, testResult{
|
||||
pkg: ev.Package,
|
||||
name: ev.Test,
|
||||
action: ev.Action,
|
||||
elapsed: elapsed,
|
||||
})
|
||||
|
||||
if !strings.Contains(ev.Test, "/") || ev.Action == "fail" {
|
||||
fmt.Fprintf(s.out, "--- %s: %s.%s (%s)\n", strings.ToUpper(ev.Action), shortPkg(ev.Package), ev.Test, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
if ev.Action == "fail" {
|
||||
s.printTestOutput(key)
|
||||
}
|
||||
delete(s.output, key)
|
||||
delete(s.dropped, key)
|
||||
}
|
||||
|
||||
func (s *summarizer) printTestOutput(key testKey) {
|
||||
lines := s.output[key]
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
skipped := s.dropped[key]
|
||||
if len(lines) > failedTestOutputLines {
|
||||
skipped += len(lines) - failedTestOutputLines
|
||||
lines = lines[len(lines)-failedTestOutputLines:]
|
||||
}
|
||||
if skipped > 0 {
|
||||
fmt.Fprintf(s.out, " ... %d earlier output lines omitted ...\n", skipped)
|
||||
}
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(s.out, " %s\n", l)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *summarizer) handlePackageResult(ev event) {
|
||||
elapsed := time.Duration(ev.Elapsed * float64(time.Second))
|
||||
s.packages = append(s.packages, packageResult{pkg: ev.Package, action: ev.Action, elapsed: elapsed})
|
||||
|
||||
label := "ok "
|
||||
switch ev.Action {
|
||||
case "fail", "build-fail":
|
||||
label = "FAIL"
|
||||
case "skip":
|
||||
label = "skip"
|
||||
}
|
||||
fmt.Fprintf(s.out, "%s %s %s\n", label, shortPkg(ev.Package), elapsed.Round(time.Millisecond))
|
||||
|
||||
if label == "FAIL" {
|
||||
if ev.FailedBuild != "" {
|
||||
// Several test binaries can share one failed dependency, so its
|
||||
// output stays available for the next package that names it.
|
||||
s.printPackageOutput(ev.FailedBuild, "build output of %s")
|
||||
delete(s.failedBuilds, ev.FailedBuild)
|
||||
}
|
||||
s.printPackageOutput(ev.Package, "output of %s outside tests")
|
||||
s.printUnfinished(ev.Package)
|
||||
s.printPanicHead(ev.Package)
|
||||
}
|
||||
delete(s.pkgOutput, ev.Package)
|
||||
delete(s.panics, ev.Package)
|
||||
}
|
||||
|
||||
// printUnclaimedBuildFailures reports the failed builds no package fail event
|
||||
// accounted for, so a compiler error never disappears from the log.
|
||||
func (s *summarizer) printUnclaimedBuildFailures() {
|
||||
var builds []string
|
||||
for b := range s.failedBuilds {
|
||||
builds = append(builds, b)
|
||||
}
|
||||
sort.Strings(builds)
|
||||
for _, b := range builds {
|
||||
fmt.Fprintf(s.out, "FAIL %s [build failed]\n", shortPkg(b))
|
||||
s.printPackageOutput(b, "build output of %s")
|
||||
}
|
||||
}
|
||||
|
||||
// printPackageOutput shows what a failed package printed outside its tests,
|
||||
// or the compiler errors of a failed build, under the given header.
|
||||
func (s *summarizer) printPackageOutput(pkg, header string) {
|
||||
lines := s.pkgOutput[pkg]
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
if len(lines) > failedTestOutputLines {
|
||||
lines = lines[len(lines)-failedTestOutputLines:]
|
||||
}
|
||||
fmt.Fprintf(s.out, "\n==== "+header+" ====\n", shortPkg(pkg))
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(s.out, " %s\n", l)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *summarizer) printPanicHead(pkg string) {
|
||||
head := s.panics[pkg]
|
||||
if len(head) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(s.out, "\n==== panic in %s (first %d lines) ====\n", shortPkg(pkg), len(head))
|
||||
for _, l := range head {
|
||||
fmt.Fprintln(s.out, l)
|
||||
}
|
||||
fmt.Fprintln(s.out, "==== end of panic head ====")
|
||||
fmt.Fprintln(s.out)
|
||||
}
|
||||
|
||||
// printUnfinished names the tests of a failed package that never reported a
|
||||
// result, which is what a timeout leaves behind, and shows their last output.
|
||||
func (s *summarizer) printUnfinished(pkg string) {
|
||||
var keys []testKey
|
||||
for key := range s.output {
|
||||
if key.pkg == pkg && key.name != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i].name < keys[j].name })
|
||||
fmt.Fprintf(s.out, "\n==== tests in %s that did not finish (%d) ====\n", shortPkg(pkg), len(keys))
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(s.out, "--- UNFINISHED: %s.%s\n", shortPkg(key.pkg), key.name)
|
||||
s.printTestOutput(key)
|
||||
delete(s.output, key)
|
||||
delete(s.dropped, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *summarizer) printSummary(slowest int) {
|
||||
s.printUnclaimedBuildFailures()
|
||||
|
||||
fmt.Fprintln(s.out)
|
||||
fmt.Fprintln(s.out, "==== package durations ====")
|
||||
sort.Slice(s.packages, func(i, j int) bool { return s.packages[i].elapsed > s.packages[j].elapsed })
|
||||
for _, p := range s.packages {
|
||||
fmt.Fprintf(s.out, "%9s %-4s %s\n", p.elapsed.Round(time.Millisecond), p.action, shortPkg(p.pkg))
|
||||
}
|
||||
|
||||
var failed []testResult
|
||||
for _, t := range s.tests {
|
||||
if t.action == "fail" {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
fmt.Fprintln(s.out)
|
||||
fmt.Fprintf(s.out, "==== failed tests (%d) ====\n", len(failed))
|
||||
for _, t := range failed {
|
||||
fmt.Fprintf(s.out, "%9s %s.%s\n", t.elapsed.Round(time.Millisecond), shortPkg(t.pkg), t.name)
|
||||
}
|
||||
}
|
||||
|
||||
s.printSlowest("slowest top-level tests", slowest, func(t testResult) bool { return !strings.Contains(t.name, "/") })
|
||||
s.printSlowest("slowest subtests", slowest/2, func(t testResult) bool { return strings.Contains(t.name, "/") })
|
||||
}
|
||||
|
||||
func (s *summarizer) printSlowest(title string, limit int, keep func(testResult) bool) {
|
||||
var tests []testResult
|
||||
for _, t := range s.tests {
|
||||
if keep(t) {
|
||||
tests = append(tests, t)
|
||||
}
|
||||
}
|
||||
if len(tests) == 0 || limit <= 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(tests, func(i, j int) bool { return tests[i].elapsed > tests[j].elapsed })
|
||||
if len(tests) > limit {
|
||||
tests = tests[:limit]
|
||||
}
|
||||
|
||||
fmt.Fprintln(s.out)
|
||||
fmt.Fprintf(s.out, "==== %s (%d) ====\n", title, len(tests))
|
||||
for _, t := range tests {
|
||||
fmt.Fprintf(s.out, "%9s %-4s %s.%s\n", t.elapsed.Round(time.Millisecond), t.action, shortPkg(t.pkg), t.name)
|
||||
}
|
||||
}
|
||||
|
||||
func shortPkg(pkg string) string {
|
||||
pkg, _, _ = strings.Cut(pkg, " [")
|
||||
return strings.TrimPrefix(pkg, modulePrefix)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func feed(t *testing.T, events string) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
s := newSummarizer(&out)
|
||||
if err := s.consume(strings.NewReader(events)); err != nil {
|
||||
t.Fatalf("consume: %v", err)
|
||||
}
|
||||
s.printSummary(10)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestTimeoutReportsUnfinishedTestsAndPanicHead(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"run","Package":"a","Test":"TestHang"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"=== RUN TestHang\n"}
|
||||
{"Action":"run","Package":"a","Test":"TestSilent"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"panic: test timed out after 1s\n"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"\trunning tests:\n"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"\t\tTestHang (1s)\n"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"goroutine 7 [running]:\n"}
|
||||
{"Action":"fail","Package":"a","Elapsed":1.0}
|
||||
`
|
||||
got := feed(t, events)
|
||||
for _, want := range []string{
|
||||
"--- UNFINISHED: a.TestHang",
|
||||
"--- UNFINISHED: a.TestSilent",
|
||||
"==== panic in a (first 4 lines) ====",
|
||||
"\t\tTestHang (1s)",
|
||||
" === RUN TestHang",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, " goroutine 7 [running]:") {
|
||||
t.Errorf("goroutine dump leaked into the test's own output:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicInOnePackageKeepsOtherPackageOutput(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"run","Package":"a","Test":"TestHang"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"panic: test timed out after 1s\n"}
|
||||
{"Action":"run","Package":"b","Test":"TestOther"}
|
||||
{"Action":"output","Package":"b","Test":"TestOther","Output":" other_test.go:9: expected 1, got 2\n"}
|
||||
{"Action":"output","Package":"a","Test":"TestHang","Output":"goroutine 7 [running]:\n"}
|
||||
{"Action":"fail","Package":"b","Test":"TestOther","Elapsed":0.01}
|
||||
{"Action":"fail","Package":"b","Elapsed":0.02}
|
||||
{"Action":"fail","Package":"a","Elapsed":1.0}
|
||||
`
|
||||
got := feed(t, events)
|
||||
if !strings.Contains(got, " other_test.go:9: expected 1, got 2") {
|
||||
t.Errorf("other package's output was swallowed by the panic head:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "panic in b") {
|
||||
t.Errorf("panic head attributed to the wrong package:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "==== panic in a (first 2 lines) ====") {
|
||||
t.Errorf("panic head missing for package a:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFailureShowsCompilerOutput(t *testing.T) {
|
||||
// The event sequence go test emits for a build failure: the build events
|
||||
// name the test binary, then the package itself fails with FailedBuild.
|
||||
events := `
|
||||
{"Action":"build-output","ImportPath":"a [a.test]","Output":"# a [a.test]\na_test.go:7:2: undefined: nope\na_test.go:9:2: undefined: nope2\n"}
|
||||
{"Action":"build-fail","ImportPath":"a [a.test]"}
|
||||
{"Action":"start","Package":"a"}
|
||||
{"Action":"output","Package":"a","Output":"FAIL\ta [build failed]\n"}
|
||||
{"Action":"fail","Package":"a","Elapsed":0,"FailedBuild":"a [a.test]"}
|
||||
`
|
||||
got := feed(t, events)
|
||||
for _, want := range []string{
|
||||
"==== build output of a ====",
|
||||
" a_test.go:7:2: undefined: nope\n a_test.go:9:2: undefined: nope2",
|
||||
"FAIL\ta [build failed]",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(got, "FAIL a 0s"); n != 1 {
|
||||
t.Errorf("expected one FAIL line for the package, got %d:\n%s", n, got)
|
||||
}
|
||||
if n := strings.Count(got, "undefined: nope2"); n != 1 {
|
||||
t.Errorf("expected the compiler output once, got %d:\n%s", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedDependencyOutputIsShownForEveryImporter(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"build-output","ImportPath":"m/x","Output":"# m/x\nx.go:3:11: undefined: y\n"}
|
||||
{"Action":"build-fail","ImportPath":"m/x"}
|
||||
{"Action":"start","Package":"m/a"}
|
||||
{"Action":"output","Package":"m/a","Output":"FAIL\tm/a [build failed]\n"}
|
||||
{"Action":"fail","Package":"m/a","Elapsed":0,"FailedBuild":"m/x"}
|
||||
{"Action":"start","Package":"m/b"}
|
||||
{"Action":"output","Package":"m/b","Output":"FAIL\tm/b [build failed]\n"}
|
||||
{"Action":"fail","Package":"m/b","Elapsed":0,"FailedBuild":"m/x"}
|
||||
`
|
||||
got := feed(t, events)
|
||||
if n := strings.Count(got, "x.go:3:11: undefined: y"); n != 2 {
|
||||
t.Errorf("expected the dependency's compiler output under both packages, got %d:\n%s", n, got)
|
||||
}
|
||||
if strings.Contains(got, "FAIL m/x") {
|
||||
t.Errorf("the dependency must not be reported as a package of its own:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFailureWithoutPackageEventIsStillReported(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"build-output","ImportPath":"a [a.test]","Output":"a_test.go:7:2: undefined: nope\n"}
|
||||
{"Action":"build-fail","ImportPath":"a [a.test]"}
|
||||
`
|
||||
got := feed(t, events)
|
||||
for _, want := range []string{"FAIL a [build failed]", "==== build output of a ====", "undefined: nope"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilerPanicIsBuildOutputNotTestPanic(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"build-output","ImportPath":"a [a.test]","Output":"# a [a.test]\npanic: internal compiler error\n\ngoroutine 1 [running]:\n"}
|
||||
{"Action":"build-fail","ImportPath":"a [a.test]"}
|
||||
{"Action":"start","Package":"a"}
|
||||
{"Action":"output","Package":"a","Output":"FAIL\ta [build failed]\n"}
|
||||
{"Action":"fail","Package":"a","Elapsed":0,"FailedBuild":"a [a.test]"}
|
||||
`
|
||||
got := feed(t, events)
|
||||
if !strings.Contains(got, "==== build output of a ====\n # a [a.test]\n panic: internal compiler error") {
|
||||
t.Errorf("compiler diagnostic missing from the build output block:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "==== panic in") {
|
||||
t.Errorf("compiler output must not be reported as a test panic:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVariantsOfOnePackageKeepSeparateOutput(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"build-output","ImportPath":"a [a.test]","Output":"a.go:1:1: broken for a.test\n"}
|
||||
{"Action":"build-output","ImportPath":"a [b.test]","Output":"a.go:1:1: broken for b.test\n"}
|
||||
{"Action":"build-fail","ImportPath":"a [a.test]"}
|
||||
{"Action":"build-fail","ImportPath":"a [b.test]"}
|
||||
{"Action":"start","Package":"a"}
|
||||
{"Action":"fail","Package":"a","Elapsed":0,"FailedBuild":"a [a.test]"}
|
||||
{"Action":"start","Package":"b"}
|
||||
{"Action":"fail","Package":"b","Elapsed":0,"FailedBuild":"a [b.test]"}
|
||||
`
|
||||
got := feed(t, events)
|
||||
if strings.Count(got, "==== build output of a ====") != 2 {
|
||||
t.Errorf("expected one output block per build variant:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"broken for a.test", "broken for b.test"} {
|
||||
if strings.Count(got, want) != 1 {
|
||||
t.Errorf("expected %q exactly once:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassingPackageOutputIsNotPrinted(t *testing.T) {
|
||||
events := `
|
||||
{"Action":"output","Package":"a","Output":"level=info msg=\"noise between tests\"\n"}
|
||||
{"Action":"pass","Package":"a","Elapsed":0.5}
|
||||
`
|
||||
got := feed(t, events)
|
||||
if strings.Contains(got, "noise between tests") {
|
||||
t.Errorf("package output of a passing package should stay quiet:\n%s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user