mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-10 17:51:29 +02:00
Compare commits
20 Commits
debug-ui-m
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f7ed858d | ||
|
|
82e799f095 | ||
|
|
2ce6323602 | ||
|
|
9a05a1c698 | ||
|
|
5dd914782a | ||
|
|
8c19b7a30a | ||
|
|
1e2a7aa571 | ||
|
|
98f8a24108 | ||
|
|
b2ae98f0fd | ||
|
|
40ac64bb4e | ||
|
|
19a6cedfff | ||
|
|
6526fc2bec | ||
|
|
2afa69b622 | ||
|
|
2a61eac047 | ||
|
|
f2d13b884a | ||
|
|
564595d283 | ||
|
|
78c1c2fc32 | ||
|
|
bc7a15ab71 | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d |
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -475,6 +475,132 @@ jobs:
|
|||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 3
|
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:
|
release_ui_darwin:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
outputs:
|
outputs:
|
||||||
@@ -688,7 +814,7 @@ jobs:
|
|||||||
comment_release_artifacts:
|
comment_release_artifacts:
|
||||||
name: Comment release artifacts
|
name: Comment release artifacts
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release, release_ui, release_ui_darwin]
|
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
|
||||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -700,12 +826,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RELEASE_RESULT: ${{ needs.release.result }}
|
RELEASE_RESULT: ${{ needs.release.result }}
|
||||||
RELEASE_UI_RESULT: ${{ needs.release_ui.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_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_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 }}
|
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_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 }}
|
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||||
with:
|
with:
|
||||||
@@ -728,6 +856,7 @@ jobs:
|
|||||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||||
['macOS packages', process.env.MACOS_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 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],
|
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -784,7 +913,7 @@ jobs:
|
|||||||
|
|
||||||
trigger_signer:
|
trigger_signer:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
|
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger binaries sign pipelines
|
- name: Trigger binaries sign pipelines
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
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
|
- name: Verify Dex retirement notice
|
||||||
run: |
|
run: |
|
||||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||||
|
|||||||
131
.goreleaser_ui_gtk3.yaml
Normal file
131
.goreleaser_ui_gtk3.yaml
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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,20 +14,22 @@ in this file, not duplicated there.
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
|
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||||
- [Contents](#contents)
|
- [Quick reference](#quick-reference)
|
||||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
- [Structure](#structure)
|
||||||
- [Quick reference](#quick-reference)
|
- [Where to look](#where-to-look)
|
||||||
- [Structure](#structure)
|
- [Security](#security)
|
||||||
- [Where to look](#where-to-look)
|
- [Agent conventions](#agent-conventions)
|
||||||
- [Repo-wide principles](#repo-wide-principles)
|
- [Repo-wide principles](#repo-wide-principles)
|
||||||
- [Error handling](#error-handling)
|
- [Type safety](#type-safety)
|
||||||
- [Comments](#comments)
|
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||||
- [Testing](#testing)
|
- [Error handling](#error-handling)
|
||||||
- [Pitfalls](#pitfalls)
|
- [Comments](#comments)
|
||||||
- [Commits, PRs, releases](#commits-prs-releases)
|
- [Testing](#testing)
|
||||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
- [Pitfalls](#pitfalls)
|
||||||
- [Discussion and support](#discussion-and-support)
|
- [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
|
## STOP and ask the user before
|
||||||
|
|
||||||
@@ -157,11 +159,125 @@ netbird/
|
|||||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||||
| End-to-end tests | `e2e/` |
|
| 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
|
## Repo-wide principles
|
||||||
|
|
||||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||||
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
|
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||||
touch, and delete imports, helpers, and parameters your refactor orphaned.
|
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||||
Exception: unused parameters in shared code may be consumed by builds outside
|
Exception: unused parameters in shared code may be consumed by builds outside
|
||||||
this repository — do not remove them, ask instead.
|
this repository — do not remove them, ask instead.
|
||||||
3. **Function comments are mandatory for exported functions**, written as full
|
3. **Function comments are mandatory for exported functions**, written as full
|
||||||
@@ -175,9 +291,12 @@ netbird/
|
|||||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||||
comments, commit messages, and PR descriptions alike.
|
comments, commit messages, and PR descriptions alike.
|
||||||
8. **Concurrency: do a two-pass race analysis after every change** that adds
|
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||||
shared state. Guard maps and slices with a mutex, keep critical sections
|
shared state, including reads of existing maps and slices. Guard them with a
|
||||||
short, and run `go test -race` on the touched packages.
|
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.
|
||||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||||
add the counterpart or a build-tagged fallback for the others.
|
add the counterpart or a build-tagged fallback for the others.
|
||||||
@@ -185,6 +304,93 @@ netbird/
|
|||||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||||
keep peer IPs and hostnames out of logs above debug level.
|
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
|
## Error handling
|
||||||
|
|
||||||
Use single-assignment form when the error is only needed inside the `if`:
|
Use single-assignment form when the error is only needed inside the `if`:
|
||||||
@@ -248,6 +454,45 @@ Log the errors you choose not to act on:
|
|||||||
- Close errors may be ignored for read-only operations; log them at debug for
|
- Close errors may be ignored for read-only operations; log them at debug for
|
||||||
writes.
|
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
|
## Comments
|
||||||
|
|
||||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||||
@@ -269,10 +514,14 @@ checksum = updateChecksum(checksum, oldPort, newPort)
|
|||||||
|
|
||||||
### Length budget
|
### Length budget
|
||||||
|
|
||||||
- **90 characters per line.** Wrap the comment, do not run past it.
|
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||||
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
|
mostly follows rather than hard limits:
|
||||||
exported identifiers may exceed it when the API genuinely needs the
|
|
||||||
explanation; inline comments inside a function body may not.
|
- **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.
|
||||||
|
|
||||||
The budget is a smell detector, not a rule to game. Do not compress a needed
|
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
|
explanation into cryptic shorthand to fit — if a block of code needs more than
|
||||||
@@ -329,6 +578,19 @@ up, and the 250-character budget does not apply to them.
|
|||||||
otherwise.
|
otherwise.
|
||||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||||
comparison, boolean, and collection assertions.
|
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
|
```go
|
||||||
server, err := StartTestServer()
|
server, err := StartTestServer()
|
||||||
@@ -380,7 +642,8 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
|||||||
than replacing it with your own summary: describe the change, link the issue,
|
than replacing it with your own summary: describe the change, link the issue,
|
||||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||||
and complete the documentation section. Do not tick a box you have not
|
and complete the documentation section. Do not tick a box you have not
|
||||||
verified, and do not delete rows that do not apply.
|
verified, and do not delete rows that do not apply — the docs gate in CI reads
|
||||||
|
that section and fails when it is missing.
|
||||||
|
|
||||||
- **Keep the PR description short.** Under 1000 words on top of the template's
|
- **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;
|
own text, and usually far less — a few paragraphs. Reviewers read the diff;
|
||||||
@@ -439,6 +702,12 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
|
|||||||
on their own. Propose that split to the user rather than opening one large PR
|
on their own. Propose that split to the user rather than opening one large PR
|
||||||
and hoping.
|
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
|
- **User-facing changes need a docs PR** in
|
||||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||||
description.
|
description.
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ type Client struct {
|
|||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
cacheDir string
|
cacheDir string
|
||||||
|
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||||
|
cfgPath string
|
||||||
|
|
||||||
stateChangeMu sync.Mutex
|
stateChangeMu sync.Mutex
|
||||||
stateChangeSubID string
|
stateChangeSubID string
|
||||||
@@ -102,11 +104,12 @@ type Client struct {
|
|||||||
extendCancel context.CancelFunc
|
extendCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
|
||||||
c.stateMu.Lock()
|
c.stateMu.Lock()
|
||||||
defer c.stateMu.Unlock()
|
defer c.stateMu.Unlock()
|
||||||
c.config = cfg
|
c.config = cfg
|
||||||
c.cacheDir = cacheDir
|
c.cacheDir = cacheDir
|
||||||
|
c.cfgPath = cfgPath
|
||||||
c.connectClient = cc
|
c.connectClient = cc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
|||||||
return c.config, c.cacheDir, c.connectClient
|
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 {
|
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||||
c.stateMu.RLock()
|
c.stateMu.RLock()
|
||||||
defer c.stateMu.RUnlock()
|
defer c.stateMu.RUnlock()
|
||||||
@@ -168,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
defer c.ctxCancel()
|
defer c.ctxCancel()
|
||||||
c.ctxCancelLock.Unlock()
|
c.ctxCancelLock.Unlock()
|
||||||
|
|
||||||
auth := NewAuthWithConfig(ctx, cfg)
|
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||||
err = auth.login(urlOpener, isAndroidTV)
|
err = auth.login(urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -176,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||||
// is authenticated again — release the latch Status() reports from. Clear
|
// is authenticated again — release the latch Status() reports from. Clear
|
||||||
// only once the fresh connect client is installed: until then Status()
|
// only once the fresh connect client is installed: until then Status()
|
||||||
@@ -217,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||||
c.setState(cfg, cacheDir, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
@@ -61,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthWithConfig instantiate Auth based on existing config
|
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
// 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 {
|
||||||
return &Auth{
|
return &Auth{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
config: config,
|
||||||
|
cfgPath: cfgPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jwtToken := ""
|
jwtToken := ""
|
||||||
|
email := ""
|
||||||
if needsLogin {
|
if needsLogin {
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
}
|
}
|
||||||
jwtToken = tokenInfo.GetTokenToUse()
|
jwtToken = tokenInfo.GetTokenToUse()
|
||||||
|
email = tokenInfo.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||||
@@ -171,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
return fmt.Errorf("login failed: %v", err)
|
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()
|
go urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
return nil
|
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) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
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())
|
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||||
|
|||||||
@@ -13,18 +13,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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)
|
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||||
androidUsername = "android"
|
androidUsername = "android"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile represents a profile for gomobile
|
// Profile represents a profile for gomobile
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID string
|
ID string
|
||||||
Name 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
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
|||||||
profiles = append(profiles, &Profile{
|
profiles = append(profiles, &Profile{
|
||||||
ID: p.ID.String(),
|
ID: p.ID.String(),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
|
Email: pm.profileEmail(p.ID.String()),
|
||||||
IsActive: p.IsActive,
|
IsActive: p.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||||
}
|
}
|
||||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchProfile switches to a different profile
|
// SwitchProfile switches to a different profile
|
||||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
|||||||
return fmt.Errorf("failed to save config: %w", err)
|
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)
|
log.Infof("logged out from profile: %s", id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
108
client/android/profile_state.go
Normal file
108
client/android/profile_state.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
161
client/android/profile_state_test.go
Normal file
161
client/android/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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 {
|
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||||
cfg, _, cc := c.stateSnapshot()
|
cfg, cfgPath, cc := c.authSnapshot()
|
||||||
if cfg == nil || cc == nil {
|
if cfg == nil || cc == nil {
|
||||||
return fmt.Errorf("engine is not running")
|
return fmt.Errorf("engine is not running")
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
|||||||
}
|
}
|
||||||
defer authClient.Close()
|
defer authClient.Close()
|
||||||
|
|
||||||
a := &Auth{ctx: ctx, config: cfg}
|
// 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)
|
||||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||||
|
|||||||
521
client/android/ssh_client.go
Normal file
521
client/android/ssh_client.go
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sshDialTimeout = 30 * time.Second
|
||||||
|
sshDetectionTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
||||||
|
// a string because gomobile flattens errors to their message, so a sentinel
|
||||||
|
// value would not survive the binding.
|
||||||
|
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
||||||
|
|
||||||
|
var errPasswordRequired = errors.New(PasswordRequiredMarker)
|
||||||
|
|
||||||
|
// SSHTerminalListener receives SSH session events. It is implemented in Java.
|
||||||
|
//
|
||||||
|
// 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 Java 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 Java 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 Java URL opener used to display the device-code
|
||||||
|
// authorization page in a Custom Tabs window 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.stateSnapshot()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
err = s.dialAndHandshake(host, port, clientConfig)
|
||||||
|
|
||||||
|
// A regular server may still accept a password, so let the caller ask for
|
||||||
|
// one instead of failing. NetBird servers never use a password, so a
|
||||||
|
// failure there is genuine.
|
||||||
|
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
|
||||||
|
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) {
|
||||||
|
return errPasswordRequired
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Infof("SSH: connect to %s:%d failed: %v", host, port, err)
|
||||||
|
return rootCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAuthFailure distinguishes credential rejection from dial, timeout and
|
||||||
|
// host-key errors, which retrying with a password would not fix.
|
||||||
|
func isAuthFailure(err error) bool {
|
||||||
|
if errors.Is(err, errPasswordRequired) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var partial *gossh.PartialSuccessError
|
||||||
|
if errors.As(err, &partial) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "unable to authenticate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
err := s.startSession(cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Infof("SSH: start session failed: %v", err)
|
||||||
|
return rootCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Nothing to offer at all: ask for a password rather than failing,
|
||||||
|
// so the caller can retry once the user supplies one.
|
||||||
|
return nil, nil, errPasswordRequired
|
||||||
|
}
|
||||||
|
return auths, gossh.InsecureIgnoreHostKey(), nil // nolint:gosec // TOFU not yet implemented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// WaitToken blocks for as long as the browser round-trip takes, so say so
|
||||||
|
// rather than leaving the terminal blank.
|
||||||
|
s.notifyStatus("Waiting for browser authentication...")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// EOF is a normal shell exit, so report it without a reason.
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
s.notifyClose("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debugf("ssh %s read: %v", name, err)
|
||||||
|
s.notifyClose(rootCause(err).Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootCause returns the innermost error of a %w chain, so the terminal shows
|
||||||
|
// "i/o timeout" rather than every layer that added context on the way up.
|
||||||
|
func rootCause(err error) error {
|
||||||
|
for {
|
||||||
|
// A joined error has no single root, so keep it as-is.
|
||||||
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := errors.Unwrap(err)
|
||||||
|
if next == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset makes a closed client usable for another Connect: Close leaves the
|
||||||
|
// one-shot guard set, and clearing it lets the same client back a reconnect.
|
||||||
|
func (s *SSHClient) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.closed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStatus writes a progress line to the terminal through the normal
|
||||||
|
// output path, so long steps are visible while nothing else is arriving.
|
||||||
|
func (s *SSHClient) notifyStatus(text string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
// its wg interface into firewalld's "trusted" zone. This is required because
|
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||||
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||||
// versions, which returns EPERM to any other process that tries to insert
|
// versions, which returns EPERM to any other process that tries to insert
|
||||||
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
// rules into them. Trusting the interface makes firewalld itself add the
|
||||||
// itself add the accept rules to its own chains by trusting the interface.
|
// accept rules to its own chains instead.
|
||||||
package firewalld
|
package firewalld
|
||||||
|
|
||||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
gstatus "google.golang.org/grpc/status"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
@@ -27,9 +25,9 @@ func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T
|
|||||||
|
|
||||||
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||||
attempts := 0
|
attempts := 0
|
||||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
attempts++
|
attempts++
|
||||||
return internal.StatusLoginFailed, unreachable
|
return false, unreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
@@ -55,15 +53,12 @@ func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
|||||||
s.rootCtx = internal.CtxInitState(context.Background())
|
s.rootCtx = internal.CtxInitState(context.Background())
|
||||||
breakProfilePrivateKey(t, cfgPath)
|
breakProfilePrivateKey(t, cfgPath)
|
||||||
|
|
||||||
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
|
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||||
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
|
return true, nil
|
||||||
return internal.StatusNeedsLogin, refused
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.NotErrorIs(t, err, refused,
|
|
||||||
"the refusal was handed back to the caller instead of starting the SSO flow")
|
|
||||||
|
|
||||||
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||||
require.NoError(t, stateErr)
|
require.NoError(t, stateErr)
|
||||||
@@ -71,6 +66,32 @@ func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
|||||||
"the SSO flow setup was never reached with the broken key")
|
"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
|
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||||
// one, which makes any attempt to build a Management client fail on the spot.
|
// one, which makes any attempt to build a Management client fail on the spot.
|
||||||
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ type Server struct {
|
|||||||
// it to drive the login outcomes that need a server on the other end;
|
// it to drive the login outcomes that need a server on the other end;
|
||||||
// production leaves it nil, and every login goes through loginAttempt.
|
// production leaves it nil, and every login goes through loginAttempt.
|
||||||
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||||
|
|
||||||
|
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthAuthFlow struct {
|
type oauthAuthFlow struct {
|
||||||
@@ -384,6 +386,21 @@ func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (i
|
|||||||
return s.loginAttempt(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
|
// loginAttempt attempts to login using the provided information. It returns
|
||||||
// StatusNeedsLogin when Management refused the peer's credentials and
|
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||||
// StatusLoginFailed for every other failure, so callers can tell an
|
// StatusLoginFailed for every other failure, so callers can tell an
|
||||||
@@ -640,22 +657,22 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
|||||||
s.config = config
|
s.config = config
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
loginStatus, err := s.attemptLogin(ctx, "", "")
|
// A probe that errors leaves the login undecided: Management unreachable, a
|
||||||
if err == nil {
|
|
||||||
state.Set(internal.StatusIdle)
|
|
||||||
return &proto.LoginResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only an authentication refusal means the peer has to (re-)authenticate.
|
|
||||||
// Any other failure leaves the login undecided: Management unreachable, a
|
|
||||||
// restart mid-request, an internal error. Those are returned for the caller
|
// 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
|
// 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
|
// something that is not theirs to solve, and a browser login cannot succeed
|
||||||
// while Management is unreachable anyway.
|
// while Management is unreachable anyway. Only Management refusing the
|
||||||
if loginStatus != internal.StatusNeedsLogin {
|
// peer's key is a decision, and IsLoginRequired reports that as
|
||||||
state.Set(loginStatus)
|
// needsLogin=true rather than an error.
|
||||||
|
needsLogin, err := s.isLoginRequired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
state.Set(internal.StatusLoginFailed)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !needsLogin {
|
||||||
|
state.Set(internal.StatusIdle)
|
||||||
|
return &proto.LoginResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if msg.SetupKey == "" {
|
if msg.SetupKey == "" {
|
||||||
hint := ""
|
hint := ""
|
||||||
@@ -1798,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
|
|||||||
if connectClient == nil {
|
if connectClient == nil {
|
||||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
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 := ""
|
hint := ""
|
||||||
if msg.Hint != nil {
|
if msg.Hint != nil {
|
||||||
|
|||||||
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
|||||||
import { SquareIcon } from "@/components/SquareIcon";
|
import { SquareIcon } from "@/components/SquareIcon";
|
||||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||||
import { formatRemaining } from "@/lib/formatters";
|
import { formatRemaining } from "@/lib/formatters";
|
||||||
|
|
||||||
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
|
|||||||
}
|
}
|
||||||
}, [busy, t]);
|
}, [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 () => {
|
const logout = useCallback(async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
|
|||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
size={"md"}
|
size={"md"}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
onClick={stay}
|
onClick={expired ? authenticate : stay}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
|
|||||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||||
|
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -19,6 +20,7 @@ export function SettingsGeneral() {
|
|||||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||||
useManagementUrl();
|
useManagementUrl();
|
||||||
const { mdm, features } = useRestrictions();
|
const { mdm, features } = useRestrictions();
|
||||||
|
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const managementUrlId = useId();
|
const managementUrlId = useId();
|
||||||
@@ -57,6 +59,15 @@ export function SettingsGeneral() {
|
|||||||
helpText={t("settings.general.autostart.help")}
|
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>
|
</SectionGroup>
|
||||||
|
|
||||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Anzeigesprache"
|
"message": "Anzeigesprache"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -735,6 +735,14 @@
|
|||||||
"message": "Autostart Change Failed",
|
"message": "Autostart Change Failed",
|
||||||
"description": "Error-dialog title when changing the autostart setting fails."
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Display Language",
|
"message": "Display Language",
|
||||||
"description": "Label for the display-language picker."
|
"description": "Label for the display-language picker."
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Error al cambiar el inicio automático"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Idioma de la interfaz"
|
"message": "Idioma de la interfaz"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Échec de la modification du démarrage automatique"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Langue d’affichage"
|
"message": "Langue d’affichage"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Az automatikus indítás módosítása sikertelen"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Megjelenítési nyelv"
|
"message": "Megjelenítési nyelv"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Modifica avvio automatico non riuscita"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Lingua dell'interfaccia"
|
"message": "Lingua dell'interfaccia"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "自動起動の変更に失敗しました"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "表示言語"
|
"message": "表示言語"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Falha ao alterar o início automático"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Idioma de exibição"
|
"message": "Idioma de exibição"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "Не удалось изменить автозапуск"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "Язык интерфейса"
|
"message": "Язык интерфейса"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -551,6 +551,14 @@
|
|||||||
"settings.general.autostart.errorTitle": {
|
"settings.general.autostart.errorTitle": {
|
||||||
"message": "更改自启动设置失败"
|
"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": {
|
"settings.general.language.label": {
|
||||||
"message": "显示语言"
|
"message": "显示语言"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ func main() {
|
|||||||
WindowManager: windowManager,
|
WindowManager: windowManager,
|
||||||
Session: authSession,
|
Session: authSession,
|
||||||
Localizer: localizer,
|
Localizer: localizer,
|
||||||
|
Preferences: prefStore,
|
||||||
})
|
})
|
||||||
listenForShowSignal(context.Background(), tray)
|
listenForShowSignal(context.Background(), tray)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ type UIPreferences struct {
|
|||||||
// decision has run for this OS user. It only ever transitions to true
|
// 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.
|
// and is never reset, so the default-on flow runs at most once, ever.
|
||||||
AutostartInitialized bool `json:"autostartInitialized"`
|
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.
|
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||||
@@ -183,6 +187,26 @@ func (s *Store) SetAutostartInitialized(done bool) error {
|
|||||||
return nil
|
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.
|
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
|
|||||||
@@ -238,6 +238,42 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
|||||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
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) {
|
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||||
withTempConfigDir(t)
|
withTempConfigDir(t)
|
||||||
|
|
||||||
|
|||||||
@@ -49,5 +49,11 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
|
|||||||
if app == nil || app.Screen == nil {
|
if app == nil || app.Screen == nil {
|
||||||
return p, true
|
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
|
return app.Screen.PhysicalToDipPoint(p), true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
|||||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||||
return s.store.SetOnboardingCompleted(done)
|
return s.store.SetOnboardingCompleted(done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
|
||||||
|
return s.store.SetKeepConnectedOnQuit(keep)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
"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/client/ui/services"
|
||||||
"github.com/netbirdio/netbird/version"
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
@@ -50,8 +51,9 @@ type TrayServices struct {
|
|||||||
WindowManager *services.WindowManager
|
WindowManager *services.WindowManager
|
||||||
// Session is bound to authsession directly because the services wrapper
|
// Session is bound to authsession directly because the services wrapper
|
||||||
// only re-exposes the React subset.
|
// only re-exposes the React subset.
|
||||||
Session *authsession.Session
|
Session *authsession.Session
|
||||||
Localizer *Localizer
|
Localizer *Localizer
|
||||||
|
Preferences *preferences.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tray struct {
|
type Tray struct {
|
||||||
@@ -461,10 +463,12 @@ func (t *Tray) handleQuit() {
|
|||||||
t.profileMu.Unlock()
|
t.profileMu.Unlock()
|
||||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
|
||||||
defer cancel()
|
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
defer cancel()
|
||||||
log.Errorf("disconnect on quit: %v", err)
|
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||||
|
log.Errorf("disconnect on quit: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
t.app.Quit()
|
t.app.Quit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ package main
|
|||||||
|
|
||||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||||
//
|
//
|
||||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
// Expected behaviour per tray host:
|
||||||
// fires the registered clickHandler:
|
//
|
||||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
// Host Left click Right click
|
||||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
// GNOME Shell + AppIndicator menu only menu only
|
||||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
//
|
||||||
// Registering OnClick here therefore covers both paths with one handler — no
|
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||||
// main window; right-click still opens the menu via Wails' default
|
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
//
|
||||||
|
// 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.
|
||||||
//
|
//
|
||||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ const (
|
|||||||
finalWarningCountdownSeconds = 120
|
finalWarningCountdownSeconds = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
|
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
|
||||||
func (t *Tray) handleSessionExpired() {
|
func (t *Tray) handleSessionExpired() {
|
||||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||||
if t.window != nil {
|
if t.window != nil {
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
t.window.Show()
|
||||||
t.window.Focus()
|
t.window.Focus()
|
||||||
}
|
}
|
||||||
@@ -308,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
|
|||||||
}
|
}
|
||||||
seconds := int(time.Until(deadline).Seconds())
|
seconds := int(time.Until(deadline).Seconds())
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
if t.window != nil {
|
t.app.Event.Emit(services.EventTriggerLogin)
|
||||||
t.window.SetURL("/#/login")
|
|
||||||
t.window.Show()
|
|
||||||
t.window.Focus()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t.svc.WindowManager == nil {
|
if t.svc.WindowManager == nil {
|
||||||
|
|||||||
40
client/ui/xembed_host_gtk3_linux.go
Normal file
40
client/ui/xembed_host_gtk3_linux.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//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 && !(linux && 386)
|
//go:build linux && !gtk3 && !(linux && 386)
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build linux && !gtk3 && !(linux && 386)
|
||||||
|
|
||||||
#include "xembed_tray_linux.h"
|
#include "xembed_tray_linux.h"
|
||||||
|
|
||||||
#include <X11/Xatom.h>
|
#include <X11/Xatom.h>
|
||||||
|
|||||||
@@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) {
|
|||||||
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
|
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")
|
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.
|
// Restore the original toggles.
|
||||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||||
|
Cluster: ptr(before.Cluster),
|
||||||
EnableLogCollection: before.EnableLogCollection,
|
EnableLogCollection: before.EnableLogCollection,
|
||||||
EnablePromptCollection: before.EnablePromptCollection,
|
EnablePromptCollection: before.EnablePromptCollection,
|
||||||
RedactPii: before.RedactPii,
|
RedactPii: before.RedactPii,
|
||||||
|
|||||||
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
114
e2e/agentnetwork/settings_bootstrap_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//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/conntrack v0.5.1
|
||||||
github.com/ti-mo/netfilter v0.5.2
|
github.com/ti-mo/netfilter v0.5.2
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
github.com/wailsapp/wails/v3 v3.0.0-beta.3
|
||||||
github.com/yusufpapurcu/wmi v1.2.4
|
github.com/yusufpapurcu/wmi v1.2.4
|
||||||
github.com/zcalusic/sysinfo v1.1.3
|
github.com/zcalusic/sysinfo v1.1.3
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||||
@@ -339,3 +339,5 @@ 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/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/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,6 +490,8 @@ 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/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 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
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 h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
@@ -660,8 +662,6 @@ 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/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 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ SED_STRIP_PADDING='s/=//g'
|
|||||||
|
|
||||||
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
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() {
|
check_docker_compose() {
|
||||||
if command -v docker-compose &> /dev/null; then
|
if command -v docker-compose &> /dev/null; then
|
||||||
echo "docker-compose"
|
echo "docker-compose"
|
||||||
@@ -80,7 +84,7 @@ read_nb_domain() {
|
|||||||
if ! check_domain_resolves "$value"; then
|
if ! check_domain_resolves "$value"; then
|
||||||
echo "" > /dev/stderr
|
echo "" > /dev/stderr
|
||||||
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
|
echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
|
||||||
echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
|
echo "Traefik will not be able to issue TLS certificates until it does." > /dev/stderr
|
||||||
local confirm=""
|
local confirm=""
|
||||||
echo -n "Continue anyway? [y/N]: " > /dev/stderr
|
echo -n "Continue anyway? [y/N]: " > /dev/stderr
|
||||||
read -r confirm < /dev/tty
|
read -r confirm < /dev/tty
|
||||||
@@ -92,6 +96,23 @@ read_nb_domain() {
|
|||||||
echo "$value"
|
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() {
|
read_required() {
|
||||||
local prompt="$1"
|
local prompt="$1"
|
||||||
local value=""
|
local value=""
|
||||||
@@ -204,11 +225,11 @@ init_environment() {
|
|||||||
check_openssl
|
check_openssl
|
||||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||||
|
|
||||||
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
|
if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then
|
||||||
echo "Generated files already exist in $(pwd)."
|
echo "Generated files already exist in $(pwd)."
|
||||||
echo "If you want to reinitialize the environment, please remove them first:"
|
echo "If you want to reinitialize the environment, please remove them first:"
|
||||||
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
|
echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
|
||||||
echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
|
echo " rm -f .env docker-compose.yml config.yaml"
|
||||||
echo "Be aware this will remove all data from the database."
|
echo "Be aware this will remove all data from the database."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -230,6 +251,9 @@ init_environment() {
|
|||||||
echo ""
|
echo ""
|
||||||
NETBIRD_DOMAIN=$(read_nb_domain)
|
NETBIRD_DOMAIN=$(read_nb_domain)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
NETBIRD_LETSENCRYPT_EMAIL=$(read_letsencrypt_email)
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
|
NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
|
||||||
@@ -238,6 +262,7 @@ init_environment() {
|
|||||||
POSTGRES_DB="netbird"
|
POSTGRES_DB="netbird"
|
||||||
POSTGRES_PASSWORD=$(rand_secret)
|
POSTGRES_PASSWORD=$(rand_secret)
|
||||||
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
|
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
|
||||||
|
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
|
||||||
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
|
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"
|
POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
|
||||||
@@ -247,6 +272,7 @@ init_environment() {
|
|||||||
echo "Selected:"
|
echo "Selected:"
|
||||||
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
|
echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
|
||||||
echo " Domain: ${NETBIRD_DOMAIN}"
|
echo " Domain: ${NETBIRD_DOMAIN}"
|
||||||
|
echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Rendering files into $(pwd) ..."
|
echo "Rendering files into $(pwd) ..."
|
||||||
install -m 600 /dev/null .env
|
install -m 600 /dev/null .env
|
||||||
@@ -256,7 +282,6 @@ init_environment() {
|
|||||||
if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
|
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
|
sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
|
||||||
fi
|
fi
|
||||||
render_caddyfile > Caddyfile
|
|
||||||
install -m 600 /dev/null config.yaml
|
install -m 600 /dev/null config.yaml
|
||||||
render_config_yaml >> config.yaml
|
render_config_yaml >> config.yaml
|
||||||
|
|
||||||
@@ -283,7 +308,7 @@ init_environment() {
|
|||||||
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
|
echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Tail logs:"
|
echo "Tail logs:"
|
||||||
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
|
echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -306,6 +331,11 @@ NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
|
|||||||
# Domain
|
# Domain
|
||||||
NETBIRD_DOMAIN=${NETBIRD_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"
|
# Image tags. Default to "latest"
|
||||||
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
|
NETBIRD_DASHBOARD_TAG=${NETBIRD_DASHBOARD_TAG:-latest}
|
||||||
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
|
NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest}
|
||||||
@@ -378,26 +408,78 @@ EOF
|
|||||||
|
|
||||||
render_compose_common() {
|
render_compose_common() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
caddy:
|
# 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:
|
||||||
<<: *default
|
<<: *default
|
||||||
image: caddy:2
|
image: traefik:${NETBIRD_TRAEFIK_TAG}
|
||||||
container_name: netbird-caddy
|
container_name: netbird-traefik
|
||||||
networks: [netbird]
|
networks:
|
||||||
environment:
|
netbird:
|
||||||
- CADDY_SECURE_DOMAIN=${NETBIRD_DOMAIN}
|
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"
|
||||||
ports:
|
ports:
|
||||||
- '443:443'
|
- '443:443'
|
||||||
- '443:443/udp'
|
|
||||||
- '80:80'
|
- '80:80'
|
||||||
volumes:
|
volumes:
|
||||||
- netbird_caddy_data:/data
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
- 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=
|
||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
<<: *default
|
<<: *default
|
||||||
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
|
image: ghcr.io/netbirdio/dashboard-cloud:${NETBIRD_DASHBOARD_TAG}
|
||||||
container_name: netbird-dashboard
|
container_name: netbird-dashboard
|
||||||
networks: [netbird]
|
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:
|
environment:
|
||||||
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
- NETBIRD_MGMT_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
||||||
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${NETBIRD_DOMAIN}
|
||||||
@@ -435,6 +517,28 @@ render_compose_server() {
|
|||||||
- netbird_data:/var/lib/netbird
|
- netbird_data:/var/lib/netbird
|
||||||
- ./config.yaml:/etc/netbird/config.yaml
|
- ./config.yaml:/etc/netbird/config.yaml
|
||||||
command: ["--config", "/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:
|
environment:
|
||||||
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
|
- NB_LICENSE_KEY=${NETBIRD_LICENSE_KEY}
|
||||||
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
|
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||||
@@ -497,6 +601,18 @@ render_compose_flow() {
|
|||||||
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
|
- NB_FLOW_NATS_ENDPOINTS=nats://nats:4222
|
||||||
- NB_FLOW_NATS_STREAM=traffic-events
|
- NB_FLOW_NATS_STREAM=traffic-events
|
||||||
- NB_FLOW_AUTH_SECRET=${NETBIRD_RELAY_AUTH_SECRET}
|
- 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
|
EOF
|
||||||
}
|
}
|
||||||
@@ -536,61 +652,16 @@ EOF
|
|||||||
fi
|
fi
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
netbird_postgres:
|
netbird_postgres:
|
||||||
netbird_caddy_data:
|
netbird_traefik_letsencrypt:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
netbird:
|
netbird:
|
||||||
EOF
|
name: netbird
|
||||||
}
|
driver: bridge
|
||||||
|
ipam:
|
||||||
render_caddyfile() {
|
config:
|
||||||
cat <<'EOF'
|
- subnet: 172.30.0.0/24
|
||||||
{
|
gateway: 172.30.0.1
|
||||||
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +680,7 @@ server:
|
|||||||
logLevel: "info"
|
logLevel: "info"
|
||||||
logFile: "console"
|
logFile: "console"
|
||||||
|
|
||||||
# TLS is terminated by Caddy in front; leave this block empty.
|
# TLS is terminated by Traefik in front; leave this block empty.
|
||||||
tls:
|
tls:
|
||||||
certFile: ""
|
certFile: ""
|
||||||
keyFile: ""
|
keyFile: ""
|
||||||
@@ -626,12 +697,23 @@ server:
|
|||||||
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
|
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
|
||||||
localAuthDisabled: false
|
localAuthDisabled: false
|
||||||
signKeyRefreshEnabled: false
|
signKeyRefreshEnabled: false
|
||||||
|
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
|
||||||
dashboardRedirectURIs:
|
dashboardRedirectURIs:
|
||||||
- "https://${NETBIRD_DOMAIN}/nb-auth"
|
- "https://${NETBIRD_DOMAIN}/nb-auth"
|
||||||
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"
|
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"
|
||||||
cliRedirectURIs:
|
cliRedirectURIs:
|
||||||
- "http://localhost:53000/"
|
- "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:
|
store:
|
||||||
engine: "postgres"
|
engine: "postgres"
|
||||||
dsn: "${POSTGRES_DSN}"
|
dsn: "${POSTGRES_DSN}"
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ initialize_default_values() {
|
|||||||
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
|
||||||
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
|
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
|
||||||
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||||
|
SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||||
NETBIRD_STUN_PORT=3478
|
NETBIRD_STUN_PORT=3478
|
||||||
|
|
||||||
# Docker images
|
# Docker images
|
||||||
@@ -527,7 +528,8 @@ generate_configuration_files() {
|
|||||||
|
|
||||||
# Common files for all configurations
|
# Common files for all configurations
|
||||||
render_dashboard_env > dashboard.env
|
render_dashboard_env > dashboard.env
|
||||||
render_combined_yaml > config.yaml
|
install -m 600 /dev/null config.yaml
|
||||||
|
render_combined_yaml >> config.yaml
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +913,7 @@ server:
|
|||||||
auth:
|
auth:
|
||||||
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
|
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
|
||||||
signKeyRefreshEnabled: true
|
signKeyRefreshEnabled: true
|
||||||
|
sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"
|
||||||
dashboardRedirectURIs:
|
dashboardRedirectURIs:
|
||||||
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
|
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
|
||||||
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"
|
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ set -o pipefail
|
|||||||
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
|
# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
|
||||||
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
|
# 3. Traffic flow — add NATS + flow-enricher + flow-receiver.
|
||||||
#
|
#
|
||||||
# To revert:
|
# 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:
|
||||||
# docker compose down
|
# docker compose down
|
||||||
# rm -f docker-compose.override.yml config.yaml.enterprise
|
# rm -f docker-compose.override.yml config.yaml.enterprise
|
||||||
# # If Postgres migration was done, also restore the SQLite backup printed
|
# # If Postgres migration was done, also restore the SQLite backup printed
|
||||||
@@ -25,6 +29,15 @@ set -o pipefail
|
|||||||
OVERRIDE_FILE="docker-compose.override.yml"
|
OVERRIDE_FILE="docker-compose.override.yml"
|
||||||
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
|
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"
|
NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
|
||||||
|
|
||||||
check_docker_compose() {
|
check_docker_compose() {
|
||||||
@@ -361,7 +374,77 @@ render_enterprise_config() {
|
|||||||
# Execution steps
|
# Execution steps
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resolve_data_volume() {
|
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() {
|
||||||
local short="$1"
|
local short="$1"
|
||||||
local actual
|
local actual
|
||||||
# Resolve project-prefixed volume name from Docker Compose config first.
|
# Resolve project-prefixed volume name from Docker Compose config first.
|
||||||
@@ -391,18 +474,21 @@ resolve_data_volume() {
|
|||||||
backup_sqlite() {
|
backup_sqlite() {
|
||||||
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
|
BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
local data_volume_actual
|
|
||||||
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
|
local container
|
||||||
echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
|
container=$(combined_container_id)
|
||||||
docker run --rm \
|
if [[ -z "$container" ]]; then
|
||||||
-v "${data_volume_actual}:/var/lib/netbird:ro" \
|
echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr
|
||||||
-v "${BACKUP_DIR}:/backup" \
|
exit 1
|
||||||
busybox \
|
fi
|
||||||
sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
|
|
||||||
|
echo "Backing up the NetBird store to $BACKUP_DIR ..."
|
||||||
|
docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"
|
||||||
|
|
||||||
local copied
|
local copied
|
||||||
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
|
copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
|
||||||
if [[ -z "$copied" ]]; then
|
if [[ -z "$copied" ]]; then
|
||||||
echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
|
echo " ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " done"
|
echo " done"
|
||||||
@@ -414,6 +500,135 @@ run_migrate_store() {
|
|||||||
echo " done"
|
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
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -541,9 +756,15 @@ init_migration() {
|
|||||||
ENABLE_FLOW="no"
|
ENABLE_FLOW="no"
|
||||||
echo "Step 3 (traffic flow) skipped — requires Postgres."
|
echo "Step 3 (traffic flow) skipped — requires Postgres."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
check_data_directory
|
||||||
|
check_stale_postgres_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
apply_changes() {
|
apply_changes() {
|
||||||
|
# From here on a failure must roll the deployment back.
|
||||||
|
ROLLBACK_STATE="armed"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Writing $OVERRIDE_FILE ..."
|
echo "Writing $OVERRIDE_FILE ..."
|
||||||
install -m 644 /dev/null "$OVERRIDE_FILE"
|
install -m 644 /dev/null "$OVERRIDE_FILE"
|
||||||
@@ -564,6 +785,14 @@ apply_changes() {
|
|||||||
# picks it up automatically.
|
# picks it up automatically.
|
||||||
echo "Writing .env additions (mode 600) ..."
|
echo "Writing .env additions (mode 600) ..."
|
||||||
local ENV_FILE=".env"
|
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"
|
touch "$ENV_FILE"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
{
|
{
|
||||||
@@ -592,11 +821,16 @@ apply_changes() {
|
|||||||
|
|
||||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "Stopping existing services (volumes preserved) ..."
|
# Stop, but keep the containers: the backup reads the store out of one.
|
||||||
$DOCKER_COMPOSE_COMMAND down
|
echo "Stopping services so the store is quiescent ..."
|
||||||
|
$DOCKER_COMPOSE_COMMAND stop
|
||||||
|
|
||||||
backup_sqlite
|
backup_sqlite
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Removing stopped containers (volumes preserved) ..."
|
||||||
|
$DOCKER_COMPOSE_COMMAND down
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Starting Postgres ..."
|
echo "Starting Postgres ..."
|
||||||
$DOCKER_COMPOSE_COMMAND up -d postgres
|
$DOCKER_COMPOSE_COMMAND up -d postgres
|
||||||
@@ -626,6 +860,9 @@ apply_changes() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Migration complete."
|
echo "Migration complete."
|
||||||
|
|
||||||
|
# Nothing left to undo.
|
||||||
|
ROLLBACK_STATE="disarmed"
|
||||||
}
|
}
|
||||||
|
|
||||||
print_summary() {
|
print_summary() {
|
||||||
@@ -643,6 +880,7 @@ print_summary() {
|
|||||||
echo " $OVERRIDE_FILE"
|
echo " $OVERRIDE_FILE"
|
||||||
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
|
||||||
echo " .env (license key + secrets, mode 600)"
|
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)"
|
[[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Tail logs:"
|
echo " Tail logs:"
|
||||||
@@ -651,19 +889,27 @@ print_summary() {
|
|||||||
echo "──────────────────────────────────────────────────────────────────────"
|
echo "──────────────────────────────────────────────────────────────────────"
|
||||||
echo " To revert"
|
echo " To revert"
|
||||||
echo "──────────────────────────────────────────────────────────────────────"
|
echo "──────────────────────────────────────────────────────────────────────"
|
||||||
echo " $DOCKER_COMPOSE_COMMAND down"
|
|
||||||
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
|
||||||
# Resolve project-prefixed volume names now (before override is removed).
|
# Resolve the project-prefixed volume name now, before the override is gone.
|
||||||
local pg_volume data_volume_actual
|
local pg_volume
|
||||||
pg_volume=$(resolve_data_volume "netbird_postgres")
|
pg_volume=$(resolve_compose_volume "netbird_postgres")
|
||||||
data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
|
echo " # Stop, but keep the containers so the store can be copied back in:"
|
||||||
echo " # Remove the Postgres volume FIRST, before deleting the override file:"
|
echo " $DOCKER_COMPOSE_COMMAND stop"
|
||||||
echo " docker volume rm $pg_volume"
|
|
||||||
echo " # Restore SQLite from the backup created during this run:"
|
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/'"
|
echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
|
||||||
|
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||||
|
echo " docker volume rm $pg_volume"
|
||||||
|
else
|
||||||
|
echo " $DOCKER_COMPOSE_COMMAND down"
|
||||||
fi
|
fi
|
||||||
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
|
||||||
echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
|
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 " $DOCKER_COMPOSE_COMMAND up -d"
|
echo " $DOCKER_COMPOSE_COMMAND up -d"
|
||||||
echo "──────────────────────────────────────────────────────────────────────"
|
echo "──────────────────────────────────────────────────────────────────────"
|
||||||
}
|
}
|
||||||
@@ -672,6 +918,10 @@ print_summary() {
|
|||||||
# Run
|
# 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
|
init_migration
|
||||||
apply_changes
|
apply_changes
|
||||||
print_summary
|
print_summary
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
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"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
"github.com/netbirdio/netbird/management/server/permissions"
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/management/server/store"
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
@@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
|||||||
Return(true, context.Background(), nil).
|
Return(true, context.Background(), nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
|
|
||||||
manager := agentnetwork.NewManager(st, perms, nil, nil)
|
// 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)
|
||||||
h := &handler{manager: manager}
|
h := &handler{manager: manager}
|
||||||
|
|
||||||
router := mux.NewRouter()
|
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.addPolicyEndpoints(router)
|
||||||
h.addConsumptionEndpoints(router)
|
h.addConsumptionEndpoints(router)
|
||||||
h.addBudgetRuleEndpoints(router)
|
h.addBudgetRuleEndpoints(router)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
nethttp "net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) {
|
|||||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
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,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -11,19 +10,20 @@ import (
|
|||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// addSettingsEndpoints registers the Agent Network settings routes. The
|
// addSettingsEndpoints registers the Agent Network settings routes. The
|
||||||
// settings row is bootstrapped server-side on first provider create; GET reads
|
// settings row is bootstrapped server-side on first provider create or on the
|
||||||
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
|
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
|
||||||
// immutable).
|
// of the mutable collection toggles (cluster/subdomain stay immutable).
|
||||||
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
||||||
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
||||||
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSettings applies the collection toggles to the account's settings row.
|
// 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.
|
||||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,11 +48,9 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSettings returns the account's agent-network settings. The settings
|
// getSettings returns the account's agent-network settings. Accounts that
|
||||||
// row is bootstrapped on first provider create, so freshly-onboarded
|
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
||||||
// accounts have nothing to read. Rather than 404-ing in that case (which
|
// subdomain and endpoint; the manager synthesises that view.
|
||||||
// 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) {
|
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,11 +60,6 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||||
if err != nil {
|
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)
|
util.WriteError(r.Context(), err, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -207,7 +207,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(bootstrapCluster) != "" {
|
if strings.TrimSpace(bootstrapCluster) != "" {
|
||||||
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
|
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
|
||||||
// The provider create has already succeeded; logging the
|
// The provider create has already succeeded; logging the
|
||||||
// bootstrap miss matches the plan's PoC behaviour. The synth
|
// bootstrap miss matches the plan's PoC behaviour. The synth
|
||||||
// path treats a missing settings row as a no-op, and the next
|
// path treats a missing settings row as a no-op, and the next
|
||||||
@@ -559,40 +559,83 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings applies the mutable account-level settings — the collection
|
// UpdateSettings replaces the mutable account-level settings — the collection
|
||||||
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
|
// toggles and retention — on the account's row. When the account has no
|
||||||
// preserved from the persisted row regardless of the input. Because the
|
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
|
||||||
// collection toggles change the synthesised service config (prompt-capture
|
// first provider create); without it the update fails with NotFound. On an
|
||||||
// gating, access-log emission), a reconcile is triggered so the proxy and peer
|
// existing row the cluster and subdomain are immutable: a differing
|
||||||
// network maps converge on the new state.
|
// 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.
|
||||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
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, modules.AgentNetworkSettings, operations.Update); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
|
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
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
return nil, 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{
|
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
|
||||||
"log_collection": existing.EnableLogCollection,
|
"log_collection": updated.EnableLogCollection,
|
||||||
"prompt_collection": existing.EnablePromptCollection,
|
"prompt_collection": updated.EnablePromptCollection,
|
||||||
"redact_pii": existing.RedactPii,
|
"redact_pii": updated.RedactPii,
|
||||||
})
|
})
|
||||||
m.reconcile(ctx, settings.AccountID)
|
m.reconcile(ctx, settings.AccountID)
|
||||||
|
|
||||||
return existing, nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateProviderRefs ensures every destination provider id refers to a
|
// validateProviderRefs ensures every destination provider id refers to a
|
||||||
@@ -616,22 +659,25 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings returns the agent-network settings row for the account.
|
// GetSettings returns the agent-network settings row for the account. When no
|
||||||
// Returns the underlying status.NotFound when no row has been
|
// row has been bootstrapped yet, the defaults are returned (without
|
||||||
// bootstrapped yet (i.e. the account has no providers).
|
// persisting) with cluster and subdomain empty — settings always read as an
|
||||||
|
// object, like the account and DNS settings endpoints.
|
||||||
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
|
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, modules.AgentNetworkSettings, operations.Read); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
|
||||||
// settings row when missing. The cluster comes from the create-time
|
|
||||||
// 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.
|
|
||||||
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
|
||||||
// first provider create performs. Pinning the account's cluster and subdomain
|
// 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
|
// is a settings write, so it needs the settings permission on top of the
|
||||||
@@ -641,14 +687,20 @@ func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, ac
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var sErr *status.Error
|
if !isNotFound(err) {
|
||||||
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
|
|
||||||
return fmt.Errorf("get agent network settings: %w", err)
|
return fmt.Errorf("get agent network settings: %w", err)
|
||||||
}
|
}
|
||||||
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
|
// bootstrapSettingsIfNeeded creates the per-account agent-network
|
||||||
|
// settings row when missing. The cluster comes from the create-time
|
||||||
|
// 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) {
|
||||||
if accountID == "" {
|
if accountID == "" {
|
||||||
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
return nil, fmt.Errorf("bootstrap settings: account id is required")
|
||||||
}
|
}
|
||||||
@@ -656,16 +708,15 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
|||||||
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
var sErr *status.Error
|
if !isNotFound(err) {
|
||||||
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
|
|
||||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
|
||||||
}
|
}
|
||||||
@@ -684,18 +735,12 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
|
|||||||
m.labelRngMu.Unlock()
|
m.labelRngMu.Unlock()
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
settings := &types.Settings{
|
settings := types.DefaultSettings(accountID)
|
||||||
AccountID: accountID,
|
settings.Cluster = providerCluster
|
||||||
Cluster: providerCluster,
|
settings.Subdomain = subdomain
|
||||||
Subdomain: subdomain,
|
settings.CreatedAt = now
|
||||||
// Logs on by default; usage is collected regardless. Retention bounds
|
settings.UpdatedAt = now
|
||||||
// how long full log rows are kept.
|
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
|
||||||
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 nil, fmt.Errorf("save agent network settings: %w", err)
|
||||||
}
|
}
|
||||||
return settings, nil
|
return settings, nil
|
||||||
@@ -898,8 +943,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
|
|||||||
|
|
||||||
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
|
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
|
||||||
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
|
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
|
||||||
return nil, status.Errorf(status.NotFound, "agent network settings not found")
|
return types.DefaultSettings(accountID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
||||||
|
|||||||
@@ -164,9 +164,7 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
|||||||
p.MetadataDisabled = *req.MetadataDisabled
|
p.MetadataDisabled = *req.MetadataDisabled
|
||||||
}
|
}
|
||||||
// Identity-header overrides for catalogs flagged Customizable.
|
// Identity-header overrides for catalogs flagged Customizable.
|
||||||
// nil pointer = "field omitted on the wire" → leave the stored
|
// Empty or omitted disables stamping for this dimension.
|
||||||
// value untouched (per the openapi description). Empty string is
|
|
||||||
// an explicit clear that disables stamping for this dimension.
|
|
||||||
if req.IdentityHeaderUserId != nil {
|
if req.IdentityHeaderUserId != nil {
|
||||||
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
||||||
}
|
}
|
||||||
@@ -192,16 +190,20 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
|||||||
created := p.CreatedAt
|
created := p.CreatedAt
|
||||||
updated := p.UpdatedAt
|
updated := p.UpdatedAt
|
||||||
resp := &api.AgentNetworkProvider{
|
resp := &api.AgentNetworkProvider{
|
||||||
Id: p.ID,
|
Id: p.ID,
|
||||||
ProviderId: p.ProviderID,
|
ProviderId: p.ProviderID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
UpstreamUrl: p.UpstreamURL,
|
UpstreamUrl: p.UpstreamURL,
|
||||||
Models: models,
|
Models: models,
|
||||||
Enabled: p.Enabled,
|
// Always present on the wire so an explicitly cleared header
|
||||||
SkipTlsVerification: p.SkipTLSVerification,
|
// round-trips as "" instead of vanishing from the response.
|
||||||
MetadataDisabled: p.MetadataDisabled,
|
IdentityHeaderUserId: p.IdentityHeaderUserID,
|
||||||
CreatedAt: &created,
|
IdentityHeaderGroups: p.IdentityHeaderGroups,
|
||||||
UpdatedAt: &updated,
|
Enabled: p.Enabled,
|
||||||
|
SkipTlsVerification: p.SkipTLSVerification,
|
||||||
|
MetadataDisabled: p.MetadataDisabled,
|
||||||
|
CreatedAt: &created,
|
||||||
|
UpdatedAt: &updated,
|
||||||
}
|
}
|
||||||
if len(p.ExtraValues) > 0 {
|
if len(p.ExtraValues) > 0 {
|
||||||
out := make(map[string]string, len(p.ExtraValues))
|
out := make(map[string]string, len(p.ExtraValues))
|
||||||
@@ -210,14 +212,6 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
|||||||
}
|
}
|
||||||
resp.ExtraValues = &out
|
resp.ExtraValues = &out
|
||||||
}
|
}
|
||||||
if p.IdentityHeaderUserID != "" {
|
|
||||||
v := p.IdentityHeaderUserID
|
|
||||||
resp.IdentityHeaderUserId = &v
|
|
||||||
}
|
|
||||||
if p.IdentityHeaderGroups != "" {
|
|
||||||
v := p.IdentityHeaderGroups
|
|
||||||
resp.IdentityHeaderGroups = &v
|
|
||||||
}
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,41 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
|
|||||||
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
||||||
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
|
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,6 +1,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
@@ -42,18 +43,34 @@ type Settings struct {
|
|||||||
// schema cohesive.
|
// schema cohesive.
|
||||||
func (Settings) TableName() string { return "agent_network_settings" }
|
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:
|
// Endpoint returns the bare hostname agents reach this account at:
|
||||||
// `<subdomain>.<cluster>`.
|
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
|
||||||
func (s *Settings) Endpoint() string {
|
func (s *Settings) Endpoint() string {
|
||||||
|
if s.Cluster == "" || s.Subdomain == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return s.Subdomain + "." + s.Cluster
|
return s.Subdomain + "." + s.Cluster
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToAPIResponse renders the settings as the API representation.
|
// 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.
|
||||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||||
created := s.CreatedAt
|
|
||||||
updated := s.UpdatedAt
|
|
||||||
retention := s.AccessLogRetentionDays
|
retention := s.AccessLogRetentionDays
|
||||||
return &api.AgentNetworkSettings{
|
resp := &api.AgentNetworkSettings{
|
||||||
Cluster: s.Cluster,
|
Cluster: s.Cluster,
|
||||||
Subdomain: s.Subdomain,
|
Subdomain: s.Subdomain,
|
||||||
Endpoint: s.Endpoint(),
|
Endpoint: s.Endpoint(),
|
||||||
@@ -61,14 +78,27 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
|||||||
EnablePromptCollection: s.EnablePromptCollection,
|
EnablePromptCollection: s.EnablePromptCollection,
|
||||||
RedactPii: s.RedactPii,
|
RedactPii: s.RedactPii,
|
||||||
AccessLogRetentionDays: &retention,
|
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 mutable settings fields from the request. Cluster
|
// FromAPIRequest applies the request onto the receiver. The mutable
|
||||||
// and Subdomain are immutable and intentionally not touched here.
|
// 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.
|
||||||
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
||||||
|
if req.Cluster != nil {
|
||||||
|
s.Cluster = strings.TrimSpace(*req.Cluster)
|
||||||
|
}
|
||||||
s.EnableLogCollection = req.EnableLogCollection
|
s.EnableLogCollection = req.EnableLogCollection
|
||||||
s.EnablePromptCollection = req.EnablePromptCollection
|
s.EnablePromptCollection = req.EnablePromptCollection
|
||||||
s.RedactPii = req.RedactPii
|
s.RedactPii = req.RedactPii
|
||||||
|
|||||||
@@ -102,11 +102,20 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
|||||||
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
|
||||||
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
|
||||||
|
|
||||||
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
|
// A cluster different from the one pinned at bootstrap must be rejected
|
||||||
// immutable fields must be ignored.
|
// outright — never silently swapped or ignored.
|
||||||
|
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||||
|
AccountID: accountID,
|
||||||
|
Cluster: "attacker.cluster",
|
||||||
|
EnableLogCollection: true,
|
||||||
|
})
|
||||||
|
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
|
||||||
|
|
||||||
|
// Flipping the toggles works with the pinned cluster echoed back (and
|
||||||
|
// with it omitted); the subdomain is never taken from the request.
|
||||||
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Cluster: "attacker.cluster",
|
Cluster: clusterAddr,
|
||||||
Subdomain: "evil",
|
Subdomain: "evil",
|
||||||
EnableLogCollection: true,
|
EnableLogCollection: true,
|
||||||
EnablePromptCollection: true,
|
EnablePromptCollection: true,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -744,6 +746,14 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty
|
|||||||
return &GroupLinkError{"network router", linkedRouter.ID}
|
return &GroupLinkError{"network router", linkedRouter.ID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isLinked, linkedService := isGroupLinkedToReverseProxyService(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||||
|
return &GroupLinkError{"reverse proxy service", linkedService.Domain}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLinked, linkedPolicy := isGroupLinkedToAgentNetworkPolicy(ctx, transaction, group.AccountID, group.ID); isLinked {
|
||||||
|
return &GroupLinkError{"agent network policy", linkedPolicy.Name}
|
||||||
|
}
|
||||||
|
|
||||||
return checkGroupLinkedToSettings(ctx, transaction, group)
|
return checkGroupLinkedToSettings(ctx, transaction, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,6 +885,46 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isGroupLinkedToReverseProxyService checks if a group is used as an access group
|
||||||
|
// of a private reverse proxy service or as a bearer-auth distribution group.
|
||||||
|
func isGroupLinkedToReverseProxyService(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *service.Service) {
|
||||||
|
services, err := transaction.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("error retrieving reverse proxy services while checking group linkage: %v", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc.Private && slices.Contains(svc.AccessGroups, groupID) {
|
||||||
|
return true, svc
|
||||||
|
}
|
||||||
|
if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled && slices.Contains(svc.Auth.BearerAuth.DistributionGroups, groupID) {
|
||||||
|
return true, svc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGroupLinkedToAgentNetworkPolicy checks if a group is used as a source group by any
|
||||||
|
// agent network policy in the account.
|
||||||
|
func isGroupLinkedToAgentNetworkPolicy(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.Policy) {
|
||||||
|
policies, err := transaction.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("error retrieving agent network policies while checking group linkage: %v", err)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, policy := range policies {
|
||||||
|
if policy == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(policy.SourceGroups, groupID) {
|
||||||
|
return true, policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
|
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
|
||||||
// It fetches each collection once and checks all groupIDs against them in memory.
|
// It fetches each collection once and checks all groupIDs against them in memory.
|
||||||
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
|
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||||
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
"github.com/netbirdio/netbird/management/server/groups"
|
"github.com/netbirdio/netbird/management/server/groups"
|
||||||
"github.com/netbirdio/netbird/management/server/networks"
|
"github.com/netbirdio/netbird/management/server/networks"
|
||||||
"github.com/netbirdio/netbird/management/server/networks/resources"
|
"github.com/netbirdio/netbird/management/server/networks/resources"
|
||||||
@@ -125,6 +127,21 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
|
|||||||
"grp-for-integration",
|
"grp-for-integration",
|
||||||
"only service users with admin power can delete integration group",
|
"only service users with admin power can delete integration group",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"agent network policy",
|
||||||
|
"grp-for-agent-network-policy",
|
||||||
|
"agent network policy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reverse proxy private service access group",
|
||||||
|
"grp-for-rp-private",
|
||||||
|
"reverse proxy service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reverse proxy bearer distribution group",
|
||||||
|
"grp-for-rp-bearer",
|
||||||
|
"reverse proxy service",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
@@ -218,6 +235,17 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
|||||||
groupIDs: []string{"grp-for-integration"},
|
groupIDs: []string{"grp-for-integration"},
|
||||||
expectedReasons: []string{"only service users with admin power can delete integration group"},
|
expectedReasons: []string{"only service users with admin power can delete integration group"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "agent network policy",
|
||||||
|
groupIDs: []string{"grp-for-agent-network-policy"},
|
||||||
|
expectedReasons: []string{"agent network policy"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reverse proxy services",
|
||||||
|
groupIDs: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||||
|
expectedReasons: []string{"reverse proxy service", "reverse proxy service"},
|
||||||
|
expectedNotDeleted: []string{"grp-for-rp-private", "grp-for-rp-bearer"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "successfully delete multiple groups",
|
name: "successfully delete multiple groups",
|
||||||
groupIDs: []string{"group-1", "group-2"},
|
groupIDs: []string{"group-1", "group-2"},
|
||||||
@@ -285,6 +313,65 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultAccountManager_DeleteGroupUnlinkedFromReverseProxyService(t *testing.T) {
|
||||||
|
am, _, err := createManager(t)
|
||||||
|
require.NoError(t, err, "Failed to create account manager")
|
||||||
|
|
||||||
|
_, account, err := initTestGroupAccount(am)
|
||||||
|
require.NoError(t, err, "Failed to init testing account")
|
||||||
|
|
||||||
|
deletableGroups := []*types.Group{
|
||||||
|
{
|
||||||
|
ID: "grp-rp-bearer-disabled",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Name: "Group only in a disabled bearer auth",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "grp-rp-nonprivate-access",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Name: "Group only in a non-private service's access groups",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, group := range deletableGroups {
|
||||||
|
require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, group))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled bearer auth and stale access groups on a non-private service
|
||||||
|
// are inert configuration and must not block group deletion.
|
||||||
|
services := []*rpservice.Service{
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer-disabled",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Domain: "bearer-disabled.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: false,
|
||||||
|
DistributionGroups: []string{"grp-rp-bearer-disabled"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-nonprivate-access",
|
||||||
|
AccountID: account.Id,
|
||||||
|
Domain: "nonprivate.services.example.com",
|
||||||
|
Private: false,
|
||||||
|
AccessGroups: []string{"grp-rp-nonprivate-access"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, svc := range services {
|
||||||
|
require.NoError(t, am.Store.CreateService(context.Background(), svc))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range deletableGroups {
|
||||||
|
err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, group.ID)
|
||||||
|
assert.NoError(t, err, "group %s is not referenced by an active reverse proxy gate and should be deletable", group.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
|
func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) {
|
||||||
am, _, err := createManager(t)
|
am, _, err := createManager(t)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -406,6 +493,30 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
|||||||
Peers: make([]string, 0),
|
Peers: make([]string, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupForAgentNetworkPolicy := &types.Group{
|
||||||
|
ID: "grp-for-agent-network-policy",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for agent network policies",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
groupForRPPrivate := &types.Group{
|
||||||
|
ID: "grp-for-rp-private",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for private reverse proxy service",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
groupForRPBearer := &types.Group{
|
||||||
|
ID: "grp-for-rp-bearer",
|
||||||
|
AccountID: "account-id",
|
||||||
|
Name: "Group for bearer reverse proxy service",
|
||||||
|
Issued: types.GroupIssuedAPI,
|
||||||
|
Peers: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
routeResource := &route.Route{
|
routeResource := &route.Route{
|
||||||
ID: "example route",
|
ID: "example route",
|
||||||
Groups: []string{groupForRoute.ID},
|
Groups: []string{groupForRoute.ID},
|
||||||
@@ -461,6 +572,66 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
|
|||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
|
||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
|
||||||
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkPolicy)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPPrivate)
|
||||||
|
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForRPBearer)
|
||||||
|
|
||||||
|
agentNetworkPolicy := &agentNetworkTypes.Policy{
|
||||||
|
ID: "example agent network policy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: "Example agent network policy",
|
||||||
|
Enabled: true,
|
||||||
|
SourceGroups: []string{groupForAgentNetworkPolicy.ID},
|
||||||
|
}
|
||||||
|
if err := am.Store.SaveAgentNetworkPolicy(context.Background(), agentNetworkPolicy); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoy services are created first so the linkage check has to scan
|
||||||
|
// past services that do not reference the groups under test.
|
||||||
|
rpServices := []*rpservice.Service{
|
||||||
|
{
|
||||||
|
ID: "rp-svc-private-decoy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "private-decoy.services.example.com",
|
||||||
|
Private: true,
|
||||||
|
AccessGroups: []string{"unrelated-group"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer-decoy",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "bearer-decoy.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
DistributionGroups: []string{"unrelated-group"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-private",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "private.services.example.com",
|
||||||
|
Private: true,
|
||||||
|
AccessGroups: []string{groupForRPPrivate.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rp-svc-bearer",
|
||||||
|
AccountID: accountID,
|
||||||
|
Domain: "bearer.services.example.com",
|
||||||
|
Auth: rpservice.AuthConfig{
|
||||||
|
BearerAuth: &rpservice.BearerAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
DistributionGroups: []string{groupForRPBearer.ID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, svc := range rpServices {
|
||||||
|
if err := am.Store.CreateService(context.Background(), svc); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
acc, err := am.Store.GetAccount(context.Background(), account.Id)
|
acc, err := am.Store.GetAccount(context.Background(), account.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1707,14 +1707,34 @@ func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers
|
|||||||
if len(proxyPeers) == 0 {
|
if len(proxyPeers) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A service's AccessGroups can name groups that no longer exist — persisted
|
||||||
|
// services and the agent-network synthesiser both carry the ids verbatim from
|
||||||
|
// their own state. An unresolvable source authorises nothing, so drop it here
|
||||||
|
// rather than let the network-map assembly resolve it to a nil group.
|
||||||
|
sources := a.existingGroupIDs(svc.AccessGroups)
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, proxyPeer := range proxyPeers {
|
for _, proxyPeer := range proxyPeers {
|
||||||
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer))
|
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy {
|
// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account,
|
||||||
|
// preserving the input order.
|
||||||
|
func (a *Account) existingGroupIDs(groupIDs []string) []string {
|
||||||
|
out := make([]string, 0, len(groupIDs))
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
if _, ok := a.Groups[groupID]; ok {
|
||||||
|
out = append(out, groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy {
|
||||||
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
|
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
|
||||||
sources := append([]string(nil), svc.AccessGroups...)
|
sources := append([]string(nil), accessGroups...)
|
||||||
return &Policy{
|
return &Policy{
|
||||||
ID: policyID,
|
ID: policyID,
|
||||||
Name: fmt.Sprintf("Private Access to %s", svc.Name),
|
Name: fmt.Sprintf("Private Access to %s", svc.Name),
|
||||||
|
|||||||
@@ -4,9 +4,67 @@ set -x
|
|||||||
|
|
||||||
LOG_FILE=/var/log/netbird/client_pre_install.log
|
LOG_FILE=/var/log/netbird/client_pre_install.log
|
||||||
AGENT=/usr/local/bin/netbird
|
AGENT=/usr/local/bin/netbird
|
||||||
|
UI_PROCESS=netbird-ui
|
||||||
|
|
||||||
mkdir -p /var/log/netbird/
|
mkdir -p /var/log/netbird/
|
||||||
|
|
||||||
|
# wait_for_ui_exit polls for up to $1 seconds, returning 0 as soon as no UI
|
||||||
|
# process is left and 1 if one is still running when the time is up.
|
||||||
|
wait_for_ui_exit() {
|
||||||
|
waited=0
|
||||||
|
while [ "$waited" -lt "$1" ]; do
|
||||||
|
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# request_ui_quit asks the UI to quit from inside the console user's session and
|
||||||
|
# reports whether the request could be sent at all. The installer runs as root
|
||||||
|
# outside that session, so a quit Apple event sent straight from here always
|
||||||
|
# fails with -600.
|
||||||
|
request_ui_quit() {
|
||||||
|
console_user=$(stat -f%Su /dev/console 2>/dev/null)
|
||||||
|
case "$console_user" in
|
||||||
|
""|root|loginwindow|_mbsetupuser)
|
||||||
|
echo "No active GUI user session (console user: '${console_user:-none}'); skipping the quit request."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
uid=$(id -u "$console_user" 2>/dev/null)
|
||||||
|
if [ -z "$uid" ]; then
|
||||||
|
echo "Could not resolve uid for console user '$console_user'; skipping the quit request."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Asking the NetBird UI to quit as console user $console_user (uid $uid)."
|
||||||
|
launchctl asuser "$uid" sudo -u "$console_user" -H osascript -e 'quit app "NetBird"' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# quit_ui stops a running UI so the app bundle can be replaced underneath it. A
|
||||||
|
# UI process that survives the install keeps serving the old binary until it is
|
||||||
|
# quit by hand, so anything still running once the quit request is out of the
|
||||||
|
# way is signalled. Waiting for a graceful exit only makes sense when a quit
|
||||||
|
# request was actually sent.
|
||||||
|
quit_ui() {
|
||||||
|
if request_ui_quit && wait_for_ui_exit 10; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
echo "NetBird UI still running; terminating it."
|
||||||
|
pkill -x "$UI_PROCESS" || true
|
||||||
|
if wait_for_ui_exit 3; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "NetBird UI ignored SIGTERM; killing it."
|
||||||
|
pkill -KILL -x "$UI_PROCESS" || true
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
# check if it was installed with brew
|
# check if it was installed with brew
|
||||||
brew list --formula | grep netbird
|
brew list --formula | grep netbird
|
||||||
@@ -15,10 +73,9 @@ mkdir -p /var/log/netbird/
|
|||||||
echo "NetBird has been installed with Brew. Please use Brew to update the package."
|
echo "NetBird has been installed with Brew. Please use Brew to update the package."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
osascript -e 'quit app "Netbird"' || true
|
quit_ui
|
||||||
$AGENT service stop || true
|
$AGENT service stop || true
|
||||||
|
|
||||||
echo "Preinstall complete"
|
echo "Preinstall complete"
|
||||||
exit 0 # all good
|
exit 0 # all good
|
||||||
} &> $LOG_FILE
|
} &> $LOG_FILE
|
||||||
|
|
||||||
|
|||||||
381
shared/management/client/rest/agentnetwork.go
Normal file
381
shared/management/client/rest/agentnetwork.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentNetworkAPI APIs for the Agent Network (AI/LLM gateway), do not use directly
|
||||||
|
// see more: https://docs.netbird.io/api/resources/agent-network
|
||||||
|
type AgentNetworkAPI struct {
|
||||||
|
c *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCatalogProviders lists the catalog of supported upstream AI providers
|
||||||
|
// (openai_api, anthropic_api, bedrock_api, ...) with their default models and
|
||||||
|
// pricing, used to prefill provider create forms.
|
||||||
|
func (a *AgentNetworkAPI) ListCatalogProviders(ctx context.Context) ([]api.AgentNetworkCatalogProvider, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/catalog/providers", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[[]api.AgentNetworkCatalogProvider](resp)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders lists all Agent Network providers
|
||||||
|
func (a *AgentNetworkAPI) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[[]api.AgentNetworkProvider](resp)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProvider gets Agent Network provider info
|
||||||
|
func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*api.AgentNetworkProvider, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers/"+providerID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider creates a new Agent Network provider. Set
|
||||||
|
// request.BootstrapCluster on the account's first provider to bootstrap the
|
||||||
|
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
|
||||||
|
// with a cluster).
|
||||||
|
func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/providers", bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProvider updates an Agent Network provider. The request replaces the
|
||||||
|
// provider's mutable state; only an omitted api_key keeps the stored key
|
||||||
|
// (secrets are never required to round-trip).
|
||||||
|
func (a *AgentNetworkAPI) UpdateProvider(ctx context.Context, providerID string, request api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody) (*api.AgentNetworkProvider, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/providers/"+providerID, bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkProvider](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider deletes an Agent Network provider. Fails while any policy
|
||||||
|
// still references the provider — detach it first.
|
||||||
|
func (a *AgentNetworkAPI) DeleteProvider(ctx context.Context, providerID string) error {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/providers/"+providerID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPolicies lists all Agent Network policies
|
||||||
|
func (a *AgentNetworkAPI) ListPolicies(ctx context.Context) ([]api.AgentNetworkPolicy, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[[]api.AgentNetworkPolicy](resp)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPolicy gets Agent Network policy info
|
||||||
|
func (a *AgentNetworkAPI) GetPolicy(ctx context.Context, policyID string) (*api.AgentNetworkPolicy, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies/"+policyID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePolicy creates a new Agent Network policy
|
||||||
|
func (a *AgentNetworkAPI) CreatePolicy(ctx context.Context, request api.PostApiAgentNetworkPoliciesJSONRequestBody) (*api.AgentNetworkPolicy, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/policies", bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePolicy updates an Agent Network policy
|
||||||
|
func (a *AgentNetworkAPI) UpdatePolicy(ctx context.Context, policyID string, request api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody) (*api.AgentNetworkPolicy, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/policies/"+policyID, bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePolicy deletes an Agent Network policy
|
||||||
|
func (a *AgentNetworkAPI) DeletePolicy(ctx context.Context, policyID string) error {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/policies/"+policyID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGuardrails lists all Agent Network guardrails
|
||||||
|
func (a *AgentNetworkAPI) ListGuardrails(ctx context.Context) ([]api.AgentNetworkGuardrail, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[[]api.AgentNetworkGuardrail](resp)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGuardrail gets Agent Network guardrail info
|
||||||
|
func (a *AgentNetworkAPI) GetGuardrail(ctx context.Context, guardrailID string) (*api.AgentNetworkGuardrail, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGuardrail creates a new Agent Network guardrail
|
||||||
|
func (a *AgentNetworkAPI) CreateGuardrail(ctx context.Context, request api.PostApiAgentNetworkGuardrailsJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/guardrails", bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGuardrail updates an Agent Network guardrail
|
||||||
|
func (a *AgentNetworkAPI) UpdateGuardrail(ctx context.Context, guardrailID string, request api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/guardrails/"+guardrailID, bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGuardrail deletes an Agent Network guardrail
|
||||||
|
func (a *AgentNetworkAPI) DeleteGuardrail(ctx context.Context, guardrailID string) error {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBudgetRules lists all account-level Agent Network budget rules
|
||||||
|
func (a *AgentNetworkAPI) ListBudgetRules(ctx context.Context) ([]api.AgentNetworkBudgetRule, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[[]api.AgentNetworkBudgetRule](resp)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBudgetRule gets Agent Network budget rule info
|
||||||
|
func (a *AgentNetworkAPI) GetBudgetRule(ctx context.Context, ruleID string) (*api.AgentNetworkBudgetRule, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBudgetRule creates a new Agent Network budget rule
|
||||||
|
func (a *AgentNetworkAPI) CreateBudgetRule(ctx context.Context, request api.PostApiAgentNetworkBudgetRulesJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/budget-rules", bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBudgetRule updates an Agent Network budget rule
|
||||||
|
func (a *AgentNetworkAPI) UpdateBudgetRule(ctx context.Context, ruleID string, request api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/budget-rules/"+ruleID, bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBudgetRule deletes an Agent Network budget rule
|
||||||
|
func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) error {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings gets the account's Agent Network gateway settings (cluster,
|
||||||
|
// subdomain, endpoint, collection toggles). An account that has not been
|
||||||
|
// bootstrapped yet — via UpdateSettings with a cluster, or by creating the
|
||||||
|
// first provider with bootstrap_cluster set — reads as the defaults with an
|
||||||
|
// empty Cluster, Subdomain and Endpoint. Management servers prior to that
|
||||||
|
// contract answered 200 with a JSON null body instead; that legacy shape is
|
||||||
|
// translated to an APIError matchable via IsNotFound rather than fabricating
|
||||||
|
// defaults the server never stated.
|
||||||
|
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
|
||||||
|
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||||
|
}
|
||||||
|
var ret api.AgentNetworkSettings
|
||||||
|
if err := json.Unmarshal(body, &ret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings updates the account's Agent Network settings; the request
|
||||||
|
// replaces every mutable field (collection toggles and retention). Setting
|
||||||
|
// request.Cluster bootstraps the settings row when the account does not have
|
||||||
|
// one yet; on a bootstrapped account it must match the assigned cluster (or
|
||||||
|
// be nil) and any other value is rejected — the cluster is immutable.
|
||||||
|
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||||
|
requestBytes, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||||
|
return &ret, err
|
||||||
|
}
|
||||||
497
shared/management/client/rest/agentnetwork_test.go
Normal file
497
shared/management/client/rest/agentnetwork_test.go
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package rest_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/shared/management/client/rest"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testAgentNetworkProvider = api.AgentNetworkProvider{
|
||||||
|
Id: "ainp_test",
|
||||||
|
ProviderId: "openai_api",
|
||||||
|
Name: "OpenAI",
|
||||||
|
UpstreamUrl: "https://api.openai.com",
|
||||||
|
Models: []api.AgentNetworkProviderModel{},
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentNetworkPolicy = api.AgentNetworkPolicy{
|
||||||
|
Id: "ainpol_test",
|
||||||
|
Name: "Engineering → OpenAI",
|
||||||
|
Enabled: true,
|
||||||
|
SourceGroups: []string{"grp-eng"},
|
||||||
|
DestinationProviderIds: []string{"ainp_test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentNetworkGuardrail = api.AgentNetworkGuardrail{
|
||||||
|
Id: "aingr_test",
|
||||||
|
Name: "No secrets",
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentNetworkBudgetRule = api.AgentNetworkBudgetRule{
|
||||||
|
Id: "ainbud_test",
|
||||||
|
Name: "Org monthly ceiling",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentNetworkSettings = api.AgentNetworkSettings{
|
||||||
|
Cluster: "eu.proxy.netbird.io",
|
||||||
|
Subdomain: "violet",
|
||||||
|
Endpoint: "violet.eu.proxy.netbird.io",
|
||||||
|
EnableLogCollection: true,
|
||||||
|
AccessLogRetentionDays: ptr(30),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentNetwork_ListCatalogProviders_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/catalog/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal([]api.AgentNetworkCatalogProvider{{Id: "openai_api", Name: "OpenAI"}})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.ListCatalogProviders(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ret, 1)
|
||||||
|
assert.Equal(t, "openai_api", ret[0].Id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_ListProviders_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal([]api.AgentNetworkProvider{testAgentNetworkProvider})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.ListProviders(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ret, 1)
|
||||||
|
assert.Equal(t, testAgentNetworkProvider, ret[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetProvider_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetProvider_Err(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "not found", Code: 404})
|
||||||
|
w.WriteHeader(404)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, rest.IsNotFound(err), "a 404 must be matchable via IsNotFound")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_CreateProvider_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
reqBytes, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var req api.PostApiAgentNetworkProvidersJSONRequestBody
|
||||||
|
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||||
|
assert.Equal(t, "OpenAI", req.Name)
|
||||||
|
require.NotNil(t, req.BootstrapCluster)
|
||||||
|
assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||||
|
_, err = w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{
|
||||||
|
ProviderId: "openai_api",
|
||||||
|
Name: "OpenAI",
|
||||||
|
UpstreamUrl: "https://api.openai.com",
|
||||||
|
ApiKey: ptr("sk-test"),
|
||||||
|
BootstrapCluster: ptr("eu.proxy.netbird.io"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdateProvider_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
reqBytes, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Omitted optional fields must be absent from the wire (not
|
||||||
|
// zero-valued) so the server-side merge preserves them.
|
||||||
|
assert.NotContains(t, string(reqBytes), "api_key")
|
||||||
|
assert.NotContains(t, string(reqBytes), "models")
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkProvider)
|
||||||
|
_, err = w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.UpdateProvider(context.Background(), "ainp_test", api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody{
|
||||||
|
ProviderId: "openai_api",
|
||||||
|
Name: "OpenAI",
|
||||||
|
UpstreamUrl: "https://api.openai.com",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkProvider, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_DeleteProvider_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "DELETE", r.Method)
|
||||||
|
_, err := w.Write([]byte("{}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
err := c.AgentNetwork.DeleteProvider(context.Background(), "ainp_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_ListPolicies_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal([]api.AgentNetworkPolicy{testAgentNetworkPolicy})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.ListPolicies(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ret, 1)
|
||||||
|
assert.Equal(t, testAgentNetworkPolicy, ret[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetPolicy_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetPolicy(context.Background(), "ainpol_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_CreatePolicy_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.CreatePolicy(context.Background(), api.PostApiAgentNetworkPoliciesJSONRequestBody{
|
||||||
|
Name: "Engineering → OpenAI",
|
||||||
|
SourceGroups: []string{"grp-eng"},
|
||||||
|
DestinationProviderIds: []string{"ainp_test"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdatePolicy_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.UpdatePolicy(context.Background(), "ainpol_test", api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody{
|
||||||
|
Name: "Engineering → OpenAI",
|
||||||
|
SourceGroups: []string{"grp-eng"},
|
||||||
|
DestinationProviderIds: []string{"ainp_test"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkPolicy, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_DeletePolicy_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "DELETE", r.Method)
|
||||||
|
_, err := w.Write([]byte("{}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
err := c.AgentNetwork.DeletePolicy(context.Background(), "ainpol_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_ListGuardrails_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal([]api.AgentNetworkGuardrail{testAgentNetworkGuardrail})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.ListGuardrails(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ret, 1)
|
||||||
|
assert.Equal(t, testAgentNetworkGuardrail, ret[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetGuardrail_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetGuardrail(context.Background(), "aingr_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_CreateGuardrail_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.CreateGuardrail(context.Background(), api.PostApiAgentNetworkGuardrailsJSONRequestBody{
|
||||||
|
Name: "No secrets",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdateGuardrail_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.UpdateGuardrail(context.Background(), "aingr_test", api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody{
|
||||||
|
Name: "No secrets",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkGuardrail, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_DeleteGuardrail_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "DELETE", r.Method)
|
||||||
|
_, err := w.Write([]byte("{}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
err := c.AgentNetwork.DeleteGuardrail(context.Background(), "aingr_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_ListBudgetRules_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal([]api.AgentNetworkBudgetRule{testAgentNetworkBudgetRule})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.ListBudgetRules(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ret, 1)
|
||||||
|
assert.Equal(t, testAgentNetworkBudgetRule, ret[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetBudgetRule_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetBudgetRule(context.Background(), "ainbud_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_CreateBudgetRule_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.CreateBudgetRule(context.Background(), api.PostApiAgentNetworkBudgetRulesJSONRequestBody{
|
||||||
|
Name: "Org monthly ceiling",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdateBudgetRule_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.UpdateBudgetRule(context.Background(), "ainbud_test", api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody{
|
||||||
|
Name: "Org monthly ceiling",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_DeleteBudgetRule_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "DELETE", r.Method)
|
||||||
|
_, err := w.Write([]byte("{}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
err := c.AgentNetwork.DeleteBudgetRule(context.Background(), "ainbud_test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetSettings_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNetwork_GetSettings_UnbootstrappedDefaults pins the settings-read
|
||||||
|
// contract: an unbootstrapped account answers 200 with the defaults and empty
|
||||||
|
// cluster/subdomain/endpoint, which the client passes through untouched.
|
||||||
|
func TestAgentNetwork_GetSettings_UnbootstrappedDefaults(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(api.AgentNetworkSettings{
|
||||||
|
EnableLogCollection: true,
|
||||||
|
AccessLogRetentionDays: ptr(30),
|
||||||
|
})
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, ret.Endpoint, "empty endpoint is the not-bootstrapped signal")
|
||||||
|
assert.True(t, ret.EnableLogCollection, "defaults must pass through")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_GetSettings_Err(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "no", Code: 403})
|
||||||
|
w.WriteHeader(403)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err := c.AgentNetwork.GetSettings(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, "no", err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNetwork_GetSettings_LegacyNullBody pins the compatibility shim for
|
||||||
|
// management servers that answered 200 with a JSON null body before the
|
||||||
|
// defaults contract: the client translates that shape into an IsNotFound
|
||||||
|
// error instead of returning a bogus zero-valued settings object or
|
||||||
|
// fabricating defaults the server never stated.
|
||||||
|
func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := w.Write([]byte("null"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.GetSettings(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, ret)
|
||||||
|
assert.True(t, rest.IsNotFound(err), "the legacy 200+null shape must surface as IsNotFound")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "PUT", r.Method)
|
||||||
|
reqBytes, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var req api.PutApiAgentNetworkSettingsJSONRequestBody
|
||||||
|
require.NoError(t, json.Unmarshal(reqBytes, &req))
|
||||||
|
require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire")
|
||||||
|
assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster)
|
||||||
|
assert.True(t, req.EnableLogCollection)
|
||||||
|
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||||
|
_, err = w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||||
|
Cluster: ptr("eu.proxy.netbird.io"),
|
||||||
|
EnableLogCollection: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNetwork_UpdateSettings_Err(t *testing.T) {
|
||||||
|
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
|
||||||
|
w.WriteHeader(422)
|
||||||
|
_, err := w.Write(retBytes)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||||
|
Cluster: ptr("us.proxy.netbird.io"),
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "immutable")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -147,6 +147,10 @@ type Client struct {
|
|||||||
// ReverseProxyTokens account-scoped proxy access tokens used to register
|
// ReverseProxyTokens account-scoped proxy access tokens used to register
|
||||||
// self-hosted (bring-your-own-proxy) `netbird proxy` instances.
|
// self-hosted (bring-your-own-proxy) `netbird proxy` instances.
|
||||||
ReverseProxyTokens *ReverseProxyTokensAPI
|
ReverseProxyTokens *ReverseProxyTokensAPI
|
||||||
|
|
||||||
|
// AgentNetwork NetBird Agent Network (AI/LLM gateway) APIs: catalog,
|
||||||
|
// providers, policies, guardrails, budget rules and account settings.
|
||||||
|
AgentNetwork *AgentNetworkAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
// New initialize new Client instance using PAT token
|
// New initialize new Client instance using PAT token
|
||||||
@@ -209,6 +213,7 @@ func (c *Client) initialize() {
|
|||||||
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
|
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
|
||||||
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
|
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
|
||||||
c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
|
c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
|
||||||
|
c.AgentNetwork = &AgentNetworkAPI{c}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRequest creates and executes new management API request
|
// NewRequest creates and executes new management API request
|
||||||
|
|||||||
@@ -5149,12 +5149,12 @@ components:
|
|||||||
identity_header_user_id:
|
identity_header_user_id:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
||||||
example: "x-bf-dim-netbird_user_id"
|
example: "x-bf-dim-netbird_user_id"
|
||||||
identity_header_groups:
|
identity_header_groups:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||||
example: "x-bf-dim-netbird_groups"
|
example: "x-bf-dim-netbird_groups"
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -5186,6 +5186,8 @@ components:
|
|||||||
- name
|
- name
|
||||||
- upstream_url
|
- upstream_url
|
||||||
- models
|
- models
|
||||||
|
- identity_header_user_id
|
||||||
|
- identity_header_groups
|
||||||
- enabled
|
- enabled
|
||||||
- skip_tls_verification
|
- skip_tls_verification
|
||||||
- metadata_disabled
|
- metadata_disabled
|
||||||
@@ -5222,7 +5224,7 @@ components:
|
|||||||
extra_values:
|
extra_values:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
|
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
example:
|
example:
|
||||||
@@ -5230,12 +5232,12 @@ components:
|
|||||||
identity_header_user_id:
|
identity_header_user_id:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
|
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
|
||||||
example: "x-bf-dim-netbird_user_id"
|
example: "x-bf-dim-netbird_user_id"
|
||||||
identity_header_groups:
|
identity_header_groups:
|
||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
|
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
|
||||||
example: "x-bf-dim-netbird_groups"
|
example: "x-bf-dim-netbird_groups"
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -5243,11 +5245,11 @@ components:
|
|||||||
example: true
|
example: true
|
||||||
skip_tls_verification:
|
skip_tls_verification:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
|
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
|
||||||
example: false
|
example: false
|
||||||
metadata_disabled:
|
metadata_disabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
|
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
|
||||||
example: false
|
example: false
|
||||||
required:
|
required:
|
||||||
- provider_id
|
- provider_id
|
||||||
@@ -6191,19 +6193,19 @@ components:
|
|||||||
- cache_cost_usd
|
- cache_cost_usd
|
||||||
AgentNetworkSettings:
|
AgentNetworkSettings:
|
||||||
type: object
|
type: object
|
||||||
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
|
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
|
||||||
properties:
|
properties:
|
||||||
cluster:
|
cluster:
|
||||||
type: string
|
type: string
|
||||||
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
|
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
|
||||||
example: "eu.proxy.netbird.io"
|
example: "eu.proxy.netbird.io"
|
||||||
subdomain:
|
subdomain:
|
||||||
type: string
|
type: string
|
||||||
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
|
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
|
||||||
example: "violet"
|
example: "violet"
|
||||||
endpoint:
|
endpoint:
|
||||||
type: string
|
type: string
|
||||||
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
|
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
|
||||||
example: "violet.eu.proxy.netbird.io"
|
example: "violet.eu.proxy.netbird.io"
|
||||||
enable_log_collection:
|
enable_log_collection:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -6224,13 +6226,13 @@ components:
|
|||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Timestamp when the settings row was created.
|
description: Timestamp when the settings row was created. Absent until the account is bootstrapped.
|
||||||
readOnly: true
|
readOnly: true
|
||||||
example: "2026-04-26T10:30:00Z"
|
example: "2026-04-26T10:30:00Z"
|
||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Timestamp when the settings row was last updated.
|
description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
|
||||||
readOnly: true
|
readOnly: true
|
||||||
example: "2026-04-26T10:30:00Z"
|
example: "2026-04-26T10:30:00Z"
|
||||||
required:
|
required:
|
||||||
@@ -6240,12 +6242,14 @@ components:
|
|||||||
- enable_log_collection
|
- enable_log_collection
|
||||||
- enable_prompt_collection
|
- enable_prompt_collection
|
||||||
- redact_pii
|
- redact_pii
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
AgentNetworkSettingsRequest:
|
AgentNetworkSettingsRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
|
description: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
|
||||||
properties:
|
properties:
|
||||||
|
cluster:
|
||||||
|
type: string
|
||||||
|
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
|
||||||
|
example: "eu.proxy.netbird.io"
|
||||||
enable_log_collection:
|
enable_log_collection:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
|
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||||
@@ -13690,7 +13694,7 @@ paths:
|
|||||||
/api/agent-network/settings:
|
/api/agent-network/settings:
|
||||||
get:
|
get:
|
||||||
summary: Retrieve Agent Network settings
|
summary: Retrieve Agent Network settings
|
||||||
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create.
|
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint.
|
||||||
tags: [ Agent Network ]
|
tags: [ Agent Network ]
|
||||||
security:
|
security:
|
||||||
- BearerAuth: [ ]
|
- BearerAuth: [ ]
|
||||||
@@ -13706,13 +13710,11 @@ paths:
|
|||||||
"$ref": "#/components/responses/requires_authentication"
|
"$ref": "#/components/responses/requires_authentication"
|
||||||
'403':
|
'403':
|
||||||
"$ref": "#/components/responses/forbidden"
|
"$ref": "#/components/responses/forbidden"
|
||||||
'404':
|
|
||||||
"$ref": "#/components/responses/not_found"
|
|
||||||
'500':
|
'500':
|
||||||
"$ref": "#/components/responses/internal_error"
|
"$ref": "#/components/responses/internal_error"
|
||||||
put:
|
put:
|
||||||
summary: Update Agent Network settings
|
summary: Update Agent Network settings
|
||||||
description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet).
|
description: Updates the account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable.
|
||||||
tags: [ Agent Network ]
|
tags: [ Agent Network ]
|
||||||
security:
|
security:
|
||||||
- BearerAuth: [ ]
|
- BearerAuth: [ ]
|
||||||
@@ -13738,6 +13740,8 @@ paths:
|
|||||||
"$ref": "#/components/responses/forbidden"
|
"$ref": "#/components/responses/forbidden"
|
||||||
'404':
|
'404':
|
||||||
"$ref": "#/components/responses/not_found"
|
"$ref": "#/components/responses/not_found"
|
||||||
|
'422':
|
||||||
|
"$ref": "#/components/responses/validation_failed"
|
||||||
'500':
|
'500':
|
||||||
"$ref": "#/components/responses/internal_error"
|
"$ref": "#/components/responses/internal_error"
|
||||||
/api/agent-network/budget-rules:
|
/api/agent-network/budget-rules:
|
||||||
|
|||||||
@@ -2275,11 +2275,11 @@ type AgentNetworkProvider struct {
|
|||||||
// Id Provider ID
|
// Id Provider ID
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|
||||||
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
|
||||||
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
|
IdentityHeaderGroups string `json:"identity_header_groups"`
|
||||||
|
|
||||||
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
|
||||||
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
IdentityHeaderUserId string `json:"identity_header_user_id"`
|
||||||
|
|
||||||
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
|
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
|
||||||
MetadataDisabled bool `json:"metadata_disabled"`
|
MetadataDisabled bool `json:"metadata_disabled"`
|
||||||
@@ -2335,16 +2335,16 @@ type AgentNetworkProviderRequest struct {
|
|||||||
// Enabled Whether the provider is enabled. Defaults to true on create.
|
// Enabled Whether the provider is enabled. Defaults to true on create.
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
|
||||||
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
|
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
|
||||||
ExtraValues *map[string]string `json:"extra_values,omitempty"`
|
ExtraValues *map[string]string `json:"extra_values,omitempty"`
|
||||||
|
|
||||||
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
|
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
|
||||||
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
|
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
|
||||||
|
|
||||||
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
|
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
|
||||||
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
|
||||||
|
|
||||||
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
|
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
|
||||||
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
|
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
|
||||||
|
|
||||||
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
|
||||||
@@ -2356,22 +2356,22 @@ type AgentNetworkProviderRequest struct {
|
|||||||
// ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom).
|
// ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom).
|
||||||
ProviderId string `json:"provider_id"`
|
ProviderId string `json:"provider_id"`
|
||||||
|
|
||||||
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
|
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
|
||||||
SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"`
|
SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"`
|
||||||
|
|
||||||
// UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to.
|
// UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to.
|
||||||
UpstreamUrl string `json:"upstream_url"`
|
UpstreamUrl string `json:"upstream_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
|
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
|
||||||
type AgentNetworkSettings struct {
|
type AgentNetworkSettings struct {
|
||||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
|
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
|
||||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||||
|
|
||||||
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
|
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
|
||||||
Cluster string `json:"cluster"`
|
Cluster string `json:"cluster"`
|
||||||
|
|
||||||
// CreatedAt Timestamp when the settings row was created.
|
// CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped.
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
|
||||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||||
@@ -2380,24 +2380,27 @@ type AgentNetworkSettings struct {
|
|||||||
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
|
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
|
||||||
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
EnablePromptCollection bool `json:"enable_prompt_collection"`
|
||||||
|
|
||||||
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
|
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
|
|
||||||
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
|
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
|
||||||
RedactPii bool `json:"redact_pii"`
|
RedactPii bool `json:"redact_pii"`
|
||||||
|
|
||||||
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
|
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
|
||||||
Subdomain string `json:"subdomain"`
|
Subdomain string `json:"subdomain"`
|
||||||
|
|
||||||
// UpdatedAt Timestamp when the settings row was last updated.
|
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
|
// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
|
||||||
type AgentNetworkSettingsRequest struct {
|
type AgentNetworkSettingsRequest struct {
|
||||||
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
|
||||||
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
|
||||||
|
|
||||||
|
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
|
||||||
|
Cluster *string `json:"cluster,omitempty"`
|
||||||
|
|
||||||
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
|
||||||
EnableLogCollection bool `json:"enable_log_collection"`
|
EnableLogCollection bool `json:"enable_log_collection"`
|
||||||
|
|
||||||
|
|||||||
@@ -156,9 +156,11 @@ func (g *Guard) notifyReconnected() {
|
|||||||
func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
|
func (g *Guard) exponentTicker(ctx context.Context) *backoff.Ticker {
|
||||||
bo := backoff.WithContext(&backoff.ExponentialBackOff{
|
bo := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||||
InitialInterval: 2 * time.Second,
|
InitialInterval: 2 * time.Second,
|
||||||
Multiplier: 2,
|
// Spreads the reconnects of every client that lost the same relay server.
|
||||||
MaxInterval: g.maxBackoffInterval,
|
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
||||||
Clock: backoff.SystemClock,
|
Multiplier: 2,
|
||||||
|
MaxInterval: g.maxBackoffInterval,
|
||||||
|
Clock: backoff.SystemClock,
|
||||||
}, ctx)
|
}, ctx)
|
||||||
|
|
||||||
return backoff.NewTicker(bo)
|
return backoff.NewTicker(bo)
|
||||||
|
|||||||
Reference in New Issue
Block a user