mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-08 16:51:29 +02:00
Compare commits
4 Commits
v0.76.3
...
feature/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081f2d153b | ||
|
|
90bb5e7c0f | ||
|
|
1381dcf919 | ||
|
|
7ea0882975 |
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,8 +16,6 @@ jobs:
|
||||
const allowedTags = [
|
||||
'management',
|
||||
'client',
|
||||
'android',
|
||||
'ios',
|
||||
'signal',
|
||||
'proxy',
|
||||
'relay',
|
||||
|
||||
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -475,132 +475,6 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_gtk3:
|
||||
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
|
||||
# the binary links against the oldest supported glibc.
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
# Restore-only from the release_ui cache written by trusted runs; the
|
||||
# module cache is identical (same go.sum) and stale build-cache
|
||||
# entries just miss.
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-go-releaser-
|
||||
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: Decode GPG signing key
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
|
||||
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||
rpm --import /tmp/rpm-pub.key
|
||||
echo "=== Verifying RPM signatures ==="
|
||||
for rpm_file in /dist/*.rpm; do
|
||||
[ -f "$rpm_file" ] || continue
|
||||
echo "--- $(basename $rpm_file) ---"
|
||||
rpm -K "$rpm_file"
|
||||
done
|
||||
'
|
||||
- name: Clean up GPG key
|
||||
if: always()
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release_ui_gtk3
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: release-ui-gtk3
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_darwin:
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
@@ -814,7 +688,7 @@ jobs:
|
||||
comment_release_artifacts:
|
||||
name: Comment release artifacts
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
|
||||
needs: [release, release_ui, release_ui_darwin]
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -826,14 +700,12 @@ jobs:
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
|
||||
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
|
||||
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||
with:
|
||||
@@ -856,7 +728,6 @@ jobs:
|
||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
|
||||
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||
];
|
||||
|
||||
@@ -913,7 +784,7 @@ jobs:
|
||||
|
||||
trigger_signer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
|
||||
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
|
||||
@@ -257,15 +257,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify fresh-install session cookie key hardening
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
|
||||
@@ -93,9 +93,7 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- netbird
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -116,9 +114,7 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- netbird
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
version: 2
|
||||
env:
|
||||
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
|
||||
project_name: netbird-ui
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# Bindings are gitignored; regenerate before the frontend build so
|
||||
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
|
||||
# build without them).
|
||||
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
|
||||
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
|
||||
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||
|
||||
builds:
|
||||
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
|
||||
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
|
||||
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
|
||||
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
|
||||
- id: netbird-ui-gtk3
|
||||
dir: client/ui
|
||||
binary: netbird-ui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
- gtk3
|
||||
|
||||
archives:
|
||||
- id: linux-gtk3-arch
|
||||
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
|
||||
nfpms:
|
||||
# Same package_name as the GTK4 packages -- the two are mutually-exclusive
|
||||
# alternatives served from separate repo paths (see uploads below); a given
|
||||
# distro points at exactly one of them. The file names must still differ:
|
||||
# the Debian pool is shared storage keyed by file name, so a default-named
|
||||
# gtk3 .deb would overwrite the stable one.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- deb
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
- rpm
|
||||
scripts:
|
||||
postinstall: "release_files/ui-post-install.sh"
|
||||
contents:
|
||||
- src: client/ui/build/linux/netbird.desktop
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk3 or libgtk-3-0)
|
||||
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||
|
||||
# The GTK4 UI job shares project_name, so the default checksum file name would
|
||||
# collide with it on the shared GitHub release.
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
uploads:
|
||||
# The gtk3 packages reuse the netbird-ui package name, so they live in
|
||||
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
|
||||
# legacy distros point their repo config at.
|
||||
- name: debian-gtk3
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
317
AGENTS.md
317
AGENTS.md
@@ -14,22 +14,20 @@ in this file, not duplicated there.
|
||||
|
||||
## Contents
|
||||
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Security](#security)
|
||||
- [Agent conventions](#agent-conventions)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Type safety](#type-safety)
|
||||
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
|
||||
- [Contents](#contents)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
|
||||
## STOP and ask the user before
|
||||
|
||||
@@ -159,125 +157,11 @@ netbird/
|
||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||
| End-to-end tests | `e2e/` |
|
||||
|
||||
## Security
|
||||
|
||||
### Never fail open
|
||||
|
||||
When a security check — access control, an IP restriction, an auth decision —
|
||||
hits an error such as an unparseable value, an unavailable lookup, or a state it
|
||||
does not recognize, it must **deny**. Never skip the check or allow the request
|
||||
through because the check itself failed, and make the `default` and unknown cases
|
||||
of a security-related `switch` deny rather than fall through.
|
||||
|
||||
### Daemon RPC input is untrusted
|
||||
|
||||
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
|
||||
privilege boundary: treat every field as untrusted input rather than as something
|
||||
the UI or CLI validated on the way in.
|
||||
|
||||
When you add or change an RPC, ask what the handler does with caller input while
|
||||
running as root. If the answer touches a filesystem path, a URL or host, or a
|
||||
privileged state change, it needs a gate **in the handler** — a check in the client
|
||||
that normally calls it is not a check at all.
|
||||
|
||||
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
|
||||
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
|
||||
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
|
||||
not own — so a symlink or hardlink aimed at a root-only file is rejected.
|
||||
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
|
||||
allow only known hosts for unprivileged callers. Prefer a lexical host
|
||||
allowlist plus TLS verification over "resolve the host, then reject private
|
||||
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
|
||||
check time, attacker IP at connect time), while a name allowlist has no IP
|
||||
check to race. Never accept `http://` where `https://` is expected.
|
||||
- **A privileged state change** (SSH root login, management URL, deregistration)
|
||||
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
|
||||
|
||||
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
|
||||
named-pipe client token — and never from an RPC field. When
|
||||
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
|
||||
do not fall back to treating the caller as the transport peer.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
### Three networking modes
|
||||
|
||||
Where packets actually flow depends on the mode the agent is running in. The
|
||||
three are not interchangeable, so establish which one a change applies to — and
|
||||
what it should do in the other two — before you write it.
|
||||
|
||||
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
|
||||
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
|
||||
client programs kernel facilities but never sees the traffic itself.
|
||||
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
|
||||
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
|
||||
— exit nodes and network routes — goes through the userspace forwarder, which
|
||||
terminates the connection and re-establishes it over OS sockets. Used on
|
||||
platforms without kernel WireGuard® or when the user opts out.
|
||||
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
|
||||
networking. The forwarder does all routing by stitching userspace sockets, and
|
||||
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
|
||||
Used where the process cannot create a TUN device, such as the embedded client
|
||||
(`client/embed/`) and the WASM build.
|
||||
|
||||
### The overlay interface is not "WireGuard"
|
||||
|
||||
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
|
||||
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
|
||||
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
|
||||
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
|
||||
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
|
||||
reference there is simply inaccurate and rots as the transports change.
|
||||
|
||||
### IPv6 is a soft feature
|
||||
|
||||
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
|
||||
it as soft rather than a requirement:
|
||||
|
||||
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
|
||||
not on raw state fields, and skip the v6 path when the host has no v6 rather
|
||||
than returning an error.
|
||||
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
|
||||
it, keeping the v4 path working.
|
||||
- Never let a missing v6 break v4. Fail-closed is for security checks; a
|
||||
capability mismatch skips the v6 work and carries on.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
|
||||
than comparing strings inline, so an unexpected value is logged instead of
|
||||
silently meaning false:
|
||||
|
||||
```go
|
||||
const EnvDisableFeature = "NB_DISABLE_FEATURE"
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
val := os.Getenv(EnvDisableFeature)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Validating against protocol specs
|
||||
|
||||
When a change depends on what a protocol actually mandates, read the specification
|
||||
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
|
||||
summary, and check that you have the current RFC — the widely cited one for a
|
||||
protocol is often superseded. Cite the section, not just the document, so a
|
||||
reviewer can jump straight to the rule.
|
||||
|
||||
## Repo-wide principles
|
||||
|
||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
|
||||
touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
Exception: unused parameters in shared code may be consumed by builds outside
|
||||
this repository — do not remove them, ask instead.
|
||||
3. **Function comments are mandatory for exported functions**, written as full
|
||||
@@ -291,12 +175,9 @@ reviewer can jump straight to the rule.
|
||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||
comments, commit messages, and PR descriptions alike.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||
shared state, including reads of existing maps and slices. Guard them with a
|
||||
mutex (or an atomic or channel where that fits better), keep critical
|
||||
sections short, and run `go test -race` on the touched packages. See
|
||||
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
|
||||
to check for.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that adds
|
||||
shared state. Guard maps and slices with a mutex, keep critical sections
|
||||
short, and run `go test -race` on the touched packages.
|
||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||
add the counterpart or a build-tagged fallback for the others.
|
||||
@@ -304,93 +185,6 @@ reviewer can jump straight to the rule.
|
||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||
keep peer IPs and hostnames out of logs above debug level.
|
||||
|
||||
## Type safety
|
||||
|
||||
**No bare primitives for domain concepts.** A `string` parameter for an account
|
||||
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
|
||||
because the compiler cannot catch the swap. Declare the type once and use it
|
||||
throughout, converting only at the boundaries where data enters or leaves —
|
||||
protobuf, gRPC, HTTP, an external library.
|
||||
|
||||
```go
|
||||
type ServiceID string
|
||||
type AccountID string
|
||||
|
||||
// Internal: typed all the way through
|
||||
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
|
||||
|
||||
// Proto boundary: convert once, on the way in and on the way out
|
||||
svcID := ServiceID(mapping.GetId())
|
||||
req.ServiceId = string(svcID)
|
||||
```
|
||||
|
||||
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
|
||||
boundary and pass the typed value inward.
|
||||
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
|
||||
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
|
||||
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
|
||||
compared mapped address silently fails to match those rules.
|
||||
- **Ports are `uint16`** internally; use `int` only where a library forces it and
|
||||
convert immediately.
|
||||
- **Enums are a typed string with constants**, so the valid set is discoverable
|
||||
and a typo fails to compile.
|
||||
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
|
||||
string`) rather than an alias (`type serviceID = string`) — an alias silently
|
||||
accepts bare strings.
|
||||
|
||||
## Concurrency and lifecycle
|
||||
|
||||
Beyond the mutex hygiene in the principles above, check for these failure
|
||||
modes.
|
||||
|
||||
- **Never read a struct field inside a goroutine** when another goroutine may nil
|
||||
or reassign it. Pass the value as a parameter, or capture it into a local before
|
||||
launching. This matters most when `Stop()` nils a field without waiting for the
|
||||
goroutine to finish.
|
||||
|
||||
```go
|
||||
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
|
||||
m.Start(ctx, ifaceName)
|
||||
}(iface.Name())
|
||||
```
|
||||
|
||||
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
|
||||
need out from under the lock, release it, then wait.
|
||||
|
||||
```go
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel, done := m.cancel, m.done
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
|
||||
nil cancel — and must release the state they guarded. Clear maps and caches;
|
||||
a cancelled goroutine holding a live map still pins that memory. Note that a
|
||||
nil map only panics on writes; reads and iteration behave like an empty map,
|
||||
so where post-close use must be rejected, check the stopped flag explicitly.
|
||||
- **Publish coupled state only after every fallible step succeeds.** When several
|
||||
fields form an invariant, build them into locals and assign them to the receiver
|
||||
at the end. Assigning as you go leaves the object half-initialized when a later
|
||||
step fails, so a readiness predicate reports ready while a coupled field is nil.
|
||||
If an earlier step already had an external side effect — a created chain, an
|
||||
opened handle, an inserted rule — roll it back before returning the error.
|
||||
- **Clean up what you own on constructor error paths.** Once a constructor has
|
||||
started something, every later error path must undo it: cancel a goroutine and
|
||||
wait for it to exit, stop a ticker, close a watcher. The object is never
|
||||
returned, so its `Close` will never run.
|
||||
- **A failed `Start` must undo everything it started.** When a component brings up
|
||||
several subsystems in sequence — connection manager, watchers, routing, DNS,
|
||||
flow, persisted state — a failure partway through has to tear down the ones
|
||||
already running, not just close the handle the error came from. Put the
|
||||
already-started guard *before* that teardown path, so a rejected second `Start`
|
||||
cannot dismantle the one that is running.
|
||||
|
||||
## Error handling
|
||||
|
||||
Use single-assignment form when the error is only needed inside the `if`:
|
||||
@@ -454,45 +248,6 @@ Log the errors you choose not to act on:
|
||||
- Close errors may be ignored for read-only operations; log them at debug for
|
||||
writes.
|
||||
|
||||
**Do not log and return the same error.** It gets reported twice, from two places,
|
||||
and the second reader cannot tell whether it happened once or twice. Return it and
|
||||
let the caller decide. The exception is an API handler that has already written a
|
||||
response. Internal helpers return errors rather than logging and swallowing them.
|
||||
|
||||
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
|
||||
interface is not nil, so `err != nil` is true and callers take the failure path on
|
||||
success. Return the error only where it is actually set:
|
||||
|
||||
```go
|
||||
if _, err := conn.Write(buf); err != nil { // good
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
**Accumulate with `multierror` when an operation should continue past individual
|
||||
failures** — teardown, cleanup, or setup where partial success is acceptable.
|
||||
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
|
||||
still see a plain nil on full success:
|
||||
|
||||
```go
|
||||
func (m *Manager) Cleanup() error {
|
||||
var merr *multierror.Error
|
||||
for _, r := range m.resources {
|
||||
if err := r.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
```
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| --------------------- | --------------------- | ----------------------------------------- |
|
||||
| Cleanup / teardown | Accumulate | Clean up as much as possible |
|
||||
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
|
||||
| Setup with partial OK | Accumulate | Degraded operation is still useful |
|
||||
|
||||
## Comments
|
||||
|
||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||
@@ -514,14 +269,10 @@ checksum = updateChecksum(checksum, oldPort, newPort)
|
||||
|
||||
### Length budget
|
||||
|
||||
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||
mostly follows rather than hard limits:
|
||||
|
||||
- **Around 90 characters per line.** Wrap the comment rather than running well past
|
||||
it.
|
||||
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
|
||||
on exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body rarely should.
|
||||
- **90 characters per line.** Wrap the comment, do not run past it.
|
||||
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
|
||||
exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body may not.
|
||||
|
||||
The budget is a smell detector, not a rule to game. Do not compress a needed
|
||||
explanation into cryptic shorthand to fit — if a block of code needs more than
|
||||
@@ -578,19 +329,6 @@ up, and the 250-character budget does not apply to them.
|
||||
otherwise.
|
||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||
comparison, boolean, and collection assertions.
|
||||
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
|
||||
reason you expect* — a test that fails for an unrelated reason proves nothing —
|
||||
then apply the fix and confirm it passes. Add the thin surrounding cases while
|
||||
you are there.
|
||||
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
|
||||
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
|
||||
then `os.Unsetenv`.
|
||||
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
|
||||
parent function returns, running its `defer`s, while parallel subtests are
|
||||
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
|
||||
there, but `t.Cleanup` works in both cases.
|
||||
- **Explanatory comments in tests are welcome.** Describe the scenario being set
|
||||
up; the comment budget below does not apply to them.
|
||||
|
||||
```go
|
||||
server, err := StartTestServer()
|
||||
@@ -642,8 +380,7 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
than replacing it with your own summary: describe the change, link the issue,
|
||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||
and complete the documentation section. Do not tick a box you have not
|
||||
verified, and do not delete rows that do not apply — the docs gate in CI reads
|
||||
that section and fails when it is missing.
|
||||
verified, and do not delete rows that do not apply.
|
||||
|
||||
- **Keep the PR description short.** Under 1000 words on top of the template's
|
||||
own text, and usually far less — a few paragraphs. Reviewers read the diff;
|
||||
@@ -702,12 +439,6 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
||||
on their own. Propose that split to the user rather than opening one large PR
|
||||
and hoping.
|
||||
|
||||
Prefer GitHub's stacked pull requests for such a sequence, rather than
|
||||
hand-managing base branches: open each PR against the branch below it instead of
|
||||
`main`, so every PR's diff shows only its own change. Merging a layer retargets
|
||||
the PRs above it, and branch protections and required checks on the base branch
|
||||
still apply to each one.
|
||||
|
||||
- **User-facing changes need a docs PR** in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||
description.
|
||||
|
||||
@@ -57,12 +57,6 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -82,8 +76,6 @@ type Client struct {
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||
cfgPath string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
@@ -104,12 +96,11 @@ type Client struct {
|
||||
extendCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
c.config = cfg
|
||||
c.cacheDir = cacheDir
|
||||
c.cfgPath = cfgPath
|
||||
c.connectClient = cc
|
||||
}
|
||||
|
||||
@@ -119,16 +110,6 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
||||
return c.config, c.cacheDir, c.connectClient
|
||||
}
|
||||
|
||||
// authSnapshot returns the config together with the path it was loaded from, in
|
||||
// one lock: the path identifies the profile whose account email backs the login
|
||||
// hint, so reading it separately could pair one profile's config with another's
|
||||
// hint when a profile switch lands in between.
|
||||
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
return c.config, c.cfgPath, c.connectClient
|
||||
}
|
||||
|
||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
@@ -181,7 +162,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
|
||||
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||
auth := NewAuthWithConfig(ctx, cfg)
|
||||
err = auth.login(urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -189,7 +170,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -230,7 +211,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -259,24 +240,6 @@ func (c *Client) RenewTun(fd int) error {
|
||||
return e.RenewTun(fd)
|
||||
}
|
||||
|
||||
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return nil, fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
routes, searchDomains := e.TunSettings()
|
||||
return &TunSettings{
|
||||
Routes: strings.Join(routes, ";"),
|
||||
SearchDomains: strings.Join(searchDomains, ";"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -38,20 +36,12 @@ type Auth struct {
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
//
|
||||
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||
// management stream rejects it with "no peer auth method provided".
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -63,14 +53,11 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||
// file the config was loaded from; it identifies the profile whose account email
|
||||
// backs the login_hint.
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
cfgPath: cfgPath,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +150,12 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||
@@ -178,42 +163,17 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
||||
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
||||
// way internal/auth wires it in authenticateWithPKCEFlow.
|
||||
type loginHintSetter interface {
|
||||
SetLoginHint(hint string)
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||
// leaves the choice to the IdP, which is how accounts get switched.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||
//
|
||||
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("first NewAuth: %v", err)
|
||||
}
|
||||
if first.config.PrivateKey == "" {
|
||||
t.Fatal("first NewAuth produced no private key")
|
||||
}
|
||||
|
||||
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("second NewAuth: %v", err)
|
||||
}
|
||||
|
||||
if second.config.PrivateKey != first.config.PrivateKey {
|
||||
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuth: %v", err)
|
||||
}
|
||||
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||
t.Fatal("NewAuth did not create a usable configuration")
|
||||
}
|
||||
if auth.cfgPath != cfgPath {
|
||||
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login or was logged out. See profile_state.go.
|
||||
Email string
|
||||
ID string
|
||||
Name string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -100,7 +101,6 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
@@ -123,22 +123,7 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||
// an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(configPath)
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
@@ -200,11 +185,6 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on Android both resolve under files/, so sharing the name
|
||||
// would have the two overwrite each other — the state manager rewrites the
|
||||
// whole file from its own keys (see statemanager.Manager.PersistState), and
|
||||
// this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: Auth.login runs in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// readProfileEmail returns the account email stored for the profile whose config
|
||||
// lives at configPath. A missing or unreadable file yields "", which leaves the
|
||||
// account choice to the IdP.
|
||||
func readProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// writeProfileEmail records the account email for the profile whose config lives
|
||||
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
|
||||
// email is ignored rather than blanking what is already stored.
|
||||
func writeProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on logout: while the
|
||||
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||
// straight back into the account just logged out of. Mirrors the desktop UI's
|
||||
// RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
|
||||
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on Android both
|
||||
// resolve under files/, and the state manager rewrites the whole file from its
|
||||
// own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/data/io.netbird.client/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after logout, got %q", got)
|
||||
}
|
||||
|
||||
// Logout may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := writeProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := writeProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := readProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
@@ -293,10 +293,7 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
// Passing the config path makes the flow pick up the login_hint: an extend
|
||||
// renews the session of the account already signed in, so it must not stop to
|
||||
// offer a choice.
|
||||
a := NewAuthWithConfig(ctx, cfg, cfgPath)
|
||||
a := &Auth{ctx: ctx, config: cfg}
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||
// versions, which returns EPERM to any other process that tries to insert
|
||||
// rules into them. Trusting the interface makes firewalld itself add the
|
||||
// accept rules to its own chains instead.
|
||||
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
||||
// itself add the accept rules to its own chains by trusting the interface.
|
||||
package firewalld
|
||||
|
||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||
|
||||
@@ -113,14 +113,11 @@ func (c *ConnectClient) RunOnAndroid(
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
) error {
|
||||
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||
defer notifier.Close()
|
||||
|
||||
// in case of non Android os these variables will be nil
|
||||
mobileDependency := MobileDependency{
|
||||
TunAdapter: tunAdapter,
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: notifier,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
StateFilePath: stateFilePath,
|
||||
|
||||
@@ -51,5 +51,7 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
n.listener.OnNetworkChanged("")
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,12 +602,6 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -692,7 +686,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
|
||||
@@ -572,7 +572,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
dnsServer, err := e.newDnsServer()
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dns server: %w", err)
|
||||
}
|
||||
@@ -590,8 +595,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
WGInterface: e.wgInterface,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||
@@ -2095,6 +2102,42 @@ func (e *Engine) close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
if runtime.GOOS != "android" {
|
||||
// nolint:nilnil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
info := system.GetInfo(e.ctx)
|
||||
info.SetFlags(
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
e.config.DisableFirewall,
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
)
|
||||
|
||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
routes := toRoutes(netMap.GetRoutes())
|
||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2129,7 +2172,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
case "ios":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2142,7 +2185,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
// due to tests where we are using a mocked version of the DNS server
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2154,7 +2197,7 @@ func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
nbdns.Config{},
|
||||
*dnsConfig,
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
e.config.DisableDNS,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package internal
|
||||
|
||||
func (e *Engine) TunSettings() ([]string, []string) {
|
||||
e.syncMsgMux.Lock()
|
||||
routeManager := e.routeManager
|
||||
dnsServer := e.dnsServer
|
||||
e.syncMsgMux.Unlock()
|
||||
|
||||
var routes []string
|
||||
if routeManager != nil {
|
||||
routes = routeManager.CurrentRouteRange()
|
||||
}
|
||||
|
||||
var searchDomains []string
|
||||
if dnsServer != nil {
|
||||
searchDomains = dnsServer.SearchDomains()
|
||||
}
|
||||
|
||||
return routes, searchDomains
|
||||
}
|
||||
@@ -4,17 +4,11 @@ package metrics
|
||||
type ConnectionType string
|
||||
|
||||
const (
|
||||
// ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICEP2P ConnectionType = "ice_p2p"
|
||||
|
||||
// ConnectionTypeICETurn represents an ICE connection through a TURN server
|
||||
ConnectionTypeICETurn ConnectionType = "ice_turn"
|
||||
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICE ConnectionType = "ice"
|
||||
|
||||
// ConnectionTypeRelay represents a relayed connection
|
||||
ConnectionTypeRelay ConnectionType = "relay"
|
||||
|
||||
// ConnectionTypeUnknown represents a connection with no active transport. It is not pushed.
|
||||
ConnectionTypeUnknown ConnectionType = "unknown"
|
||||
)
|
||||
|
||||
// String returns the string representation of the connection type
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) {
|
||||
WgHandshakeSuccess: time.Now().Add(-1 * time.Second),
|
||||
}
|
||||
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := m.Export(&buf)
|
||||
@@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) {
|
||||
|
||||
// Record multiple times and verify consistent field order
|
||||
for i := 0; i < 10; i++ {
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts)
|
||||
m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -56,33 +56,14 @@ Measurement: `netbird_peer_connection`
|
||||
|
||||
Tags:
|
||||
- `deployment_type`: "cloud" | "selfhosted" | "unknown"
|
||||
- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below)
|
||||
- `connection_type`: "ice" | "relay"
|
||||
- `attempt_type`: "initial" | "reconnection"
|
||||
- `version`: NetBird version string
|
||||
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
|
||||
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
|
||||
|
||||
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
|
||||
|
||||
#### `connection_type` values
|
||||
|
||||
Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`:
|
||||
|
||||
| Value | Priority | Traffic is |
|
||||
|-------|----------|------------|
|
||||
| `ice_p2p` | `ICEP2P` | direct peer-to-peer |
|
||||
| `ice_turn` | `ICETurn` | relayed, through a TURN server |
|
||||
| `relay` | `Relay` | relayed, through a NetBird relay |
|
||||
| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** |
|
||||
|
||||
**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`.
|
||||
|
||||
`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time.
|
||||
|
||||
**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`.
|
||||
|
||||
### Sync Duration
|
||||
|
||||
Measurement: `netbird_sync`
|
||||
|
||||
@@ -307,8 +307,6 @@ func (conn *Conn) Close(signalToRemote bool) {
|
||||
|
||||
if conn.wgWatcherCancel != nil {
|
||||
conn.wgWatcherCancel()
|
||||
conn.wgWatcher = nil
|
||||
conn.wgWatcherCancel = nil
|
||||
}
|
||||
conn.workerRelay.CloseConn()
|
||||
if conn.workerICE != nil {
|
||||
@@ -961,9 +959,12 @@ func (conn *Conn) recordConnectionMetrics() {
|
||||
priority := conn.currentConnPriority
|
||||
conn.mu.Unlock()
|
||||
|
||||
connType := metricsConnType(priority)
|
||||
if connType == metrics.ConnectionTypeUnknown {
|
||||
return
|
||||
var connType metrics.ConnectionType
|
||||
switch priority {
|
||||
case conntype.Relay:
|
||||
connType = metrics.ConnectionTypeRelay
|
||||
default:
|
||||
connType = metrics.ConnectionTypeICE
|
||||
}
|
||||
|
||||
// Record metrics with timestamps - duration calculation happens in metrics package
|
||||
@@ -1064,16 +1065,3 @@ func boolToConnStatus(connected bool) guard.ConnStatus {
|
||||
}
|
||||
return guard.ConnStatusDisconnected
|
||||
}
|
||||
|
||||
func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType {
|
||||
switch priority {
|
||||
case conntype.Relay:
|
||||
return metrics.ConnectionTypeRelay
|
||||
case conntype.ICETurn:
|
||||
return metrics.ConnectionTypeICETurn
|
||||
case conntype.ICEP2P:
|
||||
return metrics.ConnectionTypeICEP2P
|
||||
default:
|
||||
return metrics.ConnectionTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
@@ -388,33 +386,3 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
|
||||
}
|
||||
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
|
||||
}
|
||||
|
||||
func TestMetricsConnType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
priority conntype.ConnPriority
|
||||
expected metrics.ConnectionType
|
||||
}{
|
||||
{"relay", conntype.Relay, metrics.ConnectionTypeRelay},
|
||||
{"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn},
|
||||
{"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P},
|
||||
{"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown},
|
||||
{"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, metricsConnType(tc.priority))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) {
|
||||
for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} {
|
||||
conn := &Conn{currentConnPriority: priority}
|
||||
tag := metricsConnType(priority)
|
||||
relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn
|
||||
assert.Equal(t, conn.isRelayed(), relayedTag,
|
||||
"priority %s: isRelayed and the %q metric tag must agree", priority, tag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,35 +45,12 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// SetProfileState writes the state file of the profile identified by id. Prefer
|
||||
// it over SetActiveProfileState whenever the caller knows which profile the data
|
||||
// belongs to: an SSO login spans seconds of user interaction, and the active
|
||||
// profile can change during it, which would file the account email under
|
||||
// whichever profile happened to be active when the flow returned.
|
||||
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
return fmt.Errorf("empty profile ID")
|
||||
}
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActiveProfileState writes the state file of whichever profile is active at
|
||||
// call time. Use SetProfileState when the target profile is known.
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoActiveProfile) {
|
||||
@@ -82,7 +59,18 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
return fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
return pm.SetProfileState(activeProf.ID, state)
|
||||
id := activeProf.ID
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid active profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
|
||||
@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
|
||||
|
||||
// internalDnatFw checks if the firewall supports internal DNAT
|
||||
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
|
||||
if d.firewall == nil || runtime.GOOS != "android" {
|
||||
return nil, false
|
||||
}
|
||||
fw, ok := d.firewall.(internalDNATer)
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -61,7 +62,7 @@ type Manager interface {
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
CurrentRouteRange() []string
|
||||
InitialRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
@@ -75,8 +76,10 @@ type ManagerConfig struct {
|
||||
WGInterface iface.WGIface
|
||||
StatusRecorder *peer.Status
|
||||
RelayManager *relayClient.Manager
|
||||
InitialRoutes []*route.Route
|
||||
StateManager *statemanager.Manager
|
||||
DNSServer dns.Server
|
||||
DNSFeatureFlag bool
|
||||
PeerStore *peerstore.Store
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -146,12 +149,45 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||
dm.setupRefCounters(useNoop)
|
||||
|
||||
// don't proceed with client routes if it is disabled
|
||||
if config.DisableClientRoutes {
|
||||
return dm
|
||||
}
|
||||
|
||||
if runtime.GOOS == "android" {
|
||||
dm.setupAndroidRoutes(config)
|
||||
}
|
||||
return dm
|
||||
}
|
||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
||||
|
||||
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
m.notifier.NotifyRouteChange()
|
||||
routesForComparison := slices.Clone(cr)
|
||||
|
||||
if config.DNSFeatureFlag {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
|
||||
v4ID := uuid.NewString()
|
||||
fakeIPRoute := &route.Route{
|
||||
ID: route.ID(v4ID),
|
||||
Network: m.fakeIPManager.GetFakeIPBlock(),
|
||||
NetID: route.NetID(v4ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv4Network,
|
||||
}
|
||||
v6ID := uuid.NewString()
|
||||
fakeIPv6Route := &route.Route{
|
||||
ID: route.ID(v6ID),
|
||||
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
||||
NetID: route.NetID(v6ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv6Network,
|
||||
}
|
||||
cr = append(cr, fakeIPRoute, fakeIPv6Route)
|
||||
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
|
||||
}
|
||||
|
||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
@@ -428,9 +464,6 @@ func (m *DefaultManager) UpdateRoutes(
|
||||
|
||||
var merr *multierror.Error
|
||||
if !m.disableClientRoutes {
|
||||
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||
m.enableFakeIPRoutes()
|
||||
}
|
||||
|
||||
// Update route selector based on management server's isSelected status
|
||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||
@@ -467,32 +500,9 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
||||
m.notifier.SetListener(listener)
|
||||
}
|
||||
|
||||
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
||||
func (m *DefaultManager) InitialRouteRange() []string {
|
||||
return m.notifier.GetInitialRouteRanges()
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -690,6 +700,16 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
||||
return newServerRoutesMap, newClientRoutesIDMap
|
||||
}
|
||||
|
||||
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
||||
_, crMap := m.ClassifyRoutes(initialRoutes)
|
||||
rs := make([]*route.Route, 0, len(crMap))
|
||||
for _, routes := range crMap {
|
||||
rs = append(rs, routes...)
|
||||
}
|
||||
|
||||
return rs
|
||||
}
|
||||
|
||||
func isRouteSupported(route *route.Route) bool {
|
||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||
func (m *MockManager) CurrentRouteRange() []string {
|
||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
||||
func (m *MockManager) InitialRouteRange() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -13,15 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// currentRoutes is the last announced route set. It exists only to
|
||||
// suppress noise: without it every network map sync would trigger the
|
||||
// Java side, even when the routes did not change. The actual TUN route
|
||||
// state is owned by the route manager and pulled from there.
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
@@ -29,15 +27,20 @@ func NewNotifier() *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.notifyLocked()
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
|
||||
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
|
||||
n.fakeIPRoutes = routes
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
@@ -51,32 +54,46 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
return
|
||||
}
|
||||
|
||||
n.currentRoutes = newRoutes
|
||||
n.notifyLocked()
|
||||
n.notify()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||
// Not used on Android
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyLocked() {
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
n.listener.OnNetworkChanged("")
|
||||
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
allRoutes = append(allRoutes, n.fakeIPRoutes...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged(strings.Join(routeStrings, ","))
|
||||
}(n.listener)
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
@@ -84,10 +101,25 @@ func routesToStrings(routes []*route.Route) []string {
|
||||
return nets
|
||||
}
|
||||
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
|
||||
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
|
||||
return x.NetString() == y.NetString()
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
initialStrings := n.routesToStrings(n.initialRoutes)
|
||||
sort.Strings(initialStrings)
|
||||
return initialStrings
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -31,6 +35,10 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -98,44 +98,47 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
||||
func (u *Installer) startUIAsUser() error {
|
||||
log.Infof("starting netbird-ui: %s", uiBinary)
|
||||
|
||||
username, err := consoleUser()
|
||||
// Get the current console user
|
||||
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to get console user: %w", err)
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(string(output))
|
||||
if username == "" || username == "root" {
|
||||
return fmt.Errorf("no active user session found")
|
||||
}
|
||||
|
||||
log.Infof("starting UI for user: %s", username)
|
||||
|
||||
// Get user's UID
|
||||
userInfo, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||
return fmt.Errorf("failed to lookup user %s: %w", username, err)
|
||||
}
|
||||
|
||||
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
|
||||
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
|
||||
// Start the UI process as the console user using launchctl
|
||||
// This ensures the app runs in the user's context with proper GUI access
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
|
||||
log.Infof("launchCmd: %s", launchCmd.String())
|
||||
// Set the user's home directory for proper macOS app behavior
|
||||
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
|
||||
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
|
||||
|
||||
if err := launchCmd.Run(); err != nil {
|
||||
return fmt.Errorf("run UI launch: %w", err)
|
||||
if err := launchCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start UI process: %w", err)
|
||||
}
|
||||
|
||||
// Release the process so it can run independently
|
||||
if err := launchCmd.Process.Release(); err != nil {
|
||||
log.Warnf("failed to release UI process: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("netbird-ui started successfully for user %s", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func consoleUser() (string, error) {
|
||||
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get console user: %w", err)
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(string(output))
|
||||
switch username {
|
||||
case "", "root", "loginwindow", "_mbsetupuser":
|
||||
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
|
||||
}
|
||||
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
||||
log.Infof("installing pkg file: %s", path)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -84,7 +85,14 @@ type Client struct {
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
// config holds the active configuration once Run has loaded it. Consumed by
|
||||
// the in-app SSH client for the NetBird SSH key and the OAuth flow.
|
||||
config *profilemanager.Config
|
||||
|
||||
// sshJWTCache keeps the SSH JWT token between reconnects so the user is not
|
||||
// forced through the browser OAuth flow on every session. Lives on Client
|
||||
// (not SSHClient) because the app creates a new SSHClient per session.
|
||||
sshJWTCache *jwtcache.Cache
|
||||
}
|
||||
|
||||
// NewClient instantiate a new Client
|
||||
@@ -101,6 +109,7 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
sshJWTCache: jwtcache.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +184,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
c.onHostDnsFn = func([]string) {}
|
||||
cfg.WgIface = interfaceName
|
||||
c.config = cfg
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, connectClient)
|
||||
@@ -697,6 +707,13 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClien
|
||||
return c.config, c.connectClient
|
||||
}
|
||||
|
||||
// sshState returns the active config and the running connect client for the
|
||||
// in-app SSH client. Both are nil until Run has loaded the config and started
|
||||
// the tunnel.
|
||||
func (c *Client) sshState() (*profilemanager.Config, *internal.ConnectClient) {
|
||||
return c.stateSnapshot()
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
ds := d.String()
|
||||
dotIndex := strings.Index(ds, ".")
|
||||
|
||||
447
client/ios/NetBirdSDK/ssh_client.go
Normal file
447
client/ios/NetBirdSDK/ssh_client.go
Normal file
@@ -0,0 +1,447 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
)
|
||||
|
||||
const (
|
||||
sshDialTimeout = 30 * time.Second
|
||||
sshDetectionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// SSHTerminalListener receives SSH session events. It is implemented in Swift.
|
||||
//
|
||||
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||
// other; the implementation must be safe to call from any thread.
|
||||
type SSHTerminalListener interface {
|
||||
OnConnected()
|
||||
OnData(data []byte)
|
||||
OnClose(reason string)
|
||||
OnError(message string)
|
||||
}
|
||||
|
||||
// SSHClient is a NetBird-aware SSH client exposed to Swift via gomobile.
|
||||
//
|
||||
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||
// peer SSH host keys, identical to the desktop client.
|
||||
type SSHClient struct {
|
||||
nb *Client
|
||||
mu sync.Mutex
|
||||
listener SSHTerminalListener
|
||||
urlOpener URLOpener
|
||||
|
||||
sshClient *gossh.Client
|
||||
session *gossh.Session
|
||||
stdin io.WriteCloser
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||
func NewSSHClient(c *Client) *SSHClient {
|
||||
return &SSHClient{nb: c}
|
||||
}
|
||||
|
||||
// SetListener registers the Swift listener. Must be called before Connect to
|
||||
// receive any events.
|
||||
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||
s.mu.Lock()
|
||||
s.listener = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetURLOpener registers the Swift URL opener used to display the device-code
|
||||
// authorization page in an in-app browser when the target peer requires JWT
|
||||
// authentication. Must be set before Connect to be effective.
|
||||
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||
s.mu.Lock()
|
||||
s.urlOpener = opener
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||
// and selects the appropriate authentication path:
|
||||
//
|
||||
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||
// flow, opens the verification URL through the registered URLOpener, and
|
||||
// uses the resulting token as the SSH password. Host-key verification
|
||||
// uses the NetBird peer registry.
|
||||
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||
// private key. Host-key verification uses the NetBird peer registry.
|
||||
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||
// first (so a user-installed NetBird public key works), then falls back
|
||||
// to the supplied password if non-empty. Host-key verification is
|
||||
// disabled (TOFU pending).
|
||||
//
|
||||
// The password parameter is only consulted for regular SSH servers.
|
||||
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||
cfg, cc := s.nb.sshState()
|
||||
if cc == nil {
|
||||
return errors.New("netbird client not running")
|
||||
}
|
||||
if cfg == nil {
|
||||
return errors.New("netbird config not loaded")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return errors.New("netbird engine not available")
|
||||
}
|
||||
|
||||
serverType := detectServerType(host, port)
|
||||
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
|
||||
|
||||
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := &gossh.ClientConfig{
|
||||
User: user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
return s.dialAndHandshake(host, port, clientConfig)
|
||||
}
|
||||
|
||||
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||
// the session is forwarded to the listener via OnData.
|
||||
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
s.mu.Unlock()
|
||||
|
||||
if sshClient == nil {
|
||||
return errors.New("ssh client not connected")
|
||||
}
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
|
||||
modes := gossh.TerminalModes{
|
||||
gossh.ECHO: 1,
|
||||
gossh.TTY_OP_ISPEED: 14400,
|
||||
gossh.TTY_OP_OSPEED: 14400,
|
||||
gossh.VINTR: 3,
|
||||
gossh.VQUIT: 28,
|
||||
gossh.VERASE: 127,
|
||||
}
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
closeQuiet(session, "session after pty error")
|
||||
return fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdin error")
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stdout error")
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
closeQuiet(session, "session after stderr error")
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
closeQuiet(session, "session after shell error")
|
||||
return fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.session = session
|
||||
s.stdin = stdin
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.readLoop(stdout, "stdout")
|
||||
go s.readLoop(stderr, "stderr")
|
||||
log.Debug("SSH: session started, shell running")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the SSH session stdin.
|
||||
func (s *SSHClient) Write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
stdin := s.stdin
|
||||
s.mu.Unlock()
|
||||
if stdin == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (s *SSHClient) Resize(cols, rows int) error {
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
s.mu.Unlock()
|
||||
if session == nil {
|
||||
return errors.New("ssh session not started")
|
||||
}
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Close terminates the SSH session and underlying connection. Safe to call
|
||||
// multiple times.
|
||||
func (s *SSHClient) Close() error {
|
||||
s.mu.Lock()
|
||||
sshClient := s.sshClient
|
||||
session := s.session
|
||||
stdin := s.stdin
|
||||
s.sshClient = nil
|
||||
s.session = nil
|
||||
s.stdin = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if stdin != nil {
|
||||
if err := stdin.Close(); err != nil {
|
||||
log.Debugf("ssh: stdin close: %v", err)
|
||||
}
|
||||
}
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: session close: %v", err)
|
||||
}
|
||||
}
|
||||
var firstErr error
|
||||
if sshClient != nil {
|
||||
if err := sshClient.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
s.notifyClose("closed by client")
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, engine *internal.Engine,
|
||||
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||
|
||||
switch serverType {
|
||||
case detection.ServerTypeNetBirdJWT:
|
||||
token, err := s.requestJWTToken(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
case detection.ServerTypeNetBirdNoJWT:
|
||||
if cfg.SSHKey == "" {
|
||||
return nil, nil, errors.New("no NetBird SSH key available")
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||
}
|
||||
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||
return auths, nbssh.CreateHostKeyCallback(&engineHostKeyVerifier{engine: engine}), nil
|
||||
|
||||
default: // regular SSH
|
||||
var auths []gossh.AuthMethod
|
||||
if cfg.SSHKey != "" {
|
||||
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||
auths = append(auths, gossh.PublicKeys(signer))
|
||||
} else {
|
||||
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
pw := password
|
||||
auths = append(auths, gossh.Password(pw))
|
||||
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = pw
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
return nil, nil, errors.New("no auth method available: provide a password or configure NetBird SSH key")
|
||||
}
|
||||
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||
// Reuse a cached token so the user is not forced through the browser OAuth
|
||||
// flow on every reconnect. TTL comes from cfg.SSHJWTCacheTTL, same as the
|
||||
// daemon's cache; unset/0 disables caching.
|
||||
if token, ok := s.nb.sshJWTCache.Get(); ok {
|
||||
log.Debug("SSH: reusing cached JWT token")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
urlOpener := s.urlOpener
|
||||
s.mu.Unlock()
|
||||
if urlOpener == nil {
|
||||
return "", errors.New("URL opener not configured for JWT auth")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profilemanager.GetLoginHint())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||
}
|
||||
|
||||
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request auth info: %w", err)
|
||||
}
|
||||
|
||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||
|
||||
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wait for token: %w", err)
|
||||
}
|
||||
|
||||
token := tokenInfo.GetTokenToUse()
|
||||
if token == "" {
|
||||
return "", errors.New("empty token returned by IdP")
|
||||
}
|
||||
|
||||
if ttl := jwtcache.ResolveTTL(cfg.SSHJWTCacheTTL); ttl > 0 {
|
||||
s.nb.sshJWTCache.Store(token, ttl)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := gossh.NewClientConn(conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("ssh: close after handshake error: %v", cerr)
|
||||
}
|
||||
return fmt.Errorf("ssh handshake: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sshClient = gossh.NewClient(sshConn, chans, reqs)
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("SSH: connected to %s", addr)
|
||||
if listener != nil {
|
||||
listener.OnConnected()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) readLoop(r io.Reader, name string) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
listener.OnData(chunk)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh %s read: %v", name, err)
|
||||
}
|
||||
s.notifyClose(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SSHClient) notifyClose(reason string) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener != nil {
|
||||
listener.OnClose(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// engineHostKeyVerifier adapts *internal.Engine to nbssh.HostKeyVerifier.
|
||||
type engineHostKeyVerifier struct {
|
||||
engine *internal.Engine
|
||||
}
|
||||
|
||||
func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented []byte) error {
|
||||
storedKey, found := v.engine.GetPeerSSHKey(peerAddress)
|
||||
if !found {
|
||||
return nbssh.ErrPeerNotFound
|
||||
}
|
||||
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
|
||||
}
|
||||
|
||||
func detectServerType(host string, port int) detection.ServerType {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||
if err != nil {
|
||||
log.Debugf("ssh: server detection for %s:%d failed: %v (assuming regular SSH)", host, port, err)
|
||||
return detection.ServerTypeRegular
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
func closeQuiet(c io.Closer, label string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Debugf("ssh: close %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type jwtCache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
func newJWTCache() *jwtCache {
|
||||
return &jwtCache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jwtCache) store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
func (c *jwtCache) get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *jwtCache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A login that never reached Management is not a decision about the peer's
|
||||
// credentials, so it must come back as a retryable error rather than an SSO
|
||||
// prompt: the user cannot finish a browser login while Management is down, and
|
||||
// the CLI's own backoff resolves the outage on its own once the daemon reports
|
||||
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
|
||||
// because Management happened to be restarting when the daemon dialed it.
|
||||
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||
attempts := 0
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
attempts++
|
||||
return false, unreachable
|
||||
}
|
||||
|
||||
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
|
||||
require.Nil(t, resp, "a failed login must not answer with a login response")
|
||||
require.Equal(t, 1, attempts)
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
|
||||
|
||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, internal.StatusLoginFailed, status,
|
||||
"a peer that could not reach Management is not waiting on a login")
|
||||
}
|
||||
|
||||
// The counterpart: Management refusing the peer's credentials is a decision, and
|
||||
// the SSO flow still has to start for it. The profile carries an unusable
|
||||
// private key so the flow setup fails immediately instead of dialing, which is
|
||||
// enough to show the branch was entered — the refusal itself is never what comes
|
||||
// back out.
|
||||
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
||||
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
breakProfilePrivateKey(t, cfgPath)
|
||||
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||
require.Error(t, err)
|
||||
|
||||
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, stateErr)
|
||||
require.Equal(t, internal.StatusLoginFailed, status,
|
||||
"the SSO flow setup was never reached with the broken key")
|
||||
}
|
||||
|
||||
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var keysTried []string
|
||||
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
|
||||
keysTried = append(keysTried, setupKey)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
|
||||
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
|
||||
require.NoError(t, err, "the probe's outcome leaked out as the login result")
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
|
||||
|
||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, internal.StatusIdle, status)
|
||||
}
|
||||
|
||||
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||
// one, which makes any attempt to build a Management client fail on the spot.
|
||||
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||
cfg["PrivateKey"] = "not-a-key"
|
||||
|
||||
patched, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ssh/jwtcache"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -50,9 +51,6 @@ const (
|
||||
defaultMaxRetryTime = 14 * 24 * time.Hour
|
||||
defaultRetryMultiplier = 1.7
|
||||
|
||||
// JWT token cache TTL for the client daemon (disabled by default)
|
||||
defaultJWTCacheTTL = 0
|
||||
|
||||
errRestoreResidualState = "failed to restore residual state: %v"
|
||||
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
||||
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
||||
@@ -134,14 +132,7 @@ type Server struct {
|
||||
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtCache
|
||||
|
||||
// loginAttemptFn stands in for the Management login round trip. Tests set
|
||||
// it to drive the login outcomes that need a server on the other end;
|
||||
// production leaves it nil, and every login goes through loginAttempt.
|
||||
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||
|
||||
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
||||
jwtCache *jwtcache.Cache
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
@@ -163,7 +154,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
updateSettingsDisabled: updateSettingsDisabled,
|
||||
captureEnabled: captureEnabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: newJWTCache(),
|
||||
jwtCache: jwtcache.New(),
|
||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||
probeThrottle: newProbeThrottle(probeThreshold),
|
||||
}
|
||||
@@ -377,34 +368,7 @@ func (s *Server) connectionGoroutineRunning() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// attemptLogin runs a login round trip against Management, or the stand-in a
|
||||
// test installed in place of it.
|
||||
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||
if s.loginAttemptFn != nil {
|
||||
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
||||
}
|
||||
return s.loginAttempt(ctx, setupKey, jwtToken)
|
||||
}
|
||||
|
||||
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
|
||||
if s.isLoginRequiredFn != nil {
|
||||
return s.isLoginRequiredFn(ctx)
|
||||
}
|
||||
|
||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create auth client: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
return authClient.IsLoginRequired(ctx)
|
||||
}
|
||||
|
||||
// loginAttempt attempts to login using the provided information. It returns
|
||||
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||
// StatusLoginFailed for every other failure, so callers can tell an
|
||||
// authentication decision apart from a login that never got made.
|
||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
||||
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||
if err != nil {
|
||||
@@ -657,19 +621,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
s.config = config
|
||||
s.mutex.Unlock()
|
||||
|
||||
// A probe that errors leaves the login undecided: Management unreachable, a
|
||||
// restart mid-request, an internal error. Those are returned for the caller
|
||||
// to retry, because turning them into an SSO prompt asks the user to solve
|
||||
// something that is not theirs to solve, and a browser login cannot succeed
|
||||
// while Management is unreachable anyway. Only Management refusing the
|
||||
// peer's key is a decision, and IsLoginRequired reports that as
|
||||
// needsLogin=true rather than an error.
|
||||
needsLogin, err := s.isLoginRequired(ctx)
|
||||
if err != nil {
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
return nil, err
|
||||
}
|
||||
if !needsLogin {
|
||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
||||
state.Set(internal.StatusIdle)
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
@@ -730,7 +682,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
// which returns NeedsLogin and parks on the browser leg.
|
||||
state.Set(internal.StatusConnecting)
|
||||
|
||||
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
}
|
||||
@@ -885,7 +837,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
s.mutex.Unlock()
|
||||
|
||||
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1670,19 +1622,11 @@ func (s *Server) getJWTCacheTTL() time.Duration {
|
||||
config := s.config
|
||||
s.mutex.Unlock()
|
||||
|
||||
if config == nil || config.SSHJWTCacheTTL == nil {
|
||||
return defaultJWTCacheTTL
|
||||
if config == nil {
|
||||
return jwtcache.DefaultTTL
|
||||
}
|
||||
|
||||
seconds := *config.SSHJWTCacheTTL
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
return jwtcache.ResolveTTL(config.SSHJWTCacheTTL)
|
||||
}
|
||||
|
||||
// RequestJWTAuth initiates JWT authentication flow for SSH
|
||||
@@ -1704,7 +1648,7 @@ func (s *Server) RequestJWTAuth(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
if cachedToken, found := s.jwtCache.get(); found {
|
||||
if cachedToken, found := s.jwtCache.Get(); found {
|
||||
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
||||
|
||||
return &proto.RequestJWTAuthResponse{
|
||||
@@ -1777,7 +1721,7 @@ func (s *Server) WaitJWTToken(
|
||||
|
||||
jwtCacheTTL := s.getJWTCacheTTL()
|
||||
if jwtCacheTTL > 0 {
|
||||
s.jwtCache.store(token, jwtCacheTTL)
|
||||
s.jwtCache.Store(token, jwtCacheTTL)
|
||||
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
||||
} else {
|
||||
log.Debug("JWT caching disabled, not storing token")
|
||||
@@ -1815,9 +1759,6 @@ func (s *Server) RequestExtendAuthSession(
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||
}
|
||||
if connectClient.Engine() == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
|
||||
}
|
||||
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
|
||||
109
client/ssh/jwtcache/cache.go
Normal file
109
client/ssh/jwtcache/cache.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Package jwtcache provides an in-memory, TTL-bound cache for SSH JWT tokens.
|
||||
// The token is kept in a secure memguard enclave and wiped from memory when it
|
||||
// expires. It is shared by the daemon gRPC server and the mobile SDKs, which
|
||||
// have no daemon process to delegate caching to.
|
||||
package jwtcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultTTL is used when no TTL is configured: caching disabled.
|
||||
const DefaultTTL = 0
|
||||
|
||||
// Cache stores a single JWT token in a secure enclave until it expires.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
enclave *memguard.Enclave
|
||||
expiresAt time.Time
|
||||
timer *time.Timer
|
||||
maxTokenSize int
|
||||
}
|
||||
|
||||
// New creates an empty Cache.
|
||||
func New() *Cache {
|
||||
return &Cache{
|
||||
maxTokenSize: 8192,
|
||||
}
|
||||
}
|
||||
|
||||
// Store caches the token for maxAge. A previously stored token is wiped.
|
||||
func (c *Cache) Store(token string, maxAge time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cleanup()
|
||||
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
|
||||
tokenBytes := []byte(token)
|
||||
c.enclave = memguard.NewEnclave(tokenBytes)
|
||||
|
||||
c.expiresAt = time.Now().Add(maxAge)
|
||||
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(maxAge, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != timer {
|
||||
return
|
||||
}
|
||||
c.cleanup()
|
||||
c.timer = nil
|
||||
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
|
||||
})
|
||||
c.timer = timer
|
||||
}
|
||||
|
||||
// Get returns the cached token, or false if none is stored or it has expired.
|
||||
func (c *Cache) Get() (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.enclave == nil || time.Now().After(c.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
buffer, err := c.enclave.Open()
|
||||
if err != nil {
|
||||
log.Debugf("Failed to open JWT token enclave: %v", err)
|
||||
return "", false
|
||||
}
|
||||
defer buffer.Destroy()
|
||||
|
||||
token := string(buffer.Bytes())
|
||||
return token, true
|
||||
}
|
||||
|
||||
// cleanup destroys the secure enclave, must be called with lock held
|
||||
func (c *Cache) cleanup() {
|
||||
if c.enclave != nil {
|
||||
c.enclave = nil
|
||||
}
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
// ResolveTTL converts the configured TTL (seconds, from
|
||||
// profilemanager.Config.SSHJWTCacheTTL) into a duration. Returns DefaultTTL
|
||||
// when unset; 0 means caching is disabled.
|
||||
func ResolveTTL(configuredSeconds *int) time.Duration {
|
||||
if configuredSeconds == nil {
|
||||
return DefaultTTL
|
||||
}
|
||||
|
||||
seconds := *configuredSeconds
|
||||
if seconds == 0 {
|
||||
log.Debug("SSH JWT cache disabled (configured to 0)")
|
||||
return 0
|
||||
}
|
||||
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
||||
return ttl
|
||||
}
|
||||
@@ -26,17 +26,17 @@ contents:
|
||||
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
depends:
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
# Distribution-specific overrides for different package formats
|
||||
overrides:
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
||||
rpm:
|
||||
depends:
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
- xdg-utils
|
||||
|
||||
# Arch Linux packages
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
|
||||
export const useKeepConnectedOnQuit = () => {
|
||||
const [keepConnected, setKeepConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
setKeepConnected(prefs?.keepConnectedOnQuit ?? false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
console.warn("[useKeepConnectedOnQuit] load preferences failed", err);
|
||||
setKeepConnected(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => {
|
||||
setKeepConnected(keep);
|
||||
try {
|
||||
await Preferences.SetKeepConnectedOnQuit(keep);
|
||||
} catch (err: unknown) {
|
||||
setKeepConnected(!keep);
|
||||
console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { keepConnected, setKeepConnectedOnQuit };
|
||||
};
|
||||
@@ -43,12 +43,7 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: {
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
userCode: string;
|
||||
profileId: string;
|
||||
},
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -61,7 +56,7 @@ async function runSsoLogin(
|
||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||
{ userCode: result.userCode, hostname: "" },
|
||||
{ profileName: "", username: "" },
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
|
||||
@@ -131,21 +131,6 @@ export default function SessionExpirationDialog() {
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const authenticate = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||
await WindowManager.CloseSessionExpiration();
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -200,7 +185,7 @@ export default function SessionExpirationDialog() {
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={expired ? authenticate : stay}
|
||||
onClick={stay}
|
||||
disabled={busy}
|
||||
>
|
||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,7 +19,6 @@ export function SettingsGeneral() {
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
@@ -59,15 +57,6 @@ export function SettingsGeneral() {
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
<FancyToggleSwitch
|
||||
value={keepConnected ?? false}
|
||||
onChange={(v) => {
|
||||
void setKeepConnectedOnQuit(v);
|
||||
}}
|
||||
loading={keepConnected === null}
|
||||
label={t("settings.general.keepConnectedOnQuit.label")}
|
||||
helpText={t("settings.general.keepConnectedOnQuit.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Nach dem Beenden verbunden bleiben",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Anzeigesprache"
|
||||
},
|
||||
|
||||
@@ -735,14 +735,6 @@
|
||||
"message": "Autostart Change Failed",
|
||||
"description": "Error-dialog title when changing the autostart setting fails."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Stay Connected After Quitting",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Display Language",
|
||||
"description": "Label for the display-language picker."
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Error al cambiar el inicio automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado al salir",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de la interfaz"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Échec de la modification du démarrage automatique"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Rester connecté après la fermeture",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Langue d’affichage"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Az automatikus indítás módosítása sikertelen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Kapcsolat megtartása kilépéskor",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Megjelenítési nyelv"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Modifica avvio automatico non riuscita"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Resta connesso dopo la chiusura",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Lingua dell'interfaccia"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "自動起動の変更に失敗しました"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "終了後も接続を維持",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "表示言語"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Falha ao alterar o início automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado ao sair",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de exibição"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Не удалось изменить автозапуск"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Оставаться подключённым после выхода",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Язык интерфейса"
|
||||
},
|
||||
|
||||
@@ -551,14 +551,6 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "更改自启动设置失败"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "退出后保持连接",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "显示语言"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
@@ -62,7 +63,7 @@ type registeredServices struct {
|
||||
profiles *services.Profiles
|
||||
update *services.Update
|
||||
daemonFeed *services.DaemonFeed
|
||||
notifier *Notifier
|
||||
notifier *notifications.NotificationService
|
||||
compat *services.Compat
|
||||
profileSwitcher *services.ProfileSwitcher
|
||||
bundle *i18n.Bundle
|
||||
@@ -101,7 +102,7 @@ func main() {
|
||||
updaterHolder := updater.NewHolder(app.Event)
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||
notifier := newNotifier()
|
||||
notifier := notifications.New()
|
||||
compat := services.NewCompat(conn)
|
||||
// macOS shows no toast until permission is requested. Run it after
|
||||
// ApplicationStarted so the notifier's Startup has initialised the
|
||||
@@ -180,7 +181,6 @@ func main() {
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
Preferences: prefStore,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
@@ -210,7 +210,7 @@ func main() {
|
||||
// requestNotificationAuthorization prompts for macOS notification permission.
|
||||
// The request blocks until the user responds (up to 3 minutes), so callers run
|
||||
// it in a goroutine. No-op on Linux/Windows.
|
||||
func requestNotificationAuthorization(notifier *Notifier) {
|
||||
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
||||
authorized, err := notifier.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
logrus.Debugf("check notification authorization: %v", err)
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
var errNotificationsUnavailable = errors.New("notifications unavailable")
|
||||
|
||||
// Notifier wraps the Wails notification service so an unavailable backend
|
||||
// disables notifications instead of aborting the app. Startup fails for
|
||||
// environment reasons (a bare unbundled binary on macOS has no bundle
|
||||
// identifier, a headless Linux session has no D-Bus session bus), and Wails
|
||||
// treats a service startup error as fatal. After a failed startup every call
|
||||
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
|
||||
// identifier raises an Objective-C exception that recover() cannot catch.
|
||||
type Notifier struct {
|
||||
inner *notifications.NotificationService
|
||||
available atomic.Bool
|
||||
}
|
||||
|
||||
func newNotifier() *Notifier {
|
||||
return &Notifier{inner: notifications.New()}
|
||||
}
|
||||
|
||||
// ServiceName implements the Wails service-name hook for startup logs.
|
||||
func (n *Notifier) ServiceName() string {
|
||||
return n.inner.ServiceName()
|
||||
}
|
||||
|
||||
// ServiceStartup starts the platform notifier, downgrading failure to a
|
||||
// warning so the app keeps running without notifications.
|
||||
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
if err := n.inner.ServiceStartup(ctx, options); err != nil {
|
||||
log.Warnf("notifications disabled: %v", err)
|
||||
return nil
|
||||
}
|
||||
n.available.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) ServiceShutdown() error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
}
|
||||
return n.inner.ServiceShutdown()
|
||||
}
|
||||
|
||||
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
|
||||
if !n.available.Load() {
|
||||
return false, errNotificationsUnavailable
|
||||
}
|
||||
return n.inner.CheckNotificationAuthorization()
|
||||
}
|
||||
|
||||
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
|
||||
if !n.available.Load() {
|
||||
return false, errNotificationsUnavailable
|
||||
}
|
||||
return n.inner.RequestNotificationAuthorization()
|
||||
}
|
||||
|
||||
// SendNotification delivers a notification, silently dropping it when the
|
||||
// backend never started (notifications are best-effort everywhere).
|
||||
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
|
||||
if !n.available.Load() {
|
||||
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||
return nil
|
||||
}
|
||||
return n.inner.SendNotification(options)
|
||||
}
|
||||
|
||||
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
|
||||
if !n.available.Load() {
|
||||
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||
return nil
|
||||
}
|
||||
return n.inner.SendNotificationWithActions(options)
|
||||
}
|
||||
|
||||
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
}
|
||||
return n.inner.RegisterNotificationCategory(category)
|
||||
}
|
||||
|
||||
// OnNotificationResponse registers the response callback. Pure Go state, so
|
||||
// it is safe (and simply inert) when the backend never started.
|
||||
//
|
||||
//wails:ignore
|
||||
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
|
||||
n.inner.OnNotificationResponse(callback)
|
||||
}
|
||||
@@ -58,10 +58,6 @@ type UIPreferences struct {
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
// and is never reset, so the default-on flow runs at most once, ever.
|
||||
AutostartInitialized bool `json:"autostartInitialized"`
|
||||
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
|
||||
// Its false zero value preserves the historical disconnect-on-quit
|
||||
// behaviour for preference files written before the field existed.
|
||||
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
@@ -187,26 +183,6 @@ func (s *Store) SetAutostartInitialized(done bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
|
||||
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.KeepConnectedOnQuit == keep {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.KeepConnectedOnQuit = keep
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
@@ -270,7 +246,6 @@ func (s *Store) ExistedAtLoad() bool {
|
||||
func (s *Store) load() error {
|
||||
if _, err := os.Stat(s.path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat preferences: %w", err)
|
||||
|
||||
@@ -238,42 +238,6 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(nil, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
|
||||
|
||||
reloaded, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
// A preferences file written before the field existed must keep the
|
||||
// historical disconnect-on-quit behaviour rather than silently opting out.
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
|
||||
|
||||
s, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
|
||||
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
|
||||
}
|
||||
|
||||
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
|
||||
@@ -33,21 +33,12 @@ type LoginResult struct {
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
// ProfileID is the ID of the profile this login ran against, or "" when the
|
||||
// caller named the profile itself and no ID was resolved. Pass it back in
|
||||
// WaitSSOParams so the account email lands on this profile even if the
|
||||
// active one changes during SSO.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
// ProfileID is the profile the login was started for, used to file the
|
||||
// account email against it rather than against whichever profile is active
|
||||
// when the flow returns. Optional: empty falls back to the active profile.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// UpParams selects the profile to bring up.
|
||||
@@ -86,16 +77,11 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
// Fall back to the daemon's active profile and the current OS user.
|
||||
profileName := p.ProfileName
|
||||
username := p.Username
|
||||
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
|
||||
// a handle — a display name or an ID prefix resolve too — and the state file
|
||||
// is named after the ID, so passing a handle on would name the wrong file.
|
||||
profileID := ""
|
||||
if profileName == "" {
|
||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
profileID = profileName
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
@@ -136,7 +122,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
UserCode: resp.GetUserCode(),
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
ProfileID: profileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -257,31 +242,6 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
|
||||
// Persist the account email the same way the CLI does after its own
|
||||
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||
// root and the per-profile state file is user-owned (see Logout below).
|
||||
// Without this the profile has no email, so Profiles.List shows no account
|
||||
// and later logins and session extends go out without a login_hint —
|
||||
// leaving the IdP to guess which account was meant.
|
||||
if email := resp.GetEmail(); email != "" {
|
||||
state := &profilemanager.ProfileState{Email: email}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
|
||||
// Against the profile the login was started for: SSO spans seconds of
|
||||
// user interaction, and a profile switch in that window would otherwise
|
||||
// file the email under the wrong profile.
|
||||
if p.ProfileID != "" {
|
||||
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
|
||||
} else {
|
||||
err = pm.SetActiveProfileState(state)
|
||||
}
|
||||
if err != nil {
|
||||
// Non-fatal: the login itself succeeded.
|
||||
log.Warnf("failed to store account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,5 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
if app == nil || app.Screen == nil {
|
||||
return p, true
|
||||
}
|
||||
// The wails GTK3 backend caches screens from the active window; a tray app
|
||||
// has none at startup, so the cache is empty and PhysicalToDipPoint would
|
||||
// dereference a nil nearest screen. Raw pixels are correct there anyway.
|
||||
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
|
||||
return p, true
|
||||
}
|
||||
return app.Screen.PhysicalToDipPoint(p), true
|
||||
}
|
||||
|
||||
@@ -34,7 +34,3 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
|
||||
return s.store.SetKeepConnectedOnQuit(keep)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"context"
|
||||
"os/user"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
@@ -153,31 +151,11 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
// user-owned state file holding the account email behind (same split as
|
||||
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||
// generated ID, so a recreated profile of the same name would inherit the
|
||||
// deleted one's email and offer it as the login_hint.
|
||||
//
|
||||
// Keyed on the ID the daemon resolved, not on the request handle: that may
|
||||
// have been a display name or an ID prefix, which would name a different
|
||||
// file (or none).
|
||||
if id := resp.GetId(); id != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
|
||||
// Non-fatal: the profile itself is gone.
|
||||
log.Warnf("failed to remove profile state for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
@@ -45,15 +44,14 @@ type TrayServices struct {
|
||||
Profiles *services.Profiles
|
||||
Networks *services.Networks
|
||||
DaemonFeed *services.DaemonFeed
|
||||
Notifier *Notifier
|
||||
Notifier *notifications.NotificationService
|
||||
Update *services.Update
|
||||
ProfileSwitcher *services.ProfileSwitcher
|
||||
WindowManager *services.WindowManager
|
||||
// Session is bound to authsession directly because the services wrapper
|
||||
// only re-exposes the React subset.
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
Preferences *preferences.Store
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
}
|
||||
|
||||
type Tray struct {
|
||||
@@ -463,12 +461,10 @@ func (t *Tray) handleQuit() {
|
||||
t.profileMu.Unlock()
|
||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||
|
||||
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
@@ -4,26 +4,17 @@ package main
|
||||
|
||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||
//
|
||||
// Expected behaviour per tray host:
|
||||
//
|
||||
// Host Left click Right click
|
||||
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||
// GNOME Shell + AppIndicator menu only menu only
|
||||
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||
//
|
||||
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||
//
|
||||
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
|
||||
// on ANY click and only reports the menu opening via dbusmenu
|
||||
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
|
||||
// both buttons raised the main window on top of the menu, and on KDE/Waybar
|
||||
// a right click raised it over the freshly opened menu. The netbirdio/wails
|
||||
// fork (go.mod replace) drops that heuristic: a menu open never fires
|
||||
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
|
||||
// entry; left-click-opens-window is not achievable there anyway, since the
|
||||
// host always opens the menu itself.
|
||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
||||
// fires the registered clickHandler:
|
||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
||||
// Registering OnClick here therefore covers both paths with one handler — no
|
||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
||||
// main window; right-click still opens the menu via Wails' default
|
||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
||||
//
|
||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||
|
||||
@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
|
||||
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
||||
// is reachable but too old for this UI. A probe error means the daemon isn't
|
||||
// reachable (not outdated), so it is left to the normal connection flow.
|
||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
|
||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
|
||||
ready, err := compat.DaemonReady(context.Background())
|
||||
if err != nil {
|
||||
log.Debugf("daemon compatibility probe: %v", err)
|
||||
|
||||
@@ -27,10 +27,11 @@ const (
|
||||
finalWarningCountdownSeconds = 120
|
||||
)
|
||||
|
||||
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
||||
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
|
||||
func (t *Tray) handleSessionExpired() {
|
||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||
if t.window != nil {
|
||||
t.window.SetURL("/#/login")
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
@@ -307,7 +308,11 @@ func (t *Tray) openSessionExtendFlow() {
|
||||
}
|
||||
seconds := int(time.Until(deadline).Seconds())
|
||||
if seconds <= 0 {
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
if t.window != nil {
|
||||
t.window.SetURL("/#/login")
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if t.svc.WindowManager == nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ type trayUpdater struct {
|
||||
app *application.App
|
||||
window *application.WebviewWindow
|
||||
update *services.Update
|
||||
notifier *Notifier
|
||||
notifier *notifications.NotificationService
|
||||
loc *Localizer
|
||||
onIconChange func()
|
||||
// onMenuChange drives a full tray relayout: the update row lives in the
|
||||
@@ -36,7 +36,7 @@ type trayUpdater struct {
|
||||
progressWindowOpen bool
|
||||
}
|
||||
|
||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
u := &trayUpdater{
|
||||
app: app,
|
||||
window: window,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build linux && gtk3 && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process
|
||||
// XEmbed StatusNotifierWatcher entirely. The real implementation
|
||||
// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only
|
||||
// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the
|
||||
// C layer we stub the host out on gtk3 builds. The tray still works on every
|
||||
// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator,
|
||||
// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/
|
||||
// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md.
|
||||
|
||||
// xembedHost is a placeholder so the package compiles on gtk3 builds; the real
|
||||
// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never
|
||||
// instantiated here because xembedTrayAvailable always reports false.
|
||||
type xembedHost struct{}
|
||||
|
||||
// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because
|
||||
// newXembedHost never returns a non-nil host.
|
||||
func (*xembedHost) run() {}
|
||||
|
||||
// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe
|
||||
// loop in startStatusNotifierWatcher exits immediately and newXembedHost is
|
||||
// never reached. recenter_linux.go's predicate becomes a harmless no-op too.
|
||||
func xembedTrayAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go;
|
||||
// it is unreachable because xembedTrayAvailable returns false on gtk3.
|
||||
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
|
||||
return nil, errors.New("xembed host unsupported on gtk3 build")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build linux && !gtk3 && !(linux && 386)
|
||||
//go:build linux && !(linux && 386)
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build linux && !gtk3 && !(linux && 386)
|
||||
|
||||
#include "xembed_tray_linux.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
|
||||
@@ -160,19 +160,8 @@ func TestSettingsRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
||||
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||
|
||||
// A cluster different from the pinned one must be rejected; echoing the
|
||||
// pinned one back is valid.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("attacker.cluster.invalid"),
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// Restore the original toggles.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(before.Cluster),
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// harnessStartFresh boots a dedicated combined server with its own fresh
|
||||
// account and registers its teardown on t.
|
||||
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
|
||||
t.Helper()
|
||||
fresh, err := harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
|
||||
if _, err := fresh.Bootstrap(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
|
||||
// account that has never been bootstrapped: the GET reads as the defaults
|
||||
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
|
||||
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
|
||||
// pins it immutably. The shared srv cannot provide that starting state (any
|
||||
// provider-creating test bootstraps it, and test order is deliberately not
|
||||
// relied on), so this boots a dedicated combined server — the image is
|
||||
// already built and cached by TestMain's StartCombined, so the extra cost is
|
||||
// one container start.
|
||||
func TestSettingsBootstrapViaPut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
require.NoError(t, err, "start dedicated combined server")
|
||||
|
||||
// Before agent-network bootstrap the settings read as the defaults, not
|
||||
// as an error and not as a null body.
|
||||
before, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings on a fresh account must succeed")
|
||||
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
|
||||
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
|
||||
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
|
||||
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
|
||||
// A PUT without a cluster has nothing to pin the account to.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// A PUT carrying a cluster bootstraps the account and applies the
|
||||
// mutable fields from the same request. Every toggle is set away from
|
||||
// its bootstrap default so each assertion can actually fail.
|
||||
const cluster = "e2e.bootstrap.netbird.selfhosted"
|
||||
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(cluster),
|
||||
EnableLogCollection: false,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
})
|
||||
require.NoError(t, err, "bootstrap settings via PUT must succeed")
|
||||
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
|
||||
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
|
||||
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
|
||||
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
|
||||
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
|
||||
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
|
||||
|
||||
// The row is persisted: an independent read agrees on every field.
|
||||
after, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after bootstrap must succeed")
|
||||
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
|
||||
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
|
||||
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
|
||||
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
|
||||
|
||||
// Once bootstrapped, later updates may omit the cluster entirely.
|
||||
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: true,
|
||||
})
|
||||
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
|
||||
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
|
||||
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
|
||||
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
|
||||
|
||||
// The cluster is immutable: a different value is rejected rather than
|
||||
// silently ignored, and the rejected update must not disturb anything.
|
||||
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("other.cluster.invalid"),
|
||||
EnableLogCollection: false,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
final, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
|
||||
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
|
||||
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
|
||||
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
|
||||
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -114,7 +114,7 @@ require (
|
||||
github.com/ti-mo/conntrack v0.5.1
|
||||
github.com/ti-mo/netfilter v0.5.2
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
@@ -339,5 +339,3 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
|
||||
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
|
||||
|
||||
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.20260807055527-fc03f984d701
|
||||
|
||||
4
go.sum
4
go.sum
@@ -490,8 +490,6 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -662,6 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
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=
|
||||
|
||||
@@ -11,10 +11,6 @@ SED_STRIP_PADDING='s/=//g'
|
||||
|
||||
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
||||
|
||||
# Static IP for Traefik inside the compose bridge network. The management
|
||||
# server trusts X-Forwarded-* headers from this address only.
|
||||
TRAEFIK_IP="172.30.0.10"
|
||||
|
||||
check_docker_compose() {
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
echo "docker-compose"
|
||||
@@ -84,7 +80,7 @@ read_nb_domain() {
|
||||
if ! check_domain_resolves "$value"; then
|
||||
echo "" > /dev/stderr
|
||||
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
|
||||
echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr
|
||||
echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
|
||||
local confirm=""
|
||||
echo -n "Continue anyway? [y/N]: " > /dev/stderr
|
||||
read -r confirm < /dev/tty
|
||||
@@ -96,23 +92,6 @@ read_nb_domain() {
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
read_letsencrypt_email() {
|
||||
if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then
|
||||
echo "$NETBIRD_LETSENCRYPT_EMAIL"
|
||||
return
|
||||
fi
|
||||
local value=""
|
||||
echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr
|
||||
echo -n "Email address: " > /dev/stderr
|
||||
read -r value < /dev/tty
|
||||
if [[ -z "$value" ]]; then
|
||||
echo "Email is required for Let's Encrypt." > /dev/stderr
|
||||
read_letsencrypt_email
|
||||
return
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
read_required() {
|
||||
local prompt="$1"
|
||||
local value=""
|
||||
@@ -225,11 +204,11 @@ init_environment() {
|
||||
check_openssl
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then
|
||||
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
|
||||
echo "Generated files already exist in $(pwd)."
|
||||
echo "If you want to reinitialize the environment, please remove them first:"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
|
||||
echo " rm -f .env docker-compose.yml config.yaml"
|
||||
echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
|
||||
echo "Be aware this will remove all data from the database."
|
||||
exit 1
|
||||
fi
|
||||
@@ -251,9 +230,6 @@ init_environment() {
|
||||
echo ""
|
||||
NETBIRD_DOMAIN=$(read_nb_domain)
|
||||
|
||||
echo ""
|
||||
NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email)
|
||||
|
||||
echo ""
|
||||
|
||||
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
|
||||
@@ -262,7 +238,6 @@ init_environment() {
|
||||
POSTGRES_DB="netbird"
|
||||
POSTGRES_PASSWORD=$(rand_secret)
|
||||
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
|
||||
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
|
||||
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
|
||||
|
||||
POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
|
||||
@@ -272,7 +247,6 @@ init_environment() {
|
||||
echo "Selected:"
|
||||
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
|
||||
echo " Domain: ${NETBIRD_DOMAIN}"
|
||||
echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}"
|
||||
echo ""
|
||||
echo "Rendering files into $(pwd) ..."
|
||||
install -m 600 /dev/null .env
|
||||
@@ -282,6 +256,7 @@ init_environment() {
|
||||
if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
|
||||
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
|
||||
fi
|
||||
render_caddyfile > Caddyfile
|
||||
install -m 600 /dev/null config.yaml
|
||||
render_config_yaml >> config.yaml
|
||||
|
||||
@@ -308,7 +283,7 @@ init_environment() {
|
||||
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
|
||||
echo ""
|
||||
echo "Tail logs:"
|
||||
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik"
|
||||
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -331,11 +306,6 @@ NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
|
||||
# Domain
|
||||
NETBIRD_DOMAIN=${NETBIRD_DOMAIN}
|
||||
|
||||
# Reverse proxy (Traefik)
|
||||
NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL}
|
||||
NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6}
|
||||
NETBIRD_TRAEFIK_IP=${TRAEFIK_IP}
|
||||
|
||||
# Image tags. Default to "latest"
|
||||
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
|
||||
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
|
||||
@@ -408,78 +378,26 @@ EOF
|
||||
|
||||
render_compose_common() {
|
||||
cat <<'EOF'
|
||||
# Reverse proxy with automatic TLS via Let's Encrypt. Routes are declared as
|
||||
# labels on the services below and picked up through the Docker provider.
|
||||
traefik:
|
||||
caddy:
|
||||
<<: *default
|
||||
image: traefik:${NETBIRD_TRAEFIK_TAG}
|
||||
container_name: netbird-traefik
|
||||
networks:
|
||||
netbird:
|
||||
ipv4_address: ${NETBIRD_TRAEFIK_IP}
|
||||
command:
|
||||
# Logging
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
# Docker provider
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=netbird"
|
||||
# Entrypoints
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--entrypoints.websecure.allowACMEByPass=true"
|
||||
# readTimeout bounds the whole request, and gRPC streams / relay WebSockets
|
||||
# never end one; idleTimeout would close the keep-alive connection they
|
||||
# are reused over. Entrypoint-wide is the only scope Traefik offers here.
|
||||
# writeTimeout is left alone: it already defaults to 0.
|
||||
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"
|
||||
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=0"
|
||||
# HTTP to HTTPS redirect
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
# Let's Encrypt ACME
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
|
||||
image: caddy:2
|
||||
container_name: netbird-caddy
|
||||
networks: [netbird]
|
||||
environment:
|
||||
- CADDY_SECURE_DOMAIN=${NETBIRD_DOMAIN}
|
||||
ports:
|
||||
- '443:443'
|
||||
- '443:443/udp'
|
||||
- '80:80'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- netbird_traefik_letsencrypt:/letsencrypt
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Shared security headers, referenced by every NetBird router below. A
|
||||
# label-declared middleware only exists while its container runs, so this
|
||||
# lives on Traefik itself: declaring it on an app container would drop
|
||||
# every router referencing it whenever that container restarts.
|
||||
- traefik.http.middlewares.nb-security.headers.stsSeconds=3600
|
||||
- traefik.http.middlewares.nb-security.headers.stsIncludeSubdomains=true
|
||||
- traefik.http.middlewares.nb-security.headers.contentTypeNosniff=true
|
||||
- traefik.http.middlewares.nb-security.headers.browserXssFilter=true
|
||||
- traefik.http.middlewares.nb-security.headers.referrerPolicy=strict-origin-when-cross-origin
|
||||
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.X-Frame-Options=SAMEORIGIN
|
||||
# Empty value strips the header. Only the dashboard's nginx sets one; the
|
||||
# server emits none. Do not quote it — "" would send a literal Server: "".
|
||||
- traefik.http.middlewares.nb-security.headers.customResponseHeaders.Server=
|
||||
- netbird_caddy_data:/data
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
|
||||
dashboard:
|
||||
<<: *default
|
||||
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
|
||||
container_name: netbird-dashboard
|
||||
networks: [netbird]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Dashboard catch-all: lowest priority so every route below wins
|
||||
- traefik.http.routers.netbird-dashboard.rule=Host(`${NETBIRD_DOMAIN}`)
|
||||
- traefik.http.routers.netbird-dashboard.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-dashboard.tls=true
|
||||
- traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.netbird-dashboard.middlewares=nb-security@docker
|
||||
- traefik.http.routers.netbird-dashboard.service=dashboard
|
||||
- traefik.http.routers.netbird-dashboard.priority=1
|
||||
- traefik.http.services.dashboard.loadbalancer.server.port=80
|
||||
environment:
|
||||
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
||||
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
||||
@@ -517,28 +435,6 @@ render_compose_server() {
|
||||
- netbird_data:/var/lib/netbird
|
||||
- ./config.yaml:/etc/netbird/config.yaml
|
||||
command: ["--config", "/etc/netbird/config.yaml"]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Signal + Management gRPC (needs an h2c backend for HTTP/2 cleartext)
|
||||
- traefik.http.routers.netbird-grpc.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))
|
||||
- traefik.http.routers.netbird-grpc.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-grpc.tls=true
|
||||
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.netbird-grpc.middlewares=nb-security@docker
|
||||
- traefik.http.routers.netbird-grpc.service=netbird-server-h2c
|
||||
- traefik.http.routers.netbird-grpc.priority=100
|
||||
# Relay WebSocket, management API, and the embedded IdP
|
||||
- traefik.http.routers.netbird-backend.rule=Host(`${NETBIRD_DOMAIN}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))
|
||||
- traefik.http.routers.netbird-backend.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-backend.tls=true
|
||||
- traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.netbird-backend.middlewares=nb-security@docker
|
||||
- traefik.http.routers.netbird-backend.service=netbird-server
|
||||
- traefik.http.routers.netbird-backend.priority=100
|
||||
# Services
|
||||
- traefik.http.services.netbird-server.loadbalancer.server.port=80
|
||||
- traefik.http.services.netbird-server-h2c.loadbalancer.server.port=80
|
||||
- traefik.http.services.netbird-server-h2c.loadbalancer.server.scheme=h2c
|
||||
environment:
|
||||
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
|
||||
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
@@ -601,18 +497,6 @@ render_compose_flow() {
|
||||
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
|
||||
- NB_FLOW_NATS_STREAM=traffic-events
|
||||
- NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Flow receiver gRPC (h2c backend)
|
||||
- traefik.http.routers.netbird-flow.rule=Host(`${NETBIRD_DOMAIN}`) && PathPrefix(`/flow.FlowService/`)
|
||||
- traefik.http.routers.netbird-flow.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-flow.tls=true
|
||||
- traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.netbird-flow.middlewares=nb-security@docker
|
||||
- traefik.http.routers.netbird-flow.service=netbird-flow-h2c
|
||||
- traefik.http.routers.netbird-flow.priority=100
|
||||
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.port=80
|
||||
- traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c
|
||||
|
||||
EOF
|
||||
}
|
||||
@@ -652,16 +536,61 @@ EOF
|
||||
fi
|
||||
cat <<'EOF'
|
||||
netbird_postgres:
|
||||
netbird_traefik_letsencrypt:
|
||||
netbird_caddy_data:
|
||||
|
||||
networks:
|
||||
netbird:
|
||||
name: netbird
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.30.0.0/24
|
||||
gateway: 172.30.0.1
|
||||
EOF
|
||||
}
|
||||
|
||||
render_caddyfile() {
|
||||
cat <<'EOF'
|
||||
{
|
||||
servers :80,:443 {
|
||||
protocols h1 h2c h2 h3
|
||||
}
|
||||
}
|
||||
|
||||
(security_headers) {
|
||||
header * {
|
||||
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
-Server
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
redir https://{$CADDY_SECURE_DOMAIN}{uri} permanent
|
||||
}
|
||||
|
||||
{$CADDY_SECURE_DOMAIN}:443 {
|
||||
import security_headers
|
||||
# Signal (gRPC over h2c)
|
||||
reverse_proxy /signalexchange.SignalExchange/* h2c://netbird-server:80
|
||||
# Management (gRPC over h2c + HTTP)
|
||||
reverse_proxy /management.ManagementService/* h2c://netbird-server:80
|
||||
reverse_proxy /api/* netbird-server:80
|
||||
reverse_proxy /ws-proxy/* netbird-server:80
|
||||
# Embedded IdP (OAuth2 endpoints served by netbird server)
|
||||
reverse_proxy /oauth2/* netbird-server:80
|
||||
# Relay (WebSocket multiplexed on the same port)
|
||||
reverse_proxy /relay* netbird-server:80
|
||||
EOF
|
||||
|
||||
if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then
|
||||
cat <<'EOF'
|
||||
# Flow receiver (gRPC over h2c)
|
||||
reverse_proxy /flow.FlowService/* h2c://receiver:80
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
# Dashboard
|
||||
reverse_proxy /* dashboard:80
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -680,7 +609,7 @@ server:
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
|
||||
# TLS is terminated by Traefik in front; leave this block empty.
|
||||
# TLS is terminated by Caddy in front; leave this block empty.
|
||||
tls:
|
||||
certFile: ""
|
||||
keyFile: ""
|
||||
@@ -697,23 +626,12 @@ server:
|
||||
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
|
||||
dashboardRedirectURIs:
|
||||
- "https://${NETBIRD_DOMAIN}/nb-auth"
|
||||
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
|
||||
# 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, and leaving
|
||||
# it unset falls back to 0.0.0.0/0.
|
||||
reverseProxy:
|
||||
trustedPeers:
|
||||
- "${TRAEFIK_IP}/32"
|
||||
trustedHTTPProxies:
|
||||
- "${TRAEFIK_IP}/32"
|
||||
|
||||
store:
|
||||
engine: "postgres"
|
||||
dsn: "${POSTGRES_DSN}"
|
||||
|
||||
@@ -348,7 +348,6 @@ initialize_default_values() {
|
||||
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
||||
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
|
||||
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
NETBIRD_STUN_PORT=3478
|
||||
|
||||
# Docker images
|
||||
@@ -528,8 +527,7 @@ generate_configuration_files() {
|
||||
|
||||
# Common files for all configurations
|
||||
render_dashboard_env > dashboard.env
|
||||
install -m 600 /dev/null config.yaml
|
||||
render_combined_yaml >> config.yaml
|
||||
render_combined_yaml > config.yaml
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -913,7 +911,6 @@ server:
|
||||
auth:
|
||||
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
|
||||
signKeyRefreshEnabled: true
|
||||
sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"
|
||||
dashboardRedirectURIs:
|
||||
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
|
||||
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"
|
||||
|
||||
@@ -15,11 +15,7 @@ set -o pipefail
|
||||
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
|
||||
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
|
||||
#
|
||||
# If any step fails once the stack has been touched, the script rolls itself
|
||||
# back automatically: generated files are removed, the Postgres volume this run
|
||||
# created is dropped, and the original deployment is started again.
|
||||
#
|
||||
# To revert a successful migration:
|
||||
# To revert:
|
||||
# docker compose down
|
||||
# rm -f docker-compose.override.yml config.yaml.enterprise
|
||||
# # If Postgres migration was done, also restore the SQLite backup printed
|
||||
@@ -29,15 +25,6 @@ set -o pipefail
|
||||
OVERRIDE_FILE="docker-compose.override.yml"
|
||||
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
|
||||
|
||||
# Rollback bookkeeping. ROLLBACK_STATE flips to "armed" the moment the script
|
||||
# starts mutating the deployment, and back to "disarmed" once the migration has
|
||||
# completed successfully.
|
||||
ROLLBACK_STATE="disarmed"
|
||||
ENV_EXISTED="unknown"
|
||||
ENV_BACKUP=""
|
||||
PG_VOLUME_NAME=""
|
||||
BACKUP_DIR=""
|
||||
|
||||
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
||||
|
||||
check_docker_compose() {
|
||||
@@ -173,11 +160,11 @@ EOF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
detect_combined_service() {
|
||||
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
detect_dashboard_service() {
|
||||
yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/dashboard([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
detect_config_yaml_host_path() {
|
||||
@@ -374,77 +361,7 @@ render_enterprise_config() {
|
||||
# Execution steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
combined_container_id() {
|
||||
$DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
container_data_mount() {
|
||||
local container="$1"
|
||||
[[ -n "$container" ]] || return 0
|
||||
docker inspect "$container" --format \
|
||||
'{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null
|
||||
}
|
||||
|
||||
# The name comes from the container, so `-v` cannot invent an empty volume here.
|
||||
# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not
|
||||
# be reported as empty: that would abort a healthy migration over a pull error
|
||||
# or an unreadable bind mount.
|
||||
data_dir_state() {
|
||||
local src="$1" out
|
||||
if [[ "$src" == /* ]]; then
|
||||
[[ -d "$src" ]] || return 2
|
||||
out=$(ls -A "$src" 2>/dev/null) || return 2
|
||||
else
|
||||
docker volume inspect "$src" &> /dev/null || return 0
|
||||
out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2
|
||||
fi
|
||||
[[ -z "$out" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
check_data_directory() {
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
|
||||
|
||||
local container
|
||||
container=$(combined_container_id)
|
||||
if [[ -z "$container" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr
|
||||
echo "The migration backs up the store by copying it out of that container," > /dev/stderr
|
||||
echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr
|
||||
echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local src
|
||||
src=$(container_data_mount "$container")
|
||||
if [[ -z "$src" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr
|
||||
echo "Cannot locate the NetBird store to back it up." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local state=0
|
||||
data_dir_state "$src" || state=$?
|
||||
if [[ $state -eq 0 ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "The NetBird data directory is empty:" > /dev/stderr
|
||||
echo " $src" > /dev/stderr
|
||||
echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr
|
||||
echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
if [[ $state -eq 2 ]]; then
|
||||
echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr
|
||||
echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr
|
||||
fi
|
||||
|
||||
echo " Data directory: $src"
|
||||
}
|
||||
|
||||
# Only for the Postgres volume, which has no container to read it off yet.
|
||||
resolve_compose_volume() {
|
||||
resolve_data_volume() {
|
||||
local short="$1"
|
||||
local actual
|
||||
# Resolve project-prefixed volume name from Docker Compose config first.
|
||||
@@ -474,21 +391,18 @@ resolve_compose_volume() {
|
||||
backup_sqlite() {
|
||||
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
local container
|
||||
container=$(combined_container_id)
|
||||
if [[ -z "$container" ]]; then
|
||||
echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Backing up the NetBird store to $BACKUP_DIR ..."
|
||||
docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"
|
||||
|
||||
local data_volume_actual
|
||||
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
|
||||
echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
|
||||
docker run --rm \
|
||||
-v "${data_volume_actual}:/var/lib/netbird:ro" \
|
||||
-v "${BACKUP_DIR}:/backup" \
|
||||
busybox \
|
||||
sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
|
||||
local copied
|
||||
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
|
||||
if [[ -z "$copied" ]]; then
|
||||
echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr
|
||||
echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
echo " done"
|
||||
@@ -500,135 +414,6 @@ run_migrate_store() {
|
||||
echo " done"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback — a failed run must not leave the operator with a stopped stack and
|
||||
# half-written artifacts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve the name Compose would give the Postgres volume before the override
|
||||
# exists, so a leftover volume can be spotted up front.
|
||||
compose_project_name() {
|
||||
local container project
|
||||
container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1)
|
||||
if [[ -n "$container" ]]; then
|
||||
project=$(docker inspect "$container" \
|
||||
--format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null)
|
||||
if [[ -n "$project" ]]; then
|
||||
echo "$project"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null)
|
||||
if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then
|
||||
echo "$project"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
postgres_volume_name() {
|
||||
local project
|
||||
project=$(compose_project_name)
|
||||
if [[ -n "$project" ]]; then
|
||||
echo "${project}_netbird_postgres"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Postgres skips initdb when its data directory is non-empty, so a volume left
|
||||
# behind by an interrupted run would keep the old password and old contents,
|
||||
# and migrate-store would fail against it.
|
||||
check_stale_postgres_volume() {
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0
|
||||
|
||||
PG_VOLUME_NAME=$(postgres_volume_name)
|
||||
if [[ -z "$PG_VOLUME_NAME" ]]; then
|
||||
echo ""
|
||||
echo " ⚠ Could not determine the Compose project name, so a Postgres volume"
|
||||
echo " left over from an earlier attempt cannot be checked for. If a"
|
||||
echo " previous run failed, remove it before continuing:"
|
||||
echo " docker volume ls | grep netbird_postgres"
|
||||
return 0
|
||||
fi
|
||||
docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0
|
||||
|
||||
echo ""
|
||||
echo " ⚠ A Postgres volume from an earlier attempt already exists:"
|
||||
echo " $PG_VOLUME_NAME"
|
||||
echo " Postgres does not re-initialise a non-empty data directory, so the"
|
||||
echo " migration would run against stale credentials and stale data."
|
||||
local remove
|
||||
remove=$(read_yes_no " Remove it and continue?" "y")
|
||||
if [[ "$remove" != "yes" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
docker volume rm "$PG_VOLUME_NAME" > /dev/null
|
||||
echo " Removed."
|
||||
}
|
||||
|
||||
# Undo whatever this run changed and start the previous deployment again.
|
||||
rollback() {
|
||||
ROLLBACK_STATE="done"
|
||||
|
||||
echo ""
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
echo " Migration failed — restoring the previous deployment"
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
|
||||
# Resolve while the override is still present; without it Compose no longer
|
||||
# knows about the Postgres volume.
|
||||
local pg_volume="$PG_VOLUME_NAME"
|
||||
if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
pg_volume=$(postgres_volume_name)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Stopping services ..."
|
||||
$DOCKER_COMPOSE_COMMAND down || true
|
||||
|
||||
echo "Removing generated files ..."
|
||||
rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE"
|
||||
|
||||
# Restore .env to exactly what it was, or remove it if this run created it.
|
||||
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
|
||||
mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr
|
||||
elif [[ "$ENV_EXISTED" == "no" ]]; then
|
||||
rm -f .env || true
|
||||
fi
|
||||
|
||||
# Only ever the volume this run created — never the NetBird data volume.
|
||||
if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then
|
||||
echo "Removing Postgres volume $pg_volume ..."
|
||||
docker volume rm "$pg_volume" &> /dev/null || true
|
||||
fi
|
||||
|
||||
echo "Starting the previous deployment ..."
|
||||
if ! $DOCKER_COMPOSE_COMMAND up -d; then
|
||||
echo ""
|
||||
echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr
|
||||
echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data"
|
||||
echo "volume were never modified."
|
||||
if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
|
||||
echo "The SQLite backup taken during this run is kept at:"
|
||||
echo " $BACKUP_DIR"
|
||||
fi
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local code=$?
|
||||
trap - EXIT
|
||||
if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then
|
||||
rollback
|
||||
fi
|
||||
exit $code
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -661,12 +446,12 @@ init_migration() {
|
||||
COMPOSE_NETWORK=$(detect_compose_network)
|
||||
|
||||
if [[ -z "$COMBINED_SERVICE" ]]; then
|
||||
echo "Could not find a service running netbirdio/netbird-server or ghcr.io/netbirdio/netbird-server in $COMPOSE_FILE." > /dev/stderr
|
||||
echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr
|
||||
echo "This script targets the community combined-server deployment." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$DASHBOARD_SERVICE" ]]; then
|
||||
echo "Could not find a service running netbirdio/dashboard or ghcr.io/netbirdio/dashboard in $COMPOSE_FILE." > /dev/stderr
|
||||
echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$CONFIG_YAML_HOST" ]]; then
|
||||
@@ -756,15 +541,9 @@ init_migration() {
|
||||
ENABLE_FLOW="no"
|
||||
echo "Step 3 (traffic flow) skipped — requires Postgres."
|
||||
fi
|
||||
|
||||
check_data_directory
|
||||
check_stale_postgres_volume
|
||||
}
|
||||
|
||||
apply_changes() {
|
||||
# From here on a failure must roll the deployment back.
|
||||
ROLLBACK_STATE="armed"
|
||||
|
||||
echo ""
|
||||
echo "Writing $OVERRIDE_FILE ..."
|
||||
install -m 644 /dev/null "$OVERRIDE_FILE"
|
||||
@@ -785,14 +564,6 @@ apply_changes() {
|
||||
# picks it up automatically.
|
||||
echo "Writing .env additions (mode 600) ..."
|
||||
local ENV_FILE=".env"
|
||||
# Snapshot the operator's .env so a rollback can restore it byte for byte.
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
ENV_EXISTED="yes"
|
||||
ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)"
|
||||
cp -p "$ENV_FILE" "$ENV_BACKUP"
|
||||
else
|
||||
ENV_EXISTED="no"
|
||||
fi
|
||||
touch "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
{
|
||||
@@ -821,16 +592,11 @@ apply_changes() {
|
||||
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
echo ""
|
||||
# Stop, but keep the containers: the backup reads the store out of one.
|
||||
echo "Stopping services so the store is quiescent ..."
|
||||
$DOCKER_COMPOSE_COMMAND stop
|
||||
echo "Stopping existing services (volumes preserved) ..."
|
||||
$DOCKER_COMPOSE_COMMAND down
|
||||
|
||||
backup_sqlite
|
||||
|
||||
echo ""
|
||||
echo "Removing stopped containers (volumes preserved) ..."
|
||||
$DOCKER_COMPOSE_COMMAND down
|
||||
|
||||
echo ""
|
||||
echo "Starting Postgres ..."
|
||||
$DOCKER_COMPOSE_COMMAND up -d postgres
|
||||
@@ -860,9 +626,6 @@ apply_changes() {
|
||||
|
||||
echo ""
|
||||
echo "Migration complete."
|
||||
|
||||
# Nothing left to undo.
|
||||
ROLLBACK_STATE="disarmed"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
@@ -880,7 +643,6 @@ print_summary() {
|
||||
echo " $OVERRIDE_FILE"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||
echo " .env (license key + secrets, mode 600)"
|
||||
[[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)"
|
||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
|
||||
echo ""
|
||||
echo " Tail logs:"
|
||||
@@ -889,27 +651,19 @@ print_summary() {
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
echo " To revert"
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||
# Resolve the project-prefixed volume name now, before the override is gone.
|
||||
local pg_volume
|
||||
pg_volume=$(resolve_compose_volume "netbird_postgres")
|
||||
echo " # Stop, but keep the containers so the store can be copied back in:"
|
||||
echo " $DOCKER_COMPOSE_COMMAND stop"
|
||||
echo " # Restore SQLite from the backup created during this run:"
|
||||
echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||
# Resolve project-prefixed volume names now (before override is removed).
|
||||
local pg_volume data_volume_actual
|
||||
pg_volume=$(resolve_data_volume "netbird_postgres")
|
||||
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
|
||||
echo " # Remove the Postgres volume FIRST, before deleting the override file:"
|
||||
echo " docker volume rm $pg_volume"
|
||||
else
|
||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||
echo " # Restore SQLite from the backup created during this run:"
|
||||
echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
|
||||
fi
|
||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||
if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
|
||||
echo " mv $ENV_BACKUP .env # restores .env as it was before this run"
|
||||
elif [[ "$ENV_EXISTED" == "no" ]]; then
|
||||
echo " rm -f .env # created by this run"
|
||||
else
|
||||
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
|
||||
fi
|
||||
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
|
||||
echo " $DOCKER_COMPOSE_COMMAND up -d"
|
||||
echo "──────────────────────────────────────────────────────────────────────"
|
||||
}
|
||||
@@ -918,10 +672,6 @@ print_summary() {
|
||||
# Run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
trap on_exit EXIT
|
||||
# Turn signals into a normal exit so the EXIT trap can roll back.
|
||||
trap 'exit 130' INT TERM
|
||||
|
||||
init_migration
|
||||
apply_changes
|
||||
print_summary
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -176,7 +176,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
semaphore := make(chan struct{}, 10)
|
||||
|
||||
c.injectAllProxyPolicies(ctx, account)
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
dnsCache := &cache.DNSConfigCache{}
|
||||
dnsDomain := c.GetDNSDomain(account.Settings)
|
||||
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
|
||||
@@ -358,7 +357,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
|
||||
// network map that omitted the synth DNS zone, and the agent kept
|
||||
// resolving against the stale or absent record.
|
||||
c.injectAllProxyPolicies(ctx, account)
|
||||
account.PrecomputePostureValidation(ctx)
|
||||
dnsCache := &cache.DNSConfigCache{}
|
||||
dnsDomain := c.GetDNSDomain(account.Settings)
|
||||
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -62,23 +61,10 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
Return(true, context.Background(), nil).
|
||||
AnyTimes()
|
||||
|
||||
// Swallow activity events so the mutation paths (create/update/delete)
|
||||
// are exercisable through the HTTP layer.
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().
|
||||
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes()
|
||||
accounts.EXPECT().
|
||||
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes()
|
||||
|
||||
manager := agentnetwork.NewManager(st, perms, accounts, nil)
|
||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
||||
h := &handler{manager: manager}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
|
||||
h.addPolicyEndpoints(router)
|
||||
h.addConsumptionEndpoints(router)
|
||||
h.addBudgetRuleEndpoints(router)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
nethttp "net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -53,50 +51,3 @@ func TestValidate_ModelRates(t *testing.T) {
|
||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
|
||||
// with the other PUT endpoints: the request replaces the provider's mutable
|
||||
// state, so optional fields absent from the JSON land as their zero values.
|
||||
// The two exceptions are server-side: the api_key (a secret — omitted means
|
||||
// "not rotated") and the session keypair, both preserved by the manager. The
|
||||
// identity headers stay on the wire as explicit empty strings so a cleared
|
||||
// value round-trips.
|
||||
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
create := `{
|
||||
"provider_id": "openai_api",
|
||||
"name": "openai",
|
||||
"upstream_url": "https://api.openai.com",
|
||||
"api_key": "sk-test",
|
||||
"enabled": true,
|
||||
"metadata_disabled": true,
|
||||
"skip_tls_verification": true,
|
||||
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
|
||||
"identity_header_user_id": "x-bf-dim-netbird_user_id",
|
||||
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
|
||||
}`
|
||||
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
|
||||
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
|
||||
|
||||
var created api.AgentNetworkProvider
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
|
||||
|
||||
// Minimal update: only the required fields, no api_key. Everything
|
||||
// optional must land as its zero value.
|
||||
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
|
||||
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
|
||||
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
|
||||
|
||||
var updated api.AgentNetworkProvider
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
|
||||
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
|
||||
assert.True(t, updated.Enabled, "sent field must apply")
|
||||
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
|
||||
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
|
||||
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
|
||||
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
|
||||
assert.Empty(t, updated.Models, "omitted models must be cleared")
|
||||
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
|
||||
"cleared identity header must round-trip as an explicit empty string")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -10,20 +11,19 @@ import (
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// addSettingsEndpoints registers the Agent Network settings routes. The
|
||||
// settings row is bootstrapped server-side on first provider create or on the
|
||||
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
|
||||
// of the mutable collection toggles (cluster/subdomain stay immutable).
|
||||
// settings row is bootstrapped server-side on first provider create; GET reads
|
||||
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
|
||||
// immutable).
|
||||
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
||||
}
|
||||
|
||||
// updateSettings replaces the mutable settings fields on the account's row.
|
||||
// A request carrying a cluster bootstraps the row when the account doesn't
|
||||
// have one yet.
|
||||
// updateSettings applies the collection toggles to the account's settings row.
|
||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -48,9 +48,11 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
// getSettings returns the account's agent-network settings. Accounts that
|
||||
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
||||
// subdomain and endpoint; the manager synthesises that view.
|
||||
// getSettings returns the account's agent-network settings. The settings
|
||||
// row is bootstrapped on first provider create, so freshly-onboarded
|
||||
// accounts have nothing to read. Rather than 404-ing in that case (which
|
||||
// the dashboard would have to special-case), return a JSON null with 200
|
||||
// so consumers can branch on the body alone.
|
||||
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -60,6 +62,11 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
var sErr *status.Error
|
||||
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
|
||||
util.WriteJSONObject(r.Context(), w, nil)
|
||||
return
|
||||
}
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
|
||||
// convention shared with the account and DNS settings endpoints: settings
|
||||
// always read as a JSON object. Before bootstrap that object carries the
|
||||
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
|
||||
// signal) and no timestamps — never a 404 and never the legacy null body.
|
||||
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
|
||||
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
|
||||
"the legacy 200+null shape must not come back")
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
|
||||
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
|
||||
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
|
||||
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
assert.False(t, got.RedactPii, "defaults must show redaction off")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
|
||||
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
|
||||
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
|
||||
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
|
||||
// creates the row (cluster pinned, subdomain assigned) and applies the
|
||||
// mutable fields from the same request.
|
||||
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
|
||||
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
|
||||
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
|
||||
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
|
||||
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
|
||||
|
||||
// The row is now readable via GET.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
|
||||
// without a cluster cannot conjure a settings row out of nothing — there is
|
||||
// no cluster to pin — and surfaces as 404 like the GET.
|
||||
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "cluster",
|
||||
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
|
||||
// with the other PUT endpoints: the request replaces every mutable field, so a
|
||||
// toggle absent from the JSON lands as its zero value rather than being
|
||||
// preserved. Cluster and subdomain survive untouched.
|
||||
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
|
||||
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
|
||||
assert.False(t, got.RedactPii, "sent toggle must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 0, *got.AccessLogRetentionDays,
|
||||
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
|
||||
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
|
||||
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
|
||||
// assigned, a differing cluster is rejected as a validation error instead of
|
||||
// being silently ignored, so callers never observe a value other than the one
|
||||
// they sent. Echoing the assigned cluster back stays valid, which lets
|
||||
// declarative clients send their full desired state idempotently.
|
||||
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
|
||||
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
|
||||
}
|
||||
@@ -157,14 +157,14 @@ func NewManager(
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||
@@ -175,14 +175,9 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
|
||||
// been created yet; otherwise it is ignored (the cluster is pinned on
|
||||
// Settings and every provider in the account routes through it).
|
||||
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
|
||||
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// An empty api_key would silently produce a synthesised service
|
||||
// that 401s on every upstream request. Surface the misconfiguration
|
||||
@@ -207,7 +202,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
||||
}
|
||||
|
||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
|
||||
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
|
||||
// The provider create has already succeeded; logging the
|
||||
// bootstrap miss matches the plan's PoC behaviour. The synth
|
||||
// path treats a missing settings row as a no-op, and the next
|
||||
@@ -223,7 +218,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
||||
}
|
||||
|
||||
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
|
||||
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
|
||||
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -262,7 +257,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
|
||||
}
|
||||
|
||||
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -311,21 +306,21 @@ func pluralize(n int, singular, plural string) string {
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
||||
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
|
||||
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -351,7 +346,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
|
||||
}
|
||||
|
||||
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
|
||||
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
|
||||
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -378,7 +373,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
|
||||
}
|
||||
|
||||
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -398,21 +393,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
|
||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -434,7 +429,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
|
||||
}
|
||||
|
||||
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
|
||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
|
||||
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -457,7 +452,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
|
||||
}
|
||||
|
||||
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -478,7 +473,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
|
||||
|
||||
// GetAllBudgetRules returns every account-level budget rule for the account.
|
||||
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -486,7 +481,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
|
||||
|
||||
// GetBudgetRule returns a single account-level budget rule.
|
||||
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
|
||||
@@ -496,7 +491,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
|
||||
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
|
||||
// proxy config, so no reconcile is needed.
|
||||
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
||||
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
|
||||
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -518,7 +513,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
|
||||
|
||||
// UpdateBudgetRule updates an existing account-level budget rule.
|
||||
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
|
||||
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
|
||||
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -541,7 +536,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
|
||||
|
||||
// DeleteBudgetRule removes an account-level budget rule.
|
||||
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -559,83 +554,40 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSettings replaces the mutable account-level settings — the collection
|
||||
// toggles and retention — on the account's row. When the account has no
|
||||
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
|
||||
// first provider create); without it the update fails with NotFound. On an
|
||||
// existing row the cluster and subdomain are immutable: a differing
|
||||
// settings.Cluster is rejected rather than silently ignored so callers never
|
||||
// observe a value other than what they sent. Because the collection toggles
|
||||
// change the synthesised service config (prompt-capture gating, access-log
|
||||
// emission), a reconcile is triggered so the proxy and peer network maps
|
||||
// converge on the new state.
|
||||
// UpdateSettings applies the mutable account-level settings — the collection
|
||||
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
|
||||
// preserved from the persisted row regardless of the input. Because the
|
||||
// collection toggles change the synthesised service config (prompt-capture
|
||||
// gating, access-log emission), a reconcile is triggered so the proxy and peer
|
||||
// network maps converge on the new state.
|
||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestedCluster := strings.TrimSpace(settings.Cluster)
|
||||
|
||||
// The row lock from LockingStrengthUpdate only holds for the duration of
|
||||
// the surrounding transaction, so the read, the cluster-immutability
|
||||
// check, and the save must share one — otherwise concurrent PUTs could
|
||||
// interleave between them.
|
||||
var updated *types.Settings
|
||||
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
||||
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
if requestedCluster != "" && requestedCluster != existing.Cluster {
|
||||
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
|
||||
}
|
||||
case isNotFound(err):
|
||||
if requestedCluster == "" {
|
||||
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
|
||||
}
|
||||
// Bootstrapping pins the cluster and subdomain — a settings
|
||||
// create on top of the update the caller already passed, matching
|
||||
// the gate on the provider-create bootstrap path.
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
existing.EnableLogCollection = settings.EnableLogCollection
|
||||
existing.EnablePromptCollection = settings.EnablePromptCollection
|
||||
existing.RedactPii = settings.RedactPii
|
||||
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
|
||||
return fmt.Errorf("save agent network settings: %w", err)
|
||||
}
|
||||
updated = existing
|
||||
return nil
|
||||
})
|
||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
existing.EnableLogCollection = settings.EnableLogCollection
|
||||
existing.EnablePromptCollection = settings.EnablePromptCollection
|
||||
existing.RedactPii = settings.RedactPii
|
||||
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil {
|
||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
|
||||
"log_collection": updated.EnableLogCollection,
|
||||
"prompt_collection": updated.EnablePromptCollection,
|
||||
"redact_pii": updated.RedactPii,
|
||||
"log_collection": existing.EnableLogCollection,
|
||||
"prompt_collection": existing.EnablePromptCollection,
|
||||
"redact_pii": existing.RedactPii,
|
||||
})
|
||||
m.reconcile(ctx, settings.AccountID)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// isNotFound reports whether err is a status.NotFound error.
|
||||
func isNotFound(err error) bool {
|
||||
var sErr *status.Error
|
||||
return errors.As(err, &sErr) && sErr.Type() == status.NotFound
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// validateProviderRefs ensures every destination provider id refers to a
|
||||
@@ -659,38 +611,14 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSettings returns the agent-network settings row for the account. When no
|
||||
// row has been bootstrapped yet, the defaults are returned (without
|
||||
// persisting) with cluster and subdomain empty — settings always read as an
|
||||
// object, like the account and DNS settings endpoints.
|
||||
// GetSettings returns the agent-network settings row for the account.
|
||||
// Returns the underlying status.NotFound when no row has been
|
||||
// bootstrapped yet (i.e. the account has no providers).
|
||||
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return settings, nil
|
||||
case isNotFound(err):
|
||||
return types.DefaultSettings(accountID), nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||
// first provider create performs. Pinning the account's cluster and subdomain
|
||||
// is a settings write, so it needs the settings permission on top of the
|
||||
// provider one. No-op once the settings row exists.
|
||||
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
|
||||
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||
@@ -698,9 +626,8 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
|
||||
// hint the dashboard sends (auto-picked from the active cluster list);
|
||||
// the subdomain is picked from the curated wordlist avoiding
|
||||
// collisions on the same cluster. Idempotent: if a row already exists
|
||||
// it is returned untouched and the hint is ignored. st is the store to
|
||||
// operate on — pass the transaction store when calling from within one.
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
|
||||
// it is returned untouched and the hint is ignored.
|
||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||
}
|
||||
@@ -708,15 +635,16 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
|
||||
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
||||
}
|
||||
|
||||
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
var sErr *status.Error
|
||||
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||
}
|
||||
@@ -735,12 +663,18 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
|
||||
m.labelRngMu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
settings := types.DefaultSettings(accountID)
|
||||
settings.Cluster = providerCluster
|
||||
settings.Subdomain = subdomain
|
||||
settings.CreatedAt = now
|
||||
settings.UpdatedAt = now
|
||||
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||
settings := &types.Settings{
|
||||
AccountID: accountID,
|
||||
Cluster: providerCluster,
|
||||
Subdomain: subdomain,
|
||||
// Logs on by default; usage is collected regardless. Retention bounds
|
||||
// how long full log rows are kept.
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||
return nil, fmt.Errorf("save agent network settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
@@ -751,7 +685,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.St
|
||||
// counter view; permission gate is the same Read role that gates
|
||||
// every other agent-network surface.
|
||||
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
|
||||
@@ -760,7 +694,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
|
||||
// ListAccessLogs returns a paginated, server-side-filtered page of
|
||||
// agent-network access logs plus the total count matching the filter.
|
||||
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -770,7 +704,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
|
||||
// agent-network access logs grouped by session, plus the total number of
|
||||
// sessions matching the filter.
|
||||
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -779,7 +713,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
|
||||
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
|
||||
// at the requested granularity, oldest-first.
|
||||
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
|
||||
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
|
||||
@@ -853,8 +787,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
|
||||
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
|
||||
}
|
||||
|
||||
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
|
||||
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
|
||||
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
@@ -943,8 +877,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
|
||||
|
||||
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
|
||||
|
||||
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
|
||||
return types.DefaultSettings(accountID), nil
|
||||
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
|
||||
return nil, status.Errorf(status.NotFound, "agent network settings not found")
|
||||
}
|
||||
|
||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
||||
// so tests can grant the provider permission while denying (or never
|
||||
// expecting) the settings one.
|
||||
type bootstrapFixture struct {
|
||||
manager Manager
|
||||
store store.Store
|
||||
perms *permissions.MockManager
|
||||
}
|
||||
|
||||
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
perms := permissions.NewMockManager(ctrl)
|
||||
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil),
|
||||
store: st,
|
||||
perms: perms,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
||||
f.perms.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
||||
Return(allowed, context.Background(), nil)
|
||||
}
|
||||
|
||||
func newBootstrapProvider(accountID string) *types.Provider {
|
||||
p := types.NewProvider(accountID)
|
||||
p.Name = "openai"
|
||||
p.UpstreamURL = "https://api.openai.com"
|
||||
p.APIKey = "sk-test"
|
||||
p.Enabled = true
|
||||
return p
|
||||
}
|
||||
|
||||
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
|
||||
// one-time settings bootstrap: creating the first provider with a
|
||||
// bootstrap_cluster pins the account's cluster and subdomain, which is a
|
||||
// settings write and must not ride on the providers permission alone.
|
||||
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("denied without settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.Error(t, err, "bootstrap without settings permission must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
||||
|
||||
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
||||
})
|
||||
|
||||
t.Run("allowed with settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.NoError(t, err, "bootstrap with both permissions must succeed")
|
||||
require.NotNil(t, created)
|
||||
|
||||
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err, "bootstrap must create the settings row")
|
||||
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
|
||||
})
|
||||
|
||||
t.Run("existing settings need no settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
|
||||
AccountID: "account1",
|
||||
Cluster: "cluster1.example.com",
|
||||
Subdomain: "existing",
|
||||
}), "pre-existing settings row setup must succeed")
|
||||
|
||||
// Only the providers permission may be consulted: gomock fails the
|
||||
// test on any unexpected settings-permission call.
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
|
||||
require.NoError(t, err, "create with existing settings must not require the settings permission")
|
||||
})
|
||||
|
||||
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
|
||||
require.NoError(t, err, "create without bootstrap must not require the settings permission")
|
||||
})
|
||||
}
|
||||
@@ -164,7 +164,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
||||
p.MetadataDisabled = *req.MetadataDisabled
|
||||
}
|
||||
// Identity-header overrides for catalogs flagged Customizable.
|
||||
// Empty or omitted disables stamping for this dimension.
|
||||
// nil pointer = "field omitted on the wire" → leave the stored
|
||||
// value untouched (per the openapi description). Empty string is
|
||||
// an explicit clear that disables stamping for this dimension.
|
||||
if req.IdentityHeaderUserId != nil {
|
||||
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
||||
}
|
||||
@@ -190,20 +192,16 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
||||
created := p.CreatedAt
|
||||
updated := p.UpdatedAt
|
||||
resp := &api.AgentNetworkProvider{
|
||||
Id: p.ID,
|
||||
ProviderId: p.ProviderID,
|
||||
Name: p.Name,
|
||||
UpstreamUrl: p.UpstreamURL,
|
||||
Models: models,
|
||||
// Always present on the wire so an explicitly cleared header
|
||||
// round-trips as "" instead of vanishing from the response.
|
||||
IdentityHeaderUserId: p.IdentityHeaderUserID,
|
||||
IdentityHeaderGroups: p.IdentityHeaderGroups,
|
||||
Enabled: p.Enabled,
|
||||
SkipTlsVerification: p.SkipTLSVerification,
|
||||
MetadataDisabled: p.MetadataDisabled,
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
Id: p.ID,
|
||||
ProviderId: p.ProviderID,
|
||||
Name: p.Name,
|
||||
UpstreamUrl: p.UpstreamURL,
|
||||
Models: models,
|
||||
Enabled: p.Enabled,
|
||||
SkipTlsVerification: p.SkipTLSVerification,
|
||||
MetadataDisabled: p.MetadataDisabled,
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
if len(p.ExtraValues) > 0 {
|
||||
out := make(map[string]string, len(p.ExtraValues))
|
||||
@@ -212,6 +210,14 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
||||
}
|
||||
resp.ExtraValues = &out
|
||||
}
|
||||
if p.IdentityHeaderUserID != "" {
|
||||
v := p.IdentityHeaderUserID
|
||||
resp.IdentityHeaderUserId = &v
|
||||
}
|
||||
if p.IdentityHeaderGroups != "" {
|
||||
v := p.IdentityHeaderGroups
|
||||
resp.IdentityHeaderGroups = &v
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -77,41 +77,3 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
|
||||
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
||||
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
|
||||
}
|
||||
|
||||
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
|
||||
// fields are always present in the API response — an explicitly cleared
|
||||
// ("") header must round-trip as "" rather than vanish, so API consumers
|
||||
// (e.g. the Terraform provider) never observe a value other than the one
|
||||
// they wrote.
|
||||
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
|
||||
set := "x-bf-dim-netbird_user_id"
|
||||
empty := ""
|
||||
|
||||
base := func() *api.AgentNetworkProviderRequest {
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "custom",
|
||||
Name: "bifrost",
|
||||
UpstreamUrl: "https://bifrost.internal",
|
||||
}
|
||||
}
|
||||
|
||||
p := NewProvider("acc-1")
|
||||
resp := p.ToAPIResponse()
|
||||
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
|
||||
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
|
||||
|
||||
req := base()
|
||||
req.IdentityHeaderUserId = &set
|
||||
p.FromAPIRequest(req)
|
||||
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
|
||||
|
||||
// Omitting the field preserves it.
|
||||
p.FromAPIRequest(base())
|
||||
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
|
||||
|
||||
// An explicit "" clears it AND stays visible on the wire.
|
||||
req = base()
|
||||
req.IdentityHeaderUserId = &empty
|
||||
p.FromAPIRequest(req)
|
||||
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
@@ -43,34 +42,18 @@ type Settings struct {
|
||||
// schema cohesive.
|
||||
func (Settings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// DefaultSettings returns the settings an account observes before its row is
|
||||
// bootstrapped: log collection on with the default retention, everything else
|
||||
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
|
||||
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
|
||||
// and the freshly bootstrapped row agree.
|
||||
func DefaultSettings(accountID string) *Settings {
|
||||
return &Settings{
|
||||
AccountID: accountID,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at:
|
||||
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
|
||||
// `<subdomain>.<cluster>`.
|
||||
func (s *Settings) Endpoint() string {
|
||||
if s.Cluster == "" || s.Subdomain == "" {
|
||||
return ""
|
||||
}
|
||||
return s.Subdomain + "." + s.Cluster
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the settings as the API representation. The
|
||||
// timestamps are omitted while zero — a default (not yet bootstrapped) view
|
||||
// has no persisted row to date.
|
||||
// ToAPIResponse renders the settings as the API representation.
|
||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
created := s.CreatedAt
|
||||
updated := s.UpdatedAt
|
||||
retention := s.AccessLogRetentionDays
|
||||
resp := &api.AgentNetworkSettings{
|
||||
return &api.AgentNetworkSettings{
|
||||
Cluster: s.Cluster,
|
||||
Subdomain: s.Subdomain,
|
||||
Endpoint: s.Endpoint(),
|
||||
@@ -78,27 +61,14 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
EnablePromptCollection: s.EnablePromptCollection,
|
||||
RedactPii: s.RedactPii,
|
||||
AccessLogRetentionDays: &retention,
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
if !s.CreatedAt.IsZero() {
|
||||
created := s.CreatedAt
|
||||
resp.CreatedAt = &created
|
||||
}
|
||||
if !s.UpdatedAt.IsZero() {
|
||||
updated := s.UpdatedAt
|
||||
resp.UpdatedAt = &updated
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request onto the receiver. The mutable
|
||||
// collection fields are always replaced with the request values. Cluster
|
||||
// participates only in bootstrap and the immutability check (see
|
||||
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
|
||||
// from a request.
|
||||
// FromAPIRequest applies the mutable settings fields from the request. Cluster
|
||||
// and Subdomain are immutable and intentionally not touched here.
|
||||
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
||||
if req.Cluster != nil {
|
||||
s.Cluster = strings.TrimSpace(*req.Cluster)
|
||||
}
|
||||
s.EnableLogCollection = req.EnableLogCollection
|
||||
s.EnablePromptCollection = req.EnablePromptCollection
|
||||
s.RedactPii = req.RedactPii
|
||||
|
||||
@@ -24,13 +24,13 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/formatter/hook"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
||||
@@ -184,10 +184,6 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
|
||||
}
|
||||
|
||||
// Append interceptors contributed by registered gRPC extensions. These
|
||||
// run after the built-in chain (ChainUnaryInterceptor is additive).
|
||||
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
|
||||
|
||||
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
||||
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
||||
if err != nil {
|
||||
@@ -219,9 +215,6 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
||||
log.Info("ProxyService registered on gRPC server")
|
||||
|
||||
// Register services contributed by external modules via the extension seam.
|
||||
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
|
||||
|
||||
return gRPCAPIHandler
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// GRPCExtension bundles an external module's contribution to the management
|
||||
// gRPC server: the registration of one or more services onto the shared
|
||||
// grpc.Server, any server-wide interceptors those services require, and an
|
||||
// optional shutdown hook. It is a generic extension point with no knowledge of
|
||||
// any specific service.
|
||||
type GRPCExtension struct {
|
||||
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
|
||||
// after the built-in services are registered. It may register any number of
|
||||
// services. May be nil.
|
||||
Register func(grpc.ServiceRegistrar)
|
||||
// UnaryInterceptors are appended to the server's unary interceptor chain,
|
||||
// running after the built-in interceptors. May be empty.
|
||||
UnaryInterceptors []grpc.UnaryServerInterceptor
|
||||
// StreamInterceptors are appended to the server's stream interceptor chain,
|
||||
// running after the built-in interceptors. May be empty.
|
||||
StreamInterceptors []grpc.StreamServerInterceptor
|
||||
// Shutdown, if non-nil, is called once during Stop() with the context
|
||||
// governing server shutdown, which carries a deadline. The hook MUST
|
||||
// return promptly and MUST abandon its work once that context is
|
||||
// cancelled or expires: it runs before the rest of Stop()'s cleanup
|
||||
// (store, event store, embedded IdP) and before Stop() itself checks the
|
||||
// context's deadline, so a hook that ignores the context will delay all
|
||||
// of that cleanup and prevent Stop() from returning on time. May be nil.
|
||||
Shutdown func(ctx context.Context)
|
||||
}
|
||||
|
||||
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
|
||||
// is first built (i.e. before Start); registrations after that have no effect.
|
||||
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
|
||||
s.grpcExtensions = append(s.grpcExtensions, ext)
|
||||
}
|
||||
|
||||
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
|
||||
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
|
||||
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
|
||||
// extension interceptors after any interceptors already present in opts.
|
||||
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
|
||||
for _, ext := range exts {
|
||||
if len(ext.UnaryInterceptors) > 0 {
|
||||
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
|
||||
}
|
||||
if len(ext.StreamInterceptors) > 0 {
|
||||
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// registerExtensions registers each extension's services onto reg.
|
||||
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
|
||||
for _, ext := range exts {
|
||||
if ext.Register != nil {
|
||||
ext.Register(reg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
|
||||
// passing ctx through so hooks can honor its deadline/cancellation.
|
||||
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
|
||||
for _, ext := range exts {
|
||||
if ext.Shutdown != nil {
|
||||
ext.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health"
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
// Test that an extension's interceptors and service registration are actually
|
||||
// wired onto a real in-process gRPC server via the helpers, and that shutdown
|
||||
// hooks run. This validates the load-bearing assumption that
|
||||
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
|
||||
// addition to any base chain).
|
||||
func TestGRPCExtensionAppliedToServer(t *testing.T) {
|
||||
var unaryCalls atomic.Int32
|
||||
var streamShutdownCalled atomic.Bool
|
||||
|
||||
ext := GRPCExtension{
|
||||
Register: func(reg grpc.ServiceRegistrar) {
|
||||
healthgrpc.RegisterHealthServer(reg, health.NewServer())
|
||||
},
|
||||
UnaryInterceptors: []grpc.UnaryServerInterceptor{
|
||||
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
unaryCalls.Add(1)
|
||||
return handler(ctx, req)
|
||||
},
|
||||
},
|
||||
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
|
||||
}
|
||||
exts := []GRPCExtension{ext}
|
||||
|
||||
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
|
||||
var baseUnaryCalls atomic.Int32
|
||||
opts := []grpc.ServerOption{
|
||||
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
baseUnaryCalls.Add(1)
|
||||
return handler(ctx, req)
|
||||
}),
|
||||
}
|
||||
opts = appendExtensionInterceptors(opts, exts)
|
||||
|
||||
srv := grpc.NewServer(opts...)
|
||||
registerExtensions(srv, exts)
|
||||
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("health check via extension-registered service failed: %v", err)
|
||||
}
|
||||
if baseUnaryCalls.Load() != 1 {
|
||||
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
|
||||
}
|
||||
if unaryCalls.Load() != 1 {
|
||||
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
|
||||
}
|
||||
|
||||
runExtensionShutdownHooks(context.Background(), exts)
|
||||
if !streamShutdownCalled.Load() {
|
||||
t.Error("extension shutdown hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
|
||||
// a non-nil context and that it is the very same context the caller passed
|
||||
// in, so hooks can rely on values/deadlines placed on it by Stop().
|
||||
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
|
||||
type sentinelKey struct{}
|
||||
want := "shutdown-ctx-sentinel"
|
||||
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
|
||||
|
||||
var called bool
|
||||
ext := GRPCExtension{
|
||||
Shutdown: func(hookCtx context.Context) {
|
||||
called = true
|
||||
if hookCtx == nil {
|
||||
t.Fatal("hook received a nil context")
|
||||
}
|
||||
got, _ := hookCtx.Value(sentinelKey{}).(string)
|
||||
if got != want {
|
||||
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
||||
if !called {
|
||||
t.Fatal("shutdown hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
|
||||
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
|
||||
// context must see ctx.Err() != nil and a closed Done() channel.
|
||||
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
var called bool
|
||||
ext := GRPCExtension{
|
||||
Shutdown: func(hookCtx context.Context) {
|
||||
called = true
|
||||
if hookCtx.Err() == nil {
|
||||
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
|
||||
}
|
||||
select {
|
||||
case <-hookCtx.Done():
|
||||
default:
|
||||
t.Error("hook context Done() channel is not closed for a cancelled context")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
|
||||
if !called {
|
||||
t.Fatal("shutdown hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
|
||||
// with a nil Shutdown hook is skipped without panicking, and that hooks for
|
||||
// other extensions still run.
|
||||
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
exts := []GRPCExtension{
|
||||
{Shutdown: nil},
|
||||
{Shutdown: func(context.Context) { called.Store(true) }},
|
||||
}
|
||||
|
||||
runExtensionShutdownHooks(context.Background(), exts)
|
||||
if !called.Load() {
|
||||
t.Error("shutdown hook for non-nil extension was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
|
||||
s := &BaseServer{}
|
||||
s.RegisterGRPCExtension(GRPCExtension{})
|
||||
s.RegisterGRPCExtension(GRPCExtension{})
|
||||
if len(s.grpcExtensions) != 2 {
|
||||
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,6 @@ type BaseServer struct {
|
||||
|
||||
proxyAuthClose func()
|
||||
|
||||
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
|
||||
// hooks registered by external modules via RegisterGRPCExtension. Populated
|
||||
// during boot (single-threaded), consumed by GRPCServer() and Stop().
|
||||
grpcExtensions []GRPCExtension
|
||||
|
||||
listener net.Listener
|
||||
certManager *autocert.Manager
|
||||
update *version.Update
|
||||
@@ -262,7 +257,6 @@ func (s *BaseServer) Stop() error {
|
||||
s.proxyAuthClose()
|
||||
s.proxyAuthClose = nil
|
||||
}
|
||||
runExtensionShutdownHooks(ctx, s.grpcExtensions)
|
||||
_ = s.Store().Close(ctx)
|
||||
_ = s.EventStore().Close(ctx)
|
||||
if s.update != nil {
|
||||
|
||||
@@ -61,8 +61,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
return &proto.NetworkMapEnvelope{
|
||||
Payload: &proto.NetworkMapEnvelope_Full{
|
||||
Full: &proto.NetworkMapComponentsFull{
|
||||
Serial: networkSerial(c.Network),
|
||||
Network: toAccountNetwork(c.Network),
|
||||
PeerConfig: in.PeerConfig,
|
||||
// components.Peers always contains the target peer
|
||||
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},
|
||||
|
||||
@@ -758,9 +758,6 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
|
||||
assert.Equal(t, "netbird.cloud", full.DnsDomain)
|
||||
assert.Len(t, full.Peers, 1)
|
||||
assert.Empty(t, full.Policies)
|
||||
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
|
||||
assert.Equal(t, "net-empty", full.Network.Identifier)
|
||||
assert.Equal(t, uint64(9), full.Serial)
|
||||
}
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||
@@ -779,12 +776,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||
func emptyNetworkMapComponents() *types.NetworkMapComponents {
|
||||
return types.EmptyNetworkMapComponents(
|
||||
&types.NetworkMapComponents{
|
||||
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
|
||||
Network: &types.Network{
|
||||
Identifier: "net-empty",
|
||||
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
|
||||
Serial: 9,
|
||||
},
|
||||
},
|
||||
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
@@ -37,6 +36,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
@@ -1579,62 +1579,9 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
|
||||
return verifier, redirectURL, nil
|
||||
}
|
||||
|
||||
// Denied reasons reported to the proxy when access is refused because of the
|
||||
// account status of the user behind the request.
|
||||
const (
|
||||
deniedReasonPendingApproval = "pending_approval"
|
||||
deniedReasonUserBlocked = "user_blocked"
|
||||
deniedReasonUserNotFound = "user_not_found"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUserPendingApproval reports a user whose account still awaits approval
|
||||
// by an administrator and may therefore not hold a proxy session.
|
||||
ErrUserPendingApproval = errors.New("user pending approval")
|
||||
|
||||
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
|
||||
ErrUserBlocked = errors.New("user blocked")
|
||||
|
||||
errUserUnresolved = errors.New("user could not be resolved")
|
||||
)
|
||||
|
||||
// checkUserStatus reports whether the user's account status permits reverse
|
||||
// proxy access, returning the denied reason for the proxy access log together
|
||||
// with the sentinel error callers match on. A user awaiting approval is stored
|
||||
// as both pending and blocked, so the pending state is reported first: it is
|
||||
// the one an administrator can act on.
|
||||
func checkUserStatus(user *types.User) (string, error) {
|
||||
switch {
|
||||
case user == nil:
|
||||
return deniedReasonUserNotFound, errUserUnresolved
|
||||
case user.PendingApproval:
|
||||
return deniedReasonPendingApproval, ErrUserPendingApproval
|
||||
case user.IsBlocked():
|
||||
return deniedReasonUserBlocked, ErrUserBlocked
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// userStatusDeniedReason returns the denied reason for callers that report a
|
||||
// decision rather than an error, and an empty string when the user may proceed.
|
||||
func userStatusDeniedReason(user *types.User) string {
|
||||
reason, _ := checkUserStatus(user)
|
||||
return reason
|
||||
}
|
||||
|
||||
// sameAccount reports whether a user belongs to a service's account. An empty
|
||||
// identifier on either side never matches: two unset accounts must not compare
|
||||
// equal into a grant.
|
||||
func sameAccount(userAccountID, serviceAccountID string) bool {
|
||||
return userAccountID != "" && serviceAccountID != "" && userAccountID == serviceAccountID
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed session JWT for the given domain and
|
||||
// user. The user's group memberships are embedded in the token so policy-aware
|
||||
// middlewares on the proxy can authorise without an extra management round-trip.
|
||||
// A user the store cannot resolve, or whose account is pending approval or
|
||||
// blocked, gets no token at all, so the browser never receives a session cookie.
|
||||
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
||||
service, err := s.getServiceByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
@@ -1645,37 +1592,25 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
return "", fmt.Errorf("no session key configured for domain: %s", domain)
|
||||
}
|
||||
|
||||
if s.usersManager == nil {
|
||||
return "", errors.New("users manager not configured")
|
||||
var (
|
||||
email string
|
||||
groupIDs []string
|
||||
groupNames []string
|
||||
)
|
||||
if s.usersManager != nil {
|
||||
user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if uerr != nil {
|
||||
log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
|
||||
} else if user != nil {
|
||||
email = user.Email
|
||||
groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
|
||||
}
|
||||
}
|
||||
|
||||
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get user %s: %w", userID, err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("get user %s: %w", userID, errUserUnresolved)
|
||||
}
|
||||
|
||||
// Bind the OIDC identity to the service's account before signing anything
|
||||
// with that service's session key. The proxy validates an installed cookie
|
||||
// locally against the service public key, so a token minted for a user of
|
||||
// another account would be honoured without a management round-trip.
|
||||
if !sameAccount(user.AccountID, service.AccountID) {
|
||||
return "", fmt.Errorf("user %s does not belong to the service account", userID)
|
||||
}
|
||||
|
||||
if _, err := checkUserStatus(user); err != nil {
|
||||
return "", fmt.Errorf("session token for user %s: %w", userID, err)
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
return sessionkey.SignToken(
|
||||
service.SessionPrivateKey,
|
||||
userID,
|
||||
user.Email,
|
||||
email,
|
||||
domain,
|
||||
method,
|
||||
groupIDs,
|
||||
@@ -1693,10 +1628,6 @@ func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain
|
||||
return fmt.Errorf("user not found: %s", userID)
|
||||
}
|
||||
|
||||
if _, err := checkUserStatus(user); err != nil {
|
||||
return fmt.Errorf("user %s denied access to domain %s: %w", userID, domain, err)
|
||||
}
|
||||
|
||||
service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1751,7 +1682,10 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
sessionToken := req.GetSessionToken()
|
||||
|
||||
if domain == "" || sessionToken == "" {
|
||||
return deniedSessionResponse("missing domain or session_token"), nil
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "missing domain or session_token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
service, err := s.getServiceByDomain(ctx, domain)
|
||||
@@ -1761,49 +1695,83 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: service not found")
|
||||
//nolint:nilerr
|
||||
return deniedSessionResponse("service_not_found"), nil
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "service_not_found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID, reason := sessionTokenSubject(domain, service, sessionToken)
|
||||
if reason != "" {
|
||||
return deniedSessionResponse(reason), nil
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Error("ValidateSession: decode public key")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "invalid_service_config",
|
||||
}, nil
|
||||
}
|
||||
|
||||
userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: invalid session token")
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "invalid_token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"error": err,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: user not found")
|
||||
//nolint:nilerr
|
||||
return deniedSessionResponse(deniedReasonUserNotFound), nil
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "user_not_found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// A user from another account gets a bare response: none of their identity
|
||||
// belongs in an answer to a proxy serving a different account.
|
||||
if !sameAccount(user.AccountID, service.AccountID) {
|
||||
if user.AccountID != service.AccountID {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"user_account": user.AccountID,
|
||||
"service_account": service.AccountID,
|
||||
}).Debug("ValidateSession: user account mismatch")
|
||||
return deniedSessionResponse("account_mismatch"), nil
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: "account_mismatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
|
||||
if err := s.checkGroupAccess(service, user); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": userID,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: access denied")
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
//nolint:nilerr
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
UserId: user.Id,
|
||||
UserEmail: user.Email,
|
||||
DeniedReason: reason,
|
||||
DeniedReason: "not_in_group",
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
}, nil
|
||||
@@ -1815,6 +1783,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
"email": user.Email,
|
||||
}).Debug("ValidateSession: access granted")
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: true,
|
||||
UserId: user.Id,
|
||||
@@ -1824,66 +1793,6 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deniedSessionResponse builds a denial that carries no identity, for the
|
||||
// checks that run before a user of this service's account is resolved.
|
||||
func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
|
||||
return &proto.ValidateSessionResponse{
|
||||
Valid: false,
|
||||
DeniedReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionTokenSubject verifies the session token against the service's session
|
||||
// key and returns the user it was minted for, or the reason it cannot be
|
||||
// trusted.
|
||||
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
|
||||
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Error("ValidateSession: decode public key")
|
||||
return "", "invalid_service_config"
|
||||
}
|
||||
|
||||
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: invalid session token")
|
||||
return "", "invalid_token"
|
||||
}
|
||||
|
||||
return userID, ""
|
||||
}
|
||||
|
||||
// accountUserDeniedReason gates a user of the service's own account, returning
|
||||
// an empty string when access is granted. Account status comes before group
|
||||
// membership: a user awaiting approval or blocked has no access regardless of
|
||||
// the groups they were auto-assigned.
|
||||
func (s *ProxyServiceServer) accountUserDeniedReason(domain string, service *rpservice.Service, user *types.User) string {
|
||||
if reason := userStatusDeniedReason(user); reason != "" {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": user.Id,
|
||||
"reason": reason,
|
||||
}).Debug("ValidateSession: user status denies access")
|
||||
return reason
|
||||
}
|
||||
|
||||
if err := s.checkGroupAccess(service, user); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"user_id": user.Id,
|
||||
"error": err.Error(),
|
||||
}).Debug("ValidateSession: access denied")
|
||||
return "not_in_group"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
|
||||
service, err := s.serviceManager.GetServiceByDomain(ctx, domain)
|
||||
if err == nil {
|
||||
@@ -1998,20 +1907,7 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
|
||||
owner := s.resolvePeerOwner(ctx, peer, service.AccountID)
|
||||
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer, owner)
|
||||
|
||||
if reason := peerOwnerDeniedReason(peer, owner); reason != "" {
|
||||
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "user_id": peer.UserID, "reason": reason}).Debug("ValidateTunnelPeer: owner status denies access")
|
||||
return &proto.ValidateTunnelPeerResponse{
|
||||
Valid: false,
|
||||
UserId: principalID,
|
||||
UserEmail: displayIdentity,
|
||||
DeniedReason: reason,
|
||||
PeerGroupIds: groupIDs,
|
||||
PeerGroupNames: groupNames,
|
||||
}, nil
|
||||
}
|
||||
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
|
||||
|
||||
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
|
||||
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
|
||||
@@ -2048,55 +1944,9 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolvePeerOwner returns the user a peer is linked to, once per request so
|
||||
// the status gate and the identity resolution below share a single lookup.
|
||||
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
|
||||
// nil rather than an error: both callers treat an unresolved owner the same
|
||||
// way, and neither may trust one it could not read.
|
||||
func (s *ProxyServiceServer) resolvePeerOwner(ctx context.Context, peer *peer.Peer, accountID string) *types.User {
|
||||
if peer.UserID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := s.usersManager.GetUser(ctx, peer.UserID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("ValidateTunnelPeer: look up owner %s of peer %s: %v", peer.UserID, peer.ID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The lookup is by user ID alone, so a peer row pointing outside the
|
||||
// service's account would otherwise resolve a foreign user. Leave the owner
|
||||
// unresolved instead: the gate denies it, and neither the response nor the
|
||||
// minted token carries an identity from another account.
|
||||
if !sameAccount(user.AccountID, accountID) {
|
||||
log.WithContext(ctx).Debugf("ValidateTunnelPeer: owner %s of peer %s belongs to another account", peer.UserID, peer.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// peerOwnerDeniedReason gates the mesh fast-path on the account status of the
|
||||
// peer's owning user, so a user blocked after registering a peer loses
|
||||
// mesh-origin access too. Unlinked peers (machine agents) have no owner to gate
|
||||
// on and stay first-class callers. An owner the store cannot resolve denies:
|
||||
// an unavailable lookup must not grant access.
|
||||
func peerOwnerDeniedReason(peer *peer.Peer, owner *types.User) string {
|
||||
if peer.UserID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if owner == nil {
|
||||
return deniedReasonUserNotFound
|
||||
}
|
||||
|
||||
return userStatusDeniedReason(owner)
|
||||
}
|
||||
|
||||
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
|
||||
// user or peer ID, and peer name or user email. owner is the already-resolved
|
||||
// user the peer is linked to, or nil.
|
||||
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer, owner *types.User) (string, string) {
|
||||
// user or peer ID, and peer name or user email.
|
||||
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {
|
||||
// Resolve the principal: when the peer is linked to a user, the human is the
|
||||
// principal so multiple peers owned by the same user share a single
|
||||
// identity. Unlinked peers (machine agents) are their own principal keyed on
|
||||
@@ -2113,10 +1963,10 @@ func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain strin
|
||||
principalID := peer.UserID
|
||||
displayIdentity := peer.Name
|
||||
// Stored column first (cheap, but often empty for OIDC-provisioned users).
|
||||
if owner != nil {
|
||||
principalID = owner.Id
|
||||
if owner.Email != "" {
|
||||
displayIdentity = owner.Email
|
||||
if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
|
||||
principalID = user.Id
|
||||
if user.Email != "" {
|
||||
displayIdentity = user.Email
|
||||
}
|
||||
}
|
||||
// IdP enrichment wins when available — the stored email column is a
|
||||
|
||||
@@ -119,13 +119,11 @@ func (m *mockReverseProxyManager) GetClusters(_ context.Context, _, _ string) ([
|
||||
}
|
||||
|
||||
type mockUsersManager struct {
|
||||
users map[string]*types.User
|
||||
err error
|
||||
getUserCalls int
|
||||
users map[string]*types.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
||||
m.getUserCalls++
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
@@ -352,64 +350,6 @@ func TestValidateUserGroupAccess(t *testing.T) {
|
||||
},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "user pending approval denied despite group membership",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{
|
||||
Domain: "app.example.com",
|
||||
AccountID: "account1",
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
// The approval flow stores a pending user as blocked as well.
|
||||
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true, PendingApproval: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user pending approval",
|
||||
},
|
||||
{
|
||||
name: "blocked user denied despite group membership",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{
|
||||
Domain: "app.example.com",
|
||||
AccountID: "account1",
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1"}, Blocked: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user blocked",
|
||||
},
|
||||
{
|
||||
name: "blocked user denied on a service with no auth configured",
|
||||
domain: "app.example.com",
|
||||
userID: "user1",
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
"account1": {{Domain: "app.example.com", AccountID: "account1", Auth: service.AuthConfig{}}},
|
||||
},
|
||||
users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: "account1", Blocked: true},
|
||||
},
|
||||
expectErr: true,
|
||||
expectErrMsg: "user blocked",
|
||||
},
|
||||
{
|
||||
name: "proxy manager error",
|
||||
domain: "app.example.com",
|
||||
@@ -481,18 +421,17 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
peerUserID string
|
||||
storedUsers map[string]*types.User
|
||||
storedErr error
|
||||
noIdP bool
|
||||
idpEmail string
|
||||
idpHasData bool
|
||||
idpErr error
|
||||
expectEmail string
|
||||
expectUserID string
|
||||
expectIdPHit bool
|
||||
expectDeniedReason string
|
||||
name string
|
||||
peerUserID string
|
||||
storedUsers map[string]*types.User
|
||||
storedErr error
|
||||
noIdP bool
|
||||
idpEmail string
|
||||
idpHasData bool
|
||||
idpErr error
|
||||
expectEmail string
|
||||
expectUserID string
|
||||
expectIdPHit bool
|
||||
}{
|
||||
{
|
||||
name: "idp email wins over stored email",
|
||||
@@ -551,17 +490,14 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
expectIdPHit: true,
|
||||
},
|
||||
{
|
||||
// The identity still resolves from the IdP, but an owner the store
|
||||
// cannot resolve denies the fast-path rather than granting it.
|
||||
name: "idp email when stored user missing keeps peer.UserID as principal",
|
||||
peerUserID: userID,
|
||||
storedUsers: map[string]*types.User{},
|
||||
idpEmail: "idp@example.com",
|
||||
idpHasData: true,
|
||||
expectEmail: "idp@example.com",
|
||||
expectUserID: userID,
|
||||
expectIdPHit: true,
|
||||
expectDeniedReason: deniedReasonUserNotFound,
|
||||
name: "idp email when stored user missing keeps peer.UserID as principal",
|
||||
peerUserID: userID,
|
||||
storedUsers: map[string]*types.User{},
|
||||
idpEmail: "idp@example.com",
|
||||
idpHasData: true,
|
||||
expectEmail: "idp@example.com",
|
||||
expectUserID: userID,
|
||||
expectIdPHit: true,
|
||||
},
|
||||
{
|
||||
name: "unlinked peer uses peer name and never consults idp",
|
||||
@@ -609,13 +545,9 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
|
||||
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
|
||||
assert.True(t, resp.GetValid(), "expected access granted")
|
||||
assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
|
||||
assert.Equal(t, tt.expectUserID, resp.GetUserId())
|
||||
if tt.expectDeniedReason != "" {
|
||||
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
|
||||
}
|
||||
|
||||
if idpMock != nil {
|
||||
if tt.expectIdPHit {
|
||||
@@ -630,121 +562,6 @@ func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeniedReasonValues pins the wire values of the account status denied
|
||||
// reasons. The proxy logs them and operators filter access logs on them, so a
|
||||
// rename is a breaking change rather than an internal detail.
|
||||
// TestSameAccount pins the fail-closed behaviour of the account binding: an
|
||||
// unset account on either side must never compare equal into a grant.
|
||||
func TestSameAccount(t *testing.T) {
|
||||
assert.True(t, sameAccount("account1", "account1"), "matching accounts should bind")
|
||||
assert.False(t, sameAccount("account1", "account2"), "different accounts must not bind")
|
||||
assert.False(t, sameAccount("", ""), "two unset accounts must not bind")
|
||||
assert.False(t, sameAccount("account1", ""), "an unset service account must not bind")
|
||||
assert.False(t, sameAccount("", "account1"), "an unset user account must not bind")
|
||||
}
|
||||
|
||||
func TestDeniedReasonValues(t *testing.T) {
|
||||
assert.Equal(t, "pending_approval", deniedReasonPendingApproval, "pending approval denied reason wire value")
|
||||
assert.Equal(t, "user_blocked", deniedReasonUserBlocked, "blocked user denied reason wire value")
|
||||
assert.Equal(t, "user_not_found", deniedReasonUserNotFound, "unresolved user denied reason wire value")
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerOwnerStatus verifies that the mesh fast-path gates on
|
||||
// the account status of the peer's owning user. A peer whose owner was blocked
|
||||
// after the peer registered must lose access, while an unlinked machine peer
|
||||
// keeps it.
|
||||
func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
peerID = "peer1"
|
||||
peerName = "peer-display-name"
|
||||
userID = "user1"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
peerUserID string
|
||||
owner *types.User
|
||||
expectDeniedReason string
|
||||
expectEmail string
|
||||
}{
|
||||
{
|
||||
name: "active owner allowed",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "owner pending approval denied",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true, PendingApproval: true},
|
||||
expectDeniedReason: deniedReasonPendingApproval,
|
||||
},
|
||||
{
|
||||
name: "owner blocked after registering the peer denied",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Email: "user@example.com", Blocked: true},
|
||||
expectDeniedReason: deniedReasonUserBlocked,
|
||||
},
|
||||
{
|
||||
name: "unlinked machine peer stays allowed",
|
||||
peerUserID: "",
|
||||
owner: &types.User{Id: userID, AccountID: accountID, Blocked: true},
|
||||
},
|
||||
{
|
||||
// The user lookup is not account-scoped, so a peer row pointing at
|
||||
// another account's user must not resolve into an owner: the peer is
|
||||
// denied and the foreign email never reaches the response.
|
||||
name: "owner in another account denied and not disclosed",
|
||||
peerUserID: userID,
|
||||
owner: &types.User{Id: userID, AccountID: "otherAccount", Email: "foreign@example.com"},
|
||||
expectDeniedReason: deniedReasonUserNotFound,
|
||||
expectEmail: peerName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &service.Service{Domain: domain, AccountID: accountID}
|
||||
usersManager := &mockUsersManager{users: map[string]*types.User{userID: tt.owner}}
|
||||
server := &ProxyServiceServer{
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{accountID: {svc}},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID},
|
||||
},
|
||||
usersManager: usersManager,
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, tt.expectDeniedReason, resp.GetDeniedReason(), "unexpected denied reason")
|
||||
assert.Equal(t, tt.expectDeniedReason == "", resp.GetValid(), "unexpected access decision")
|
||||
if tt.expectDeniedReason != "" {
|
||||
assert.Empty(t, resp.GetSessionToken(), "a denied peer must not receive a session token")
|
||||
}
|
||||
|
||||
if tt.expectEmail != "" {
|
||||
assert.Equal(t, tt.expectEmail, resp.GetUserEmail(), "unexpected identity on the response")
|
||||
}
|
||||
|
||||
// The status gate and the identity resolution share one lookup;
|
||||
// an unlinked peer has no owner to look up at all.
|
||||
wantLookups := 1
|
||||
if tt.peerUserID == "" {
|
||||
wantLookups = 0
|
||||
}
|
||||
assert.Equal(t, wantLookups, usersManager.getUserCalls, "owner must be resolved exactly once per request")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountProxyByDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -46,7 +46,6 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
|
||||
proxyService.SetServiceManager(serviceManager)
|
||||
|
||||
createTestProxies(t, ctx, testStore)
|
||||
createStatusTestUsers(t, ctx, testStore)
|
||||
|
||||
return &validateSessionTestSetup{
|
||||
proxyService: proxyService,
|
||||
@@ -92,82 +91,6 @@ func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store)
|
||||
},
|
||||
}
|
||||
require.NoError(t, testStore.CreateService(ctx, restrictedProxy))
|
||||
|
||||
// Distributed to the account's "All" group, the configuration that hands a
|
||||
// service to every user in the account.
|
||||
allUsersProxy := &service.Service{
|
||||
ID: "allUsersProxyId",
|
||||
AccountID: "testAccountId",
|
||||
Name: "All Users Proxy",
|
||||
Domain: "all-users-proxy.example.com",
|
||||
Enabled: true,
|
||||
SessionPrivateKey: privKey,
|
||||
SessionPublicKey: pubKey,
|
||||
Auth: service.AuthConfig{
|
||||
BearerAuth: &service.BearerAuthConfig{
|
||||
Enabled: true,
|
||||
DistributionGroups: []string{allUsersGroupID},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, testStore.CreateService(ctx, allUsersProxy))
|
||||
}
|
||||
|
||||
const (
|
||||
allUsersGroupID = "allUsersGroupId"
|
||||
pendingUserID = "pendingUserId"
|
||||
blockedUserID = "blockedUserId"
|
||||
pendingAllUsersID = "pendingAllUsersUserId"
|
||||
)
|
||||
|
||||
// createStatusTestUsers adds the users whose account status must keep them out
|
||||
// of a proxy session. A user awaiting approval is persisted as both blocked and
|
||||
// pending approval, the way the approval flow stores one.
|
||||
func createStatusTestUsers(t *testing.T, ctx context.Context, testStore store.Store) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, testStore.CreateGroup(ctx, &types.Group{
|
||||
ID: allUsersGroupID,
|
||||
AccountID: "testAccountId",
|
||||
Name: "All",
|
||||
Issued: types.GroupIssuedAPI,
|
||||
}))
|
||||
|
||||
users := []*types.User{
|
||||
{
|
||||
Id: pendingUserID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
Id: pendingAllUsersID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{allUsersGroupID},
|
||||
Blocked: true,
|
||||
PendingApproval: true,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
Id: blockedUserID,
|
||||
AccountID: "testAccountId",
|
||||
Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"allowedGroupId"},
|
||||
Blocked: true,
|
||||
PendingApproval: false,
|
||||
Issued: "api",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
for _, user := range users {
|
||||
require.NoError(t, testStore.SaveUser(ctx, user))
|
||||
}
|
||||
}
|
||||
|
||||
func generateSessionKeyPair(t *testing.T) (string, string) {
|
||||
@@ -226,114 +149,6 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
|
||||
assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_PendingApprovalUserDenied covers a user who is a member of
|
||||
// the service's distribution group but is still waiting for an administrator to
|
||||
// approve the account. Group membership alone must not open the service.
|
||||
func TestValidateSession_PendingApprovalUserDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "User pending approval should be denied")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
assert.Equal(t, pendingUserID, resp.UserId, "Denial should identify the user it applies to")
|
||||
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
|
||||
assert.Equal(t, []string{"Allowed Group"}, resp.GetPeerGroupNames(), "PeerGroupNames must pair with PeerGroupIds on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_PendingApprovalUserInAllUsersGroupDenied covers the same
|
||||
// user against a service distributed to the account's "All" group, where every
|
||||
// user of the account is a member by default.
|
||||
func TestValidateSession_PendingApprovalUserInAllUsersGroupDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "allUsersProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingAllUsersID, "all-users-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "all-users-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "User pending approval should be denied even in the All Users group")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
assert.Equal(t, pendingAllUsersID, resp.UserId, "Denial should identify the user it applies to")
|
||||
assert.Equal(t, []string{allUsersGroupID}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships on denial")
|
||||
}
|
||||
|
||||
// TestValidateSession_BlockedUserDenied covers a user blocked after having been
|
||||
// approved, so PendingApproval is false and only the blocked flag is set.
|
||||
func TestValidateSession_BlockedUserDenied(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, blockedUserID, "restricted-proxy.example.com")
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Valid, "Blocked user should be denied")
|
||||
assert.Equal(t, deniedReasonUserBlocked, resp.DeniedReason, "Denied reason should name the blocked state")
|
||||
assert.Equal(t, blockedUserID, resp.UserId, "Denial should identify the user it applies to")
|
||||
}
|
||||
|
||||
// TestValidateSession_UserAllowedAfterApproval walks the same session token
|
||||
// through the approval transition: denied while pending, allowed once an
|
||||
// administrator clears both flags.
|
||||
func TestValidateSession_UserAllowedAfterApproval(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
proxy, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com")
|
||||
req := &proto.ValidateSessionRequest{
|
||||
Domain: "restricted-proxy.example.com",
|
||||
SessionToken: token,
|
||||
}
|
||||
|
||||
resp, err := setup.proxyService.ValidateSession(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.Valid, "User pending approval should be denied before approval")
|
||||
assert.Equal(t, deniedReasonPendingApproval, resp.DeniedReason, "Denied reason should name the pending approval state")
|
||||
|
||||
user, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, pendingUserID)
|
||||
require.NoError(t, err)
|
||||
user.PendingApproval = false
|
||||
user.Blocked = false
|
||||
require.NoError(t, setup.store.SaveUser(ctx, user))
|
||||
|
||||
resp, err = setup.proxyService.ValidateSession(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Valid, "Approved user should be allowed access")
|
||||
assert.Empty(t, resp.DeniedReason)
|
||||
assert.Equal(t, pendingUserID, resp.UserId, "Approved user should be identified in the response")
|
||||
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the approved user's group memberships")
|
||||
}
|
||||
|
||||
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
|
||||
setup := setupValidateSessionTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
@@ -1627,8 +1626,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
var removeOldGroups []string
|
||||
var hasChanges bool
|
||||
var user *types.User
|
||||
var change affectedpeers.Change
|
||||
var snap *affectedpeers.Snapshot
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
user, err = transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
|
||||
if err != nil {
|
||||
@@ -1667,25 +1664,14 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
return fmt.Errorf("error saving user: %w", err)
|
||||
}
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
// The user's auto-groups changed, so the SSH rules authorizing them ship a new
|
||||
// group -> user mapping even when no peer moves between groups.
|
||||
change.UserGroupIDs = allGroupChanges
|
||||
|
||||
// The user's peers are the changed entity in every scenario the sync can
|
||||
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
|
||||
// together with every peer they can connect to, like on a regular peer update.
|
||||
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
for _, peer := range userPeers {
|
||||
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
|
||||
}
|
||||
|
||||
// Propagate changes to peers if group propagation is enabled
|
||||
if settings.GroupsPropagationEnabled {
|
||||
for _, peer := range userPeers {
|
||||
peers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting user peers: %w", err)
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
for _, g := range addNewGroups {
|
||||
if err := transaction.AddPeerToGroup(ctx, userAuth.AccountId, peer.ID, g); err != nil {
|
||||
return fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, g, err)
|
||||
@@ -1698,8 +1684,7 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
change.LinkGroups = allGroupChanges
|
||||
|
||||
allGroupChanges := slices.Concat(addNewGroups, removeOldGroups)
|
||||
if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil {
|
||||
return fmt.Errorf("reconcile IPv6 for group changes: %w", err)
|
||||
}
|
||||
@@ -1709,10 +1694,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1749,17 +1730,20 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth
|
||||
}
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating affected peers", userAuth.UserId)
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
affectedPeerIDs := snap.Expand(bgCtx, userAuth.AccountId, change)
|
||||
if len(affectedPeerIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if err := am.networkMapController.BufferUpdateAffectedPeers(bgCtx, userAuth.AccountId, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}); err != nil {
|
||||
log.WithContext(bgCtx).Errorf("failed to update affected peers after JWT group sync for account %s: %v", userAuth.AccountId, err)
|
||||
}
|
||||
}()
|
||||
removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if removedGroupAffectsPeers || newGroupsAffectsPeers {
|
||||
log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId)
|
||||
am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2442,24 +2426,30 @@ func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Contex
|
||||
return fmt.Errorf("get account settings: %w", err)
|
||||
}
|
||||
|
||||
if !ipv6ReconcileNeeded(settings, groupIDs) {
|
||||
if len(settings.IPv6EnabledGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups))
|
||||
for _, gid := range settings.IPv6EnabledGroups {
|
||||
enabledSet[gid] = struct{}{}
|
||||
}
|
||||
|
||||
affected := false
|
||||
for _, gid := range groupIDs {
|
||||
if _, ok := enabledSet[gid]; ok {
|
||||
affected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !affected {
|
||||
return nil
|
||||
}
|
||||
|
||||
return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings)
|
||||
}
|
||||
|
||||
// ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6
|
||||
// reconciliation.
|
||||
func ipv6ReconcileNeeded(settings *types.Settings, groupIDs []string) bool {
|
||||
for _, groupID := range groupIDs {
|
||||
if slices.Contains(settings.IPv6EnabledGroups, groupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error {
|
||||
if settings.NetworkRangeV6.IsValid() {
|
||||
network.NetV6 = net.IPNet{
|
||||
|
||||
@@ -1757,7 +1757,6 @@ func TestAccount_Copy(t *testing.T) {
|
||||
AccountID: "account1",
|
||||
},
|
||||
},
|
||||
PostureValidation: map[string]map[string]bool{"1": {"1": true}},
|
||||
}
|
||||
err := hasNilField(account)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
)
|
||||
|
||||
// A user's auto-group change refreshes the destinations of the SSH rules authorizing
|
||||
// that group — they carry the group -> user mapping — even though no peer moved
|
||||
// between groups.
|
||||
func TestAffectedPeers_UserGroupChange_RefreshesSSHAuthorizedDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[3]: {"root"}},
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[3]}})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the SSH rule's destination peers carry the changed group -> user mapping")
|
||||
|
||||
result = resolveAffected(t, s, accountID, affectedpeers.Change{UserGroupIDs: []string{groupIDs[4]}})
|
||||
assert.Empty(t, result, "a group no SSH rule authorizes affects nobody")
|
||||
}
|
||||
|
||||
// Creating, blocking or unblocking a user changes the account's allowed-user set, which
|
||||
// reaches only the destinations of the SSH rules that ship it.
|
||||
func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) {
|
||||
manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Ships the allowed-user set: an SSH rule naming no groups and no user.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[0]},
|
||||
Destinations: []string{groupIDs[1]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Does not ship it: an SSH rule that authorizes a specific group.
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{groupIDs[2]},
|
||||
Destinations: []string{groupIDs[3]},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
AuthorizedGroups: map[string][]string{groupIDs[0]: {"root"}},
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := resolveAffected(t, s, accountID, affectedpeers.Change{AllowedUsersChanged: true})
|
||||
assert.ElementsMatch(t, []string{peerIDs[1]}, result,
|
||||
"only the destinations of the rule shipping the allowed-user set refresh")
|
||||
}
|
||||
|
||||
// TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated verifies that a JWT
|
||||
// auto-group change updates only the user's peers and the peers linked to the changed
|
||||
// group through policies, instead of fanning out to the whole account.
|
||||
func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.JWTGroupsEnabled = true
|
||||
account.Settings.JWTGroupsClaimName = "groups"
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"jwt-grp"},
|
||||
Destinations: []string{"jwt-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updUser := updateManager.CreateChannel(ctx, userPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, userPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
userAuth := auth.UserAuth{
|
||||
AccountId: accountID,
|
||||
UserId: userID,
|
||||
Groups: []string{"jwt-linked"},
|
||||
}
|
||||
|
||||
t.Run("adding JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
|
||||
t.Run("removing JWT group updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updUser)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
userAuth.Groups = nil
|
||||
require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth))
|
||||
|
||||
peerShouldReceiveUpdate(t, updUser)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, user.AutoGroups, "jwt-grp")
|
||||
})
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// A user update refreshes only the peers its auto-group change reaches, and a user
|
||||
// update that changes no group membership refreshes nobody.
|
||||
func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) {
|
||||
manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t)
|
||||
ctx := context.Background()
|
||||
accountID := account.Id
|
||||
|
||||
const targetUserID = "target-user"
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
}))
|
||||
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
require.NoError(t, err)
|
||||
targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{
|
||||
Key: key.PublicKey().String(),
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
for _, p := range policies {
|
||||
require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID))
|
||||
}
|
||||
|
||||
account, err = manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.GroupsPropagationEnabled = true
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"}))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}}))
|
||||
|
||||
_, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{
|
||||
{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Bidirectional: true,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
updTarget := updateManager.CreateChannel(ctx, targetPeer.ID)
|
||||
upd2 := updateManager.CreateChannel(ctx, peer2.ID)
|
||||
upd3 := updateManager.CreateChannel(ctx, peer3.ID)
|
||||
t.Cleanup(func() {
|
||||
updateManager.CloseChannel(ctx, targetPeer.ID)
|
||||
updateManager.CloseChannel(ctx, peer2.ID)
|
||||
updateManager.CloseChannel(ctx, peer3.ID)
|
||||
})
|
||||
|
||||
t.Run("auto group change updates only linked peers", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("update without group changes refreshes nobody", func(t *testing.T) {
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldNotReceiveUpdate(t, updTarget)
|
||||
peerShouldNotReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
|
||||
user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", user.Name)
|
||||
})
|
||||
|
||||
t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) {
|
||||
account, err := manager.Store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
account.Settings.IPv6EnabledGroups = []string{"ug-v6"}
|
||||
require.NoError(t, manager.Store.SaveAccount(ctx, account))
|
||||
require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The reassigned peer refreshes with everyone it can reach: peer2 via the
|
||||
// policy, but not peer3, which shares no group or policy with it.
|
||||
peerShouldReceiveUpdate(t, updTarget)
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
|
||||
t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) {
|
||||
// An SSH rule that authorizes no group of its own ships the account's
|
||||
// allowed-user set to its destinations, so those are the peers an unblock
|
||||
// reaches — not the whole account.
|
||||
_, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true,
|
||||
Sources: []string{"ug-linked"},
|
||||
Destinations: []string{"ug-dest"},
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Action: types.PolicyTrafficActionAccept,
|
||||
}},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
|
||||
require.NoError(t, err)
|
||||
blocked.Blocked = true
|
||||
require.NoError(t, manager.Store.SaveUser(ctx, blocked))
|
||||
|
||||
drainPeerUpdates(updTarget)
|
||||
drainPeerUpdates(upd2)
|
||||
drainPeerUpdates(upd3)
|
||||
|
||||
// Same auto-groups as the previous subtest left them, so no group change and
|
||||
// no IPv6 reconciliation interferes: the unblock alone drives the refresh.
|
||||
_, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{
|
||||
Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser,
|
||||
AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
peerShouldReceiveUpdate(t, upd2)
|
||||
peerShouldNotReceiveUpdate(t, upd3)
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user