mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-11 18:21:29 +02:00
Compare commits
37 Commits
notificati
...
android-ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bfc73cd1 | ||
|
|
77e7d82d5a | ||
|
|
e3e5a25605 | ||
|
|
072fa8143b | ||
|
|
8fb3e707af | ||
|
|
902263ac96 | ||
|
|
2ce6323602 | ||
|
|
9a05a1c698 | ||
|
|
5dd914782a | ||
|
|
8c19b7a30a | ||
|
|
1e2a7aa571 | ||
|
|
98f8a24108 | ||
|
|
b2ae98f0fd | ||
|
|
40ac64bb4e | ||
|
|
19a6cedfff | ||
|
|
6526fc2bec | ||
|
|
2afa69b622 | ||
|
|
2a61eac047 | ||
|
|
f2d13b884a | ||
|
|
564595d283 | ||
|
|
78c1c2fc32 | ||
|
|
bc7a15ab71 | ||
|
|
530021aec6 | ||
|
|
1bedb4e59d | ||
|
|
7546e7751c | ||
|
|
075b319fb3 | ||
|
|
b82a42c855 | ||
|
|
e90be36cd5 | ||
|
|
d29bc23bb7 | ||
|
|
ee1389d736 | ||
|
|
6f42636514 | ||
|
|
28197e6504 | ||
|
|
2f721ec0d5 | ||
|
|
f9b412228e | ||
|
|
2bfd9fcffe | ||
|
|
7639655883 | ||
|
|
6044663788 |
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
const allowedTags = [
|
||||
'management',
|
||||
'client',
|
||||
'android',
|
||||
'ios',
|
||||
'signal',
|
||||
'proxy',
|
||||
'relay',
|
||||
|
||||
133
.github/workflows/release.yml
vendored
133
.github/workflows/release.yml
vendored
@@ -475,6 +475,132 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_gtk3:
|
||||
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
|
||||
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
|
||||
# the binary links against the oldest supported glibc.
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
|
||||
- name: Set snapshot flag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Set build vars
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "x-${{ github.repository }}"
|
||||
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
# Restore-only from the release_ui cache written by trusted runs; the
|
||||
# module cache is identical (same go.sum) and stale build-cache
|
||||
# entries just miss.
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-go-releaser-
|
||||
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: Decode GPG signing key
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- name: Install wails3 CLI
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
|
||||
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
|
||||
# not exist on ubuntu-22.04.
|
||||
run: |
|
||||
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
|
||||
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
|
||||
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
|
||||
NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
|
||||
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
|
||||
- name: Verify RPM signatures
|
||||
run: |
|
||||
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
|
||||
dnf install -y -q rpm-sign curl >/dev/null 2>&1
|
||||
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
|
||||
rpm --import /tmp/rpm-pub.key
|
||||
echo "=== Verifying RPM signatures ==="
|
||||
for rpm_file in /dist/*.rpm; do
|
||||
[ -f "$rpm_file" ] || continue
|
||||
echo "--- $(basename $rpm_file) ---"
|
||||
rpm -K "$rpm_file"
|
||||
done
|
||||
'
|
||||
- name: Clean up GPG key
|
||||
if: always()
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release_ui_gtk3
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
with:
|
||||
name: release-ui-gtk3
|
||||
path: dist/
|
||||
retention-days: 3
|
||||
|
||||
release_ui_darwin:
|
||||
runs-on: macos-latest
|
||||
outputs:
|
||||
@@ -688,7 +814,7 @@ jobs:
|
||||
comment_release_artifacts:
|
||||
name: Comment release artifacts
|
||||
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 }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -700,12 +826,14 @@ jobs:
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
|
||||
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
|
||||
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
|
||||
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
|
||||
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
|
||||
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
|
||||
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
|
||||
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
|
||||
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
|
||||
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
|
||||
with:
|
||||
@@ -728,6 +856,7 @@ jobs:
|
||||
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
|
||||
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
|
||||
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
|
||||
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
|
||||
];
|
||||
|
||||
@@ -784,7 +913,7 @@ jobs:
|
||||
|
||||
trigger_signer:
|
||||
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/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
|
||||
@@ -257,6 +257,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify fresh-install session cookie key hardening
|
||||
run: |
|
||||
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
|
||||
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
|
||||
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
|
||||
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
|
||||
@@ -93,7 +93,9 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
@@ -114,7 +116,9 @@ nfpms:
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
dependencies:
|
||||
- netbird
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
|
||||
rpm:
|
||||
signature:
|
||||
|
||||
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
|
||||
|
||||
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
|
||||
- [Contents](#contents)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
- [STOP and ask the user before](#stop-and-ask-the-user-before)
|
||||
- [Quick reference](#quick-reference)
|
||||
- [Structure](#structure)
|
||||
- [Where to look](#where-to-look)
|
||||
- [Security](#security)
|
||||
- [Agent conventions](#agent-conventions)
|
||||
- [Repo-wide principles](#repo-wide-principles)
|
||||
- [Type safety](#type-safety)
|
||||
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
|
||||
- [Error handling](#error-handling)
|
||||
- [Comments](#comments)
|
||||
- [Testing](#testing)
|
||||
- [Pitfalls](#pitfalls)
|
||||
- [Commits, PRs, releases](#commits-prs-releases)
|
||||
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
|
||||
- [Discussion and support](#discussion-and-support)
|
||||
|
||||
## STOP and ask the user before
|
||||
|
||||
@@ -157,11 +159,125 @@ netbird/
|
||||
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
|
||||
| End-to-end tests | `e2e/` |
|
||||
|
||||
## Security
|
||||
|
||||
### Never fail open
|
||||
|
||||
When a security check — access control, an IP restriction, an auth decision —
|
||||
hits an error such as an unparseable value, an unavailable lookup, or a state it
|
||||
does not recognize, it must **deny**. Never skip the check or allow the request
|
||||
through because the check itself failed, and make the `default` and unknown cases
|
||||
of a security-related `switch` deny rather than fall through.
|
||||
|
||||
### Daemon RPC input is untrusted
|
||||
|
||||
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
|
||||
privilege boundary: treat every field as untrusted input rather than as something
|
||||
the UI or CLI validated on the way in.
|
||||
|
||||
When you add or change an RPC, ask what the handler does with caller input while
|
||||
running as root. If the answer touches a filesystem path, a URL or host, or a
|
||||
privileged state change, it needs a gate **in the handler** — a check in the client
|
||||
that normally calls it is not a check at all.
|
||||
|
||||
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
|
||||
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
|
||||
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
|
||||
not own — so a symlink or hardlink aimed at a root-only file is rejected.
|
||||
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
|
||||
allow only known hosts for unprivileged callers. Prefer a lexical host
|
||||
allowlist plus TLS verification over "resolve the host, then reject private
|
||||
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
|
||||
check time, attacker IP at connect time), while a name allowlist has no IP
|
||||
check to race. Never accept `http://` where `https://` is expected.
|
||||
- **A privileged state change** (SSH root login, management URL, deregistration)
|
||||
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
|
||||
|
||||
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
|
||||
named-pipe client token — and never from an RPC field. When
|
||||
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
|
||||
do not fall back to treating the caller as the transport peer.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
### Three networking modes
|
||||
|
||||
Where packets actually flow depends on the mode the agent is running in. The
|
||||
three are not interchangeable, so establish which one a change applies to — and
|
||||
what it should do in the other two — before you write it.
|
||||
|
||||
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
|
||||
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
|
||||
client programs kernel facilities but never sees the traffic itself.
|
||||
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
|
||||
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
|
||||
— exit nodes and network routes — goes through the userspace forwarder, which
|
||||
terminates the connection and re-establishes it over OS sockets. Used on
|
||||
platforms without kernel WireGuard® or when the user opts out.
|
||||
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
|
||||
networking. The forwarder does all routing by stitching userspace sockets, and
|
||||
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
|
||||
Used where the process cannot create a TUN device, such as the embedded client
|
||||
(`client/embed/`) and the WASM build.
|
||||
|
||||
### The overlay interface is not "WireGuard"
|
||||
|
||||
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
|
||||
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
|
||||
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
|
||||
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
|
||||
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
|
||||
reference there is simply inaccurate and rots as the transports change.
|
||||
|
||||
### IPv6 is a soft feature
|
||||
|
||||
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
|
||||
it as soft rather than a requirement:
|
||||
|
||||
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
|
||||
not on raw state fields, and skip the v6 path when the host has no v6 rather
|
||||
than returning an error.
|
||||
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
|
||||
it, keeping the v4 path working.
|
||||
- Never let a missing v6 break v4. Fail-closed is for security checks; a
|
||||
capability mismatch skips the v6 work and carries on.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
|
||||
than comparing strings inline, so an unexpected value is logged instead of
|
||||
silently meaning false:
|
||||
|
||||
```go
|
||||
const EnvDisableFeature = "NB_DISABLE_FEATURE"
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
val := os.Getenv(EnvDisableFeature)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
```
|
||||
|
||||
### Validating against protocol specs
|
||||
|
||||
When a change depends on what a protocol actually mandates, read the specification
|
||||
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
|
||||
summary, and check that you have the current RFC — the widely cited one for a
|
||||
protocol is often superseded. Cite the section, not just the document, so a
|
||||
reviewer can jump straight to the rule.
|
||||
|
||||
## Repo-wide principles
|
||||
|
||||
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
|
||||
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
|
||||
touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
|
||||
you touch, and delete imports, helpers, and parameters your refactor orphaned.
|
||||
Exception: unused parameters in shared code may be consumed by builds outside
|
||||
this repository — do not remove them, ask instead.
|
||||
3. **Function comments are mandatory for exported functions**, written as full
|
||||
@@ -175,9 +291,12 @@ netbird/
|
||||
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
|
||||
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
|
||||
comments, commit messages, and PR descriptions alike.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that adds
|
||||
shared state. Guard maps and slices with a mutex, keep critical sections
|
||||
short, and run `go test -race` on the touched packages.
|
||||
8. **Concurrency: do a two-pass race analysis after every change** that touches
|
||||
shared state, including reads of existing maps and slices. Guard them with a
|
||||
mutex (or an atomic or channel where that fits better), keep critical
|
||||
sections short, and run `go test -race` on the touched packages. See
|
||||
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
|
||||
to check for.
|
||||
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
|
||||
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
|
||||
add the counterpart or a build-tagged fallback for the others.
|
||||
@@ -185,6 +304,93 @@ netbird/
|
||||
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
|
||||
keep peer IPs and hostnames out of logs above debug level.
|
||||
|
||||
## Type safety
|
||||
|
||||
**No bare primitives for domain concepts.** A `string` parameter for an account
|
||||
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
|
||||
because the compiler cannot catch the swap. Declare the type once and use it
|
||||
throughout, converting only at the boundaries where data enters or leaves —
|
||||
protobuf, gRPC, HTTP, an external library.
|
||||
|
||||
```go
|
||||
type ServiceID string
|
||||
type AccountID string
|
||||
|
||||
// Internal: typed all the way through
|
||||
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
|
||||
|
||||
// Proto boundary: convert once, on the way in and on the way out
|
||||
svcID := ServiceID(mapping.GetId())
|
||||
req.ServiceId = string(svcID)
|
||||
```
|
||||
|
||||
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
|
||||
boundary and pass the typed value inward.
|
||||
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
|
||||
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
|
||||
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
|
||||
compared mapped address silently fails to match those rules.
|
||||
- **Ports are `uint16`** internally; use `int` only where a library forces it and
|
||||
convert immediately.
|
||||
- **Enums are a typed string with constants**, so the valid set is discoverable
|
||||
and a typo fails to compile.
|
||||
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
|
||||
string`) rather than an alias (`type serviceID = string`) — an alias silently
|
||||
accepts bare strings.
|
||||
|
||||
## Concurrency and lifecycle
|
||||
|
||||
Beyond the mutex hygiene in the principles above, check for these failure
|
||||
modes.
|
||||
|
||||
- **Never read a struct field inside a goroutine** when another goroutine may nil
|
||||
or reassign it. Pass the value as a parameter, or capture it into a local before
|
||||
launching. This matters most when `Stop()` nils a field without waiting for the
|
||||
goroutine to finish.
|
||||
|
||||
```go
|
||||
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
|
||||
m.Start(ctx, ifaceName)
|
||||
}(iface.Name())
|
||||
```
|
||||
|
||||
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
|
||||
need out from under the lock, release it, then wait.
|
||||
|
||||
```go
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel, done := m.cancel, m.done
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
|
||||
nil cancel — and must release the state they guarded. Clear maps and caches;
|
||||
a cancelled goroutine holding a live map still pins that memory. Note that a
|
||||
nil map only panics on writes; reads and iteration behave like an empty map,
|
||||
so where post-close use must be rejected, check the stopped flag explicitly.
|
||||
- **Publish coupled state only after every fallible step succeeds.** When several
|
||||
fields form an invariant, build them into locals and assign them to the receiver
|
||||
at the end. Assigning as you go leaves the object half-initialized when a later
|
||||
step fails, so a readiness predicate reports ready while a coupled field is nil.
|
||||
If an earlier step already had an external side effect — a created chain, an
|
||||
opened handle, an inserted rule — roll it back before returning the error.
|
||||
- **Clean up what you own on constructor error paths.** Once a constructor has
|
||||
started something, every later error path must undo it: cancel a goroutine and
|
||||
wait for it to exit, stop a ticker, close a watcher. The object is never
|
||||
returned, so its `Close` will never run.
|
||||
- **A failed `Start` must undo everything it started.** When a component brings up
|
||||
several subsystems in sequence — connection manager, watchers, routing, DNS,
|
||||
flow, persisted state — a failure partway through has to tear down the ones
|
||||
already running, not just close the handle the error came from. Put the
|
||||
already-started guard *before* that teardown path, so a rejected second `Start`
|
||||
cannot dismantle the one that is running.
|
||||
|
||||
## Error handling
|
||||
|
||||
Use single-assignment form when the error is only needed inside the `if`:
|
||||
@@ -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
|
||||
writes.
|
||||
|
||||
**Do not log and return the same error.** It gets reported twice, from two places,
|
||||
and the second reader cannot tell whether it happened once or twice. Return it and
|
||||
let the caller decide. The exception is an API handler that has already written a
|
||||
response. Internal helpers return errors rather than logging and swallowing them.
|
||||
|
||||
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
|
||||
interface is not nil, so `err != nil` is true and callers take the failure path on
|
||||
success. Return the error only where it is actually set:
|
||||
|
||||
```go
|
||||
if _, err := conn.Write(buf); err != nil { // good
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
**Accumulate with `multierror` when an operation should continue past individual
|
||||
failures** — teardown, cleanup, or setup where partial success is acceptable.
|
||||
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
|
||||
still see a plain nil on full success:
|
||||
|
||||
```go
|
||||
func (m *Manager) Cleanup() error {
|
||||
var merr *multierror.Error
|
||||
for _, r := range m.resources {
|
||||
if err := r.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
```
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| --------------------- | --------------------- | ----------------------------------------- |
|
||||
| Cleanup / teardown | Accumulate | Clean up as much as possible |
|
||||
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
|
||||
| Setup with partial OK | Accumulate | Degraded operation is still useful |
|
||||
|
||||
## Comments
|
||||
|
||||
Comment the **why**, never the **what**. Default to no comment, and add one only
|
||||
@@ -269,10 +514,14 @@ checksum = updateChecksum(checksum, oldPort, newPort)
|
||||
|
||||
### Length budget
|
||||
|
||||
- **90 characters per line.** Wrap the comment, do not run past it.
|
||||
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
|
||||
exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body may not.
|
||||
Neither of these is linter-enforced, so they are conventions the surrounding code
|
||||
mostly follows rather than hard limits:
|
||||
|
||||
- **Around 90 characters per line.** Wrap the comment rather than running well past
|
||||
it.
|
||||
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
|
||||
on exported identifiers may exceed it when the API genuinely needs the
|
||||
explanation; inline comments inside a function body rarely should.
|
||||
|
||||
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
|
||||
@@ -329,6 +578,19 @@ up, and the 250-character budget does not apply to them.
|
||||
otherwise.
|
||||
- **Message guidance:** optional for `NoError`/`Error`; always give context for
|
||||
comparison, boolean, and collection assertions.
|
||||
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
|
||||
reason you expect* — a test that fails for an unrelated reason proves nothing —
|
||||
then apply the fix and confirm it passes. Add the thin surrounding cases while
|
||||
you are there.
|
||||
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
|
||||
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
|
||||
then `os.Unsetenv`.
|
||||
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
|
||||
parent function returns, running its `defer`s, while parallel subtests are
|
||||
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
|
||||
there, but `t.Cleanup` works in both cases.
|
||||
- **Explanatory comments in tests are welcome.** Describe the scenario being set
|
||||
up; the comment budget below does not apply to them.
|
||||
|
||||
```go
|
||||
server, err := StartTestServer()
|
||||
@@ -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,
|
||||
tick the checklist honestly (including "ran locally" and "single purpose"),
|
||||
and complete the documentation section. Do not tick a box you have not
|
||||
verified, and do not delete rows that do not apply.
|
||||
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
|
||||
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
|
||||
and hoping.
|
||||
|
||||
Prefer GitHub's stacked pull requests for such a sequence, rather than
|
||||
hand-managing base branches: open each PR against the branch below it instead of
|
||||
`main`, so every PR's diff shows only its own change. Merging a layer retargets
|
||||
the PRs above it, and branch protections and required checks on the base branch
|
||||
still apply to each one.
|
||||
|
||||
- **User-facing changes need a docs PR** in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
|
||||
description.
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -32,11 +34,6 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// TunAdapter export internal TunAdapter for mobile
|
||||
type TunAdapter interface {
|
||||
device.TunAdapter
|
||||
@@ -57,6 +54,12 @@ type DnsReadyListener interface {
|
||||
dns.ReadyListener
|
||||
}
|
||||
|
||||
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
||||
type TunSettings struct {
|
||||
Routes string
|
||||
SearchDomains string
|
||||
}
|
||||
|
||||
func init() {
|
||||
formatter.SetLogcatFormatter(log.StandardLogger())
|
||||
}
|
||||
@@ -71,11 +74,20 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
|
||||
// ConnectClient, which distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
|
||||
stateMu sync.RWMutex
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
// Identifies the running profile for the SSO login hint; see profile_state.go.
|
||||
cfgPath string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
@@ -96,11 +108,12 @@ type Client struct {
|
||||
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()
|
||||
defer c.stateMu.Unlock()
|
||||
c.config = cfg
|
||||
c.cacheDir = cacheDir
|
||||
c.cfgPath = cfgPath
|
||||
c.connectClient = cc
|
||||
}
|
||||
|
||||
@@ -110,6 +123,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
|
||||
return c.config, c.cacheDir, c.connectClient
|
||||
}
|
||||
|
||||
// authSnapshot returns the config together with the path it was loaded from, in
|
||||
// one lock: the path identifies the profile whose account email backs the login
|
||||
// hint, so reading it separately could pair one profile's config with another's
|
||||
// hint when a profile switch lands in between.
|
||||
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
return c.config, c.cfgPath, c.connectClient
|
||||
}
|
||||
|
||||
func (c *Client) getConnectClient() *internal.ConnectClient {
|
||||
c.stateMu.RLock()
|
||||
defer c.stateMu.RUnlock()
|
||||
@@ -129,9 +152,28 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
recorder: peer.NewRecorder(""),
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||
// the connection listener reports NoNetwork instead of Connecting; when
|
||||
// availability returns, the loops resume immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange cuts the management, signal and relay connections
|
||||
// after the OS switched networks, so the reconnect loops redial immediately
|
||||
// on the new one. The engine and the TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
n := c.sweeper.Sweep()
|
||||
log.Infof("network change: swept %d connections", n)
|
||||
}
|
||||
|
||||
// Run start the internal client. It is a blocker function
|
||||
func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroidTV bool, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
||||
exportEnvList(envList)
|
||||
@@ -162,15 +204,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
defer c.ctxCancel()
|
||||
c.ctxCancelLock.Unlock()
|
||||
|
||||
auth := NewAuthWithConfig(ctx, cfg)
|
||||
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
|
||||
err = auth.login(urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
@@ -210,8 +253,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -240,6 +284,24 @@ func (c *Client) RenewTun(fd int) error {
|
||||
return e.RenewTun(fd)
|
||||
}
|
||||
|
||||
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return nil, fmt.Errorf("engine not running")
|
||||
}
|
||||
|
||||
e := cc.Engine()
|
||||
if e == nil {
|
||||
return nil, fmt.Errorf("engine not initialized")
|
||||
}
|
||||
|
||||
routes, searchDomains := e.TunSettings()
|
||||
return &TunSettings{
|
||||
Routes: strings.Join(routes, ";"),
|
||||
SearchDomains: strings.Join(searchDomains, ";"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
// It works both with and without a running engine.
|
||||
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
||||
@@ -476,7 +538,7 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
41
client/android/connection_listener.go
Normal file
41
client/android/connection_listener.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||
// re-exported as basic constants so gomobile emits them into the generated
|
||||
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||
// ClientState* constants), because gomobile cannot bind named types.
|
||||
type ConnectionListener interface {
|
||||
OnStateChanged(state int)
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener, converting the typed state to the int the binding carries.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||
a.ConnectionListener.OnStateChanged(int(state))
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -36,12 +38,20 @@ type Auth struct {
|
||||
}
|
||||
|
||||
// NewAuth instantiate Auth struct and validate the management URL
|
||||
//
|
||||
// The configuration at cfgPath is reused when one is already there, and only created when it is
|
||||
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
|
||||
// every call: the peer registers under that key, the key is written out, and any peer registered by
|
||||
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
|
||||
// the persisted config, because the identity it registered is not the one it runs with — the
|
||||
// management stream rejects it with "no peer auth method provided".
|
||||
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
inputCfg := profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: mgmURL,
|
||||
}
|
||||
|
||||
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
|
||||
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAuthWithConfig instantiate Auth based on existing config
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
||||
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
|
||||
// file the config was loaded from; it identifies the profile whose account email
|
||||
// backs the login_hint.
|
||||
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
|
||||
return &Auth{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
cfgPath: cfgPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
}
|
||||
|
||||
jwtToken := ""
|
||||
email := ""
|
||||
if needsLogin {
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
jwtToken = tokenInfo.GetTokenToUse()
|
||||
email = tokenInfo.Email
|
||||
}
|
||||
|
||||
err, _ = authClient.Login(a.ctx, "", jwtToken)
|
||||
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
||||
return fmt.Errorf("login failed: %v", err)
|
||||
}
|
||||
|
||||
// Stored after Login, not before: a rejected token must not leave a hint
|
||||
// pointing at an account that cannot be used.
|
||||
if email != "" && a.cfgPath != "" {
|
||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
||||
log.Warnf("failed to store profile account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
||||
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
||||
// way internal/auth wires it in authenticateWithPKCEFlow.
|
||||
type loginHintSetter interface {
|
||||
SetLoginHint(hint string)
|
||||
}
|
||||
|
||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||
// leaves the choice to the IdP, which is how accounts get switched.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
setter.SetLoginHint(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
||||
|
||||
51
client/android/login_test.go
Normal file
51
client/android/login_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
|
||||
//
|
||||
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
|
||||
// time. The peer registers under that key and the key is written out, so a peer registered by an
|
||||
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
|
||||
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
|
||||
// the identity that registered is not the identity that runs, and the management stream rejects it
|
||||
// with "no peer auth method provided, please use a setup key or interactive SSO login".
|
||||
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
first, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("first NewAuth: %v", err)
|
||||
}
|
||||
if first.config.PrivateKey == "" {
|
||||
t.Fatal("first NewAuth produced no private key")
|
||||
}
|
||||
|
||||
second, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("second NewAuth: %v", err)
|
||||
}
|
||||
|
||||
if second.config.PrivateKey != first.config.PrivateKey {
|
||||
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
|
||||
}
|
||||
}
|
||||
|
||||
// A missing configuration is still created, so a first enrolment works unchanged.
|
||||
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuth: %v", err)
|
||||
}
|
||||
if auth.config == nil || auth.config.PrivateKey == "" {
|
||||
t.Fatal("NewAuth did not create a usable configuration")
|
||||
}
|
||||
if auth.cfgPath != cfgPath {
|
||||
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login or was logged out. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, 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
|
||||
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
cfg, cfgPath, cc := c.authSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// its wg interface into firewalld's "trusted" zone. This is required because
|
||||
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
|
||||
// versions, which returns EPERM to any other process that tries to insert
|
||||
// rules into them. The workaround mirrors what Tailscale does: let firewalld
|
||||
// itself add the accept rules to its own chains by trusting the interface.
|
||||
// rules into them. Trusting the interface makes firewalld itself add the
|
||||
// accept rules to its own chains instead.
|
||||
package firewalld
|
||||
|
||||
// TrustedZone is the firewalld zone name used for interfaces whose traffic
|
||||
|
||||
@@ -16,28 +16,47 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
)
|
||||
|
||||
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||
return grpc.WithContextDialer(dialContext)
|
||||
}
|
||||
|
||||
// WithSweeper dials like WithCustomDialer but registers connections and
|
||||
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
|
||||
// dial options in order, so the later context dialer wins.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
dial := sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
conn, err := dialContext(dial.Ctx(), addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
return dial.WrapConn(conn)
|
||||
})
|
||||
}
|
||||
|
||||
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
if runtime.GOOS == "linux" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||
}
|
||||
|
||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||
if currentUser.Uid != "0" {
|
||||
log.Debug("Not running as root, using standard dialer")
|
||||
dialer := &net.Dialer{}
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package grpc
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/util/wsproxy/client"
|
||||
)
|
||||
|
||||
@@ -11,3 +12,8 @@ import (
|
||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
return client.WithWebSocketDialer(tlsEnabled, component)
|
||||
}
|
||||
|
||||
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
|
||||
return grpc.EmptyDialOption{}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import (
|
||||
// emits.
|
||||
|
||||
// Metadata keys attached by the daemon to session-warning SystemEvents.
|
||||
// The notification text itself travels as a message key (see
|
||||
// proto.UserMsgSessionExpiresIn); these keys carry the structured deadline
|
||||
// the UI needs for its own countdown label, and disambiguate the
|
||||
// T-WarningLead notification from the T-FinalWarningLead fallback that
|
||||
// auto-opens the SessionAboutToExpire dialog.
|
||||
// The UI tray reads these to build a locale-aware notification without
|
||||
// relying on the daemon's locale-less UserMessage string, and to
|
||||
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
|
||||
// fallback that auto-opens the SessionAboutToExpire dialog.
|
||||
const (
|
||||
// MetaSessionWarning is set to "true" on both warning events (T-10 and
|
||||
// T-2) so the UI can detect a session-warning SystemEvent without
|
||||
@@ -37,9 +36,10 @@ const (
|
||||
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
|
||||
// SystemEvent the daemon emits when it discards a deadline from the
|
||||
// management server (pre-epoch, too far in the future, or past the
|
||||
// clock-skew tolerance). The value is the rejection reason string,
|
||||
// which is diagnostic only: the user-facing text travels as
|
||||
// proto.UserMsgSessionDeadlineReject.
|
||||
// clock-skew tolerance). The value is the rejection reason string.
|
||||
// userMessage is left empty; the UI detects the event via this key
|
||||
// and builds a localized notification — same pattern as the session
|
||||
// warnings above.
|
||||
MetaSessionDeadlineRejected = "session_deadline_rejected"
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -81,7 +80,7 @@ type StatusRecorder interface {
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
userMessage *cProto.UserMessage,
|
||||
userMessage string,
|
||||
metadata map[string]string,
|
||||
)
|
||||
}
|
||||
@@ -377,22 +376,7 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
message,
|
||||
warningUserMessage(deadline),
|
||||
"",
|
||||
meta,
|
||||
)
|
||||
}
|
||||
|
||||
// warningUserMessage builds the localizable body for a session warning. The
|
||||
// remaining time is rendered here rather than in the UI so every consumer of the
|
||||
// event agrees on it; a deadline that is already gone (a warning delivered late)
|
||||
// drops to the variant without a countdown.
|
||||
func warningUserMessage(deadline time.Time) *cProto.UserMessage {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresSoon).
|
||||
WithTitle(cProto.TitleSessionWarning)
|
||||
}
|
||||
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresIn,
|
||||
cProto.ArgRemaining, nbstatus.HumaniseDuration(remaining)).
|
||||
WithTitle(cProto.TitleSessionWarning)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ type event struct {
|
||||
severity cProto.SystemEvent_Severity
|
||||
category cProto.SystemEvent_Category
|
||||
message string
|
||||
msgKey cProto.UserMessageKey
|
||||
titleKey cProto.UserMessageKey
|
||||
msgArgs map[string]string
|
||||
meta map[string]string
|
||||
}
|
||||
|
||||
@@ -65,7 +62,7 @@ func (r *fakeRecorder) PublishEvent(
|
||||
severity cProto.SystemEvent_Severity,
|
||||
category cProto.SystemEvent_Category,
|
||||
message string,
|
||||
userMessage *cProto.UserMessage,
|
||||
_ string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
r.mu.Lock()
|
||||
@@ -75,9 +72,6 @@ func (r *fakeRecorder) PublishEvent(
|
||||
severity: severity,
|
||||
category: category,
|
||||
message: message,
|
||||
msgKey: userMessage.Key(),
|
||||
titleKey: userMessage.TitleKey(),
|
||||
msgArgs: userMessage.Args(),
|
||||
meta: metadata,
|
||||
})
|
||||
}
|
||||
@@ -192,33 +186,6 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The UI localizes the warning body from the key rather than from the daemon's
|
||||
// English text, so a warning that ships no key would silently regress to English.
|
||||
func TestWarningCarriesLocalizableMessage(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
_ = w.Update(time.Now().Add(80 * time.Millisecond))
|
||||
|
||||
events := waitForEvents(t, r, 2)
|
||||
warning := events[1]
|
||||
if !warning.isWarning() {
|
||||
t.Fatalf("event[1] should be a warning publish, got %+v", warning)
|
||||
}
|
||||
if warning.msgKey != cProto.UserMsgSessionExpiresIn {
|
||||
t.Errorf("warning message key = %q, want %q", warning.msgKey, cProto.UserMsgSessionExpiresIn)
|
||||
}
|
||||
if warning.titleKey != cProto.TitleSessionWarning {
|
||||
t.Errorf("warning title key = %q, want %q", warning.titleKey, cProto.TitleSessionWarning)
|
||||
}
|
||||
// The remaining time is rendered at publish time so every consumer of the
|
||||
// event agrees on it; the exact value depends on timer slack.
|
||||
if remaining := warning.msgArgs[cProto.ArgRemaining]; remaining == "" {
|
||||
t.Errorf("warning is missing the %q argument, args=%v", cProto.ArgRemaining, warning.msgArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r) // lead > delta => fire immediately
|
||||
|
||||
@@ -38,6 +38,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
@@ -70,18 +72,42 @@ type ConnectClient struct {
|
||||
updateManager *updater.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
|
||||
// netState gates every reconnection loop on OS-reported network
|
||||
// availability. Nil (the default) disables gating; mobile platforms
|
||||
// inject it via WithNetworkState.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the management, signal and relay connections on network
|
||||
// change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
}
|
||||
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
type ConnectClientOption func(*ConnectClient)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates every
|
||||
// reconnection loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.sweeper = sweeper }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
ctx context.Context,
|
||||
config *profilemanager.Config,
|
||||
statusRecorder *peer.Status,
|
||||
opts ...ConnectClientOption,
|
||||
) *ConnectClient {
|
||||
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||
// loop. runCancel is set once at construction, so Stop can call it without
|
||||
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||
runCtx, runCancel := context.WithCancel(ctx)
|
||||
return &ConnectClient{
|
||||
c := &ConnectClient{
|
||||
ctx: runCtx,
|
||||
runCancel: runCancel,
|
||||
runExited: make(chan struct{}),
|
||||
@@ -89,6 +115,10 @@ func NewConnectClient(
|
||||
statusRecorder: statusRecorder,
|
||||
engineMutex: sync.Mutex{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||
@@ -113,11 +143,14 @@ func (c *ConnectClient) RunOnAndroid(
|
||||
stateFilePath string,
|
||||
cacheDir string,
|
||||
) error {
|
||||
notifier := tunnelnotifier.New(networkChangeListener, nil)
|
||||
defer notifier.Close()
|
||||
|
||||
// in case of non Android os these variables will be nil
|
||||
mobileDependency := MobileDependency{
|
||||
TunAdapter: tunAdapter,
|
||||
IFaceDiscover: iFaceDiscover,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
NetworkChangeListener: notifier,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
DnsReadyListener: dnsReadyListener,
|
||||
StateFilePath: stateFilePath,
|
||||
@@ -163,7 +196,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
rec.PublishEvent(
|
||||
cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM,
|
||||
"panic occurred",
|
||||
cProto.NewUserMessage(cProto.UserMsgPanic),
|
||||
"The Netbird service panicked. Please restart the service and submit a bug report with the client logs.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -271,6 +304,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return nil
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netState.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
state.Set(StatusConnecting)
|
||||
|
||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
||||
@@ -282,7 +322,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
@@ -357,7 +398,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}()
|
||||
|
||||
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return wrapErr(err)
|
||||
@@ -393,7 +434,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
engineConfig.StateDir = filepath.Dir(path)
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
if len(relayURLs) > 0 {
|
||||
if token != nil {
|
||||
@@ -421,6 +463,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
NetState: c.netState,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
c.engine = engine
|
||||
@@ -477,6 +520,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
// status stream stuck at Connecting.
|
||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||
if err != nil {
|
||||
// Once the client context is cancelled backoff.WithContext surfaces the
|
||||
// bare context error, and any attempt torn down mid-flight reports the
|
||||
// same. That cancellation is the caller asking us to stop (Stop, Down or
|
||||
// an engine restart), so exit cleanly instead of handing back a failure
|
||||
// the caller would have to distinguish from a real one.
|
||||
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
|
||||
log.Info("exiting client retry loop, context cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||
state.Set(StatusNeedsLogin)
|
||||
@@ -670,7 +723,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
}
|
||||
|
||||
// connectToSignal creates Signal Service client and established a connection
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
|
||||
var sigTLSEnabled bool
|
||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||
sigTLSEnabled = true
|
||||
@@ -678,7 +731,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
sigTLSEnabled = false
|
||||
}
|
||||
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
|
||||
if err != nil {
|
||||
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
||||
|
||||
@@ -51,7 +51,5 @@ func (n *notifier) notify() {
|
||||
return
|
||||
}
|
||||
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged("")
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
|
||||
ds.hostsDNSHolder.set(hostsDnsList)
|
||||
ds.permanent = true
|
||||
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
|
||||
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
|
||||
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
|
||||
ds.searchDomainNotifier.setListener(listener)
|
||||
setServerDns(ds)
|
||||
return ds
|
||||
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
|
||||
}
|
||||
|
||||
func (s *DefaultServer) SearchDomains() []string {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
return s.searchDomains()
|
||||
}
|
||||
|
||||
func (s *DefaultServer) searchDomains() []string {
|
||||
var searchDomains []string
|
||||
|
||||
for _, dConf := range s.currentConfig.Domains {
|
||||
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
|
||||
}()
|
||||
|
||||
if s.searchDomainNotifier != nil {
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
|
||||
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
|
||||
}
|
||||
|
||||
s.updateNSGroupStates(update.NameServerGroups)
|
||||
@@ -1134,7 +1140,7 @@ func (s *DefaultServer) projectHealthy(p *nsGroupProj, servers []netip.AddrPort)
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_DNS,
|
||||
"Nameserver group recovered",
|
||||
proto.NewUserMessage(proto.UserMsgDNSRecovered),
|
||||
"DNS servers are reachable again.",
|
||||
map[string]string{"upstreams": joinAddrPorts(servers)},
|
||||
)
|
||||
p.warningActive = false
|
||||
@@ -1157,7 +1163,7 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
|
||||
proto.SystemEvent_WARNING,
|
||||
proto.SystemEvent_DNS,
|
||||
"Nameserver group unreachable",
|
||||
proto.NewUserMessage(proto.UserMsgDNSUnreachable),
|
||||
"Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
|
||||
map[string]string{"upstreams": joinAddrPorts(servers)},
|
||||
)
|
||||
p.warningActive = true
|
||||
|
||||
@@ -58,6 +58,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -180,6 +181,9 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
// NetState gates the reconnection loops on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetState *netstate.State
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -203,6 +207,10 @@ type Engine struct {
|
||||
config *EngineConfig
|
||||
mobileDep MobileDependency
|
||||
|
||||
// netState gates the peer reconnection guards on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
netState *netstate.State
|
||||
|
||||
// STUNs is a list of STUN servers used by ICE
|
||||
STUNs []*stun.URI
|
||||
// TURNs is a list of STUN servers used by ICE
|
||||
@@ -336,6 +344,7 @@ func NewEngine(
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
netState: services.NetState,
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
@@ -572,12 +581,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
}
|
||||
e.stateManager.Start()
|
||||
|
||||
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial settings: %w", err)
|
||||
}
|
||||
|
||||
dnsServer, err := e.newDnsServer(dnsConfig)
|
||||
dnsServer, err := e.newDnsServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dns server: %w", err)
|
||||
}
|
||||
@@ -595,10 +599,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
WGInterface: e.wgInterface,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
RelayManager: e.relayManager,
|
||||
InitialRoutes: initialRoutes,
|
||||
StateManager: e.stateManager,
|
||||
DNSServer: dnsServer,
|
||||
DNSFeatureFlag: dnsFeatureFlag,
|
||||
PeerStore: e.peerStore,
|
||||
DisableClientRoutes: e.config.DisableClientRoutes,
|
||||
DisableServerRoutes: e.config.DisableServerRoutes,
|
||||
@@ -1074,7 +1076,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return err
|
||||
}
|
||||
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", nil, nil)
|
||||
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1898,7 +1900,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
Addr: e.getRosenpassAddr(),
|
||||
PermissiveMode: e.config.RosenpassPermissive,
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetworkState: e.netState,
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
@@ -2102,42 +2105,6 @@ func (e *Engine) close() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
|
||||
if runtime.GOOS != "android" {
|
||||
// nolint:nilnil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
info := system.GetInfo(e.ctx)
|
||||
info.SetFlags(
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
e.config.DisableFirewall,
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
)
|
||||
|
||||
netMap, err := e.mgmClient.GetNetworkMap(info)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
routes := toRoutes(netMap.GetRoutes())
|
||||
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
|
||||
dnsFeatureFlag := toDNSFeatureFlag(netMap)
|
||||
return routes, &dnsCfg, dnsFeatureFlag, nil
|
||||
}
|
||||
|
||||
func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
transportNet, err := e.newStdNet()
|
||||
if err != nil {
|
||||
@@ -2172,7 +2139,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
|
||||
func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
switch runtime.GOOS {
|
||||
case "android":
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
|
||||
case "ios":
|
||||
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
|
||||
if e.config.WgAddr.HasIPv6() {
|
||||
@@ -2185,7 +2152,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
func (e *Engine) newDnsServer() (dns.Server, error) {
|
||||
// due to tests where we are using a mocked version of the DNS server
|
||||
if e.dnsServer != nil {
|
||||
return e.dnsServer, nil
|
||||
@@ -2197,7 +2164,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
e.ctx,
|
||||
e.wgInterface,
|
||||
e.mobileDep.HostDNSAddresses,
|
||||
*dnsConfig,
|
||||
nbdns.Config{},
|
||||
e.mobileDep.NetworkChangeListener,
|
||||
e.statusRecorder,
|
||||
e.config.DisableDNS,
|
||||
|
||||
@@ -57,8 +57,7 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
|
||||
cProto.SystemEvent_ERROR,
|
||||
cProto.SystemEvent_AUTHENTICATION,
|
||||
"session deadline rejected",
|
||||
cProto.NewUserMessage(cProto.UserMsgSessionDeadlineReject).
|
||||
WithTitle(cProto.TitleSessionDeadlineReject),
|
||||
"",
|
||||
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
|
||||
)
|
||||
}
|
||||
|
||||
20
client/internal/engine_tunsettings.go
Normal file
20
client/internal/engine_tunsettings.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
func (e *Engine) TunSettings() ([]string, []string) {
|
||||
e.syncMsgMux.Lock()
|
||||
routeManager := e.routeManager
|
||||
dnsServer := e.dnsServer
|
||||
e.syncMsgMux.Unlock()
|
||||
|
||||
var routes []string
|
||||
if routeManager != nil {
|
||||
routes = routeManager.CurrentRouteRange()
|
||||
}
|
||||
|
||||
var searchDomains []string
|
||||
if dnsServer != nil {
|
||||
searchDomains = dnsServer.SearchDomains()
|
||||
}
|
||||
|
||||
return routes, searchDomains
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
@@ -93,6 +94,10 @@ type ConnConfig struct {
|
||||
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
|
||||
// NetworkState gates the reconnection guard on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetworkState *netstate.State
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
@@ -254,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
|
||||
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
|
||||
}
|
||||
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher)
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
|
||||
|
||||
conn.wg.Add(1)
|
||||
go func() {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// ConnStatus represents the connection state as seen by the guard.
|
||||
@@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus
|
||||
// - Relayed connection disconnected
|
||||
// - ICE candidate changes
|
||||
type Guard struct {
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
log *log.Entry
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
relayedConnDisconnected chan struct{}
|
||||
iCEConnDisconnected chan struct{}
|
||||
}
|
||||
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard {
|
||||
// NewGuard creates a reconnection guard for a peer connection. A nil netState
|
||||
// disables network availability gating.
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
|
||||
return &Guard{
|
||||
log: log,
|
||||
isConnectedOnAllWay: isConnectedFn,
|
||||
timeout: timeout,
|
||||
srWatcher: srWatcher,
|
||||
netState: netState,
|
||||
relayedConnDisconnected: make(chan struct{}, 1),
|
||||
iCEConnDisconnected: make(chan struct{}, 1),
|
||||
}
|
||||
@@ -99,6 +107,12 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
for {
|
||||
select {
|
||||
case <-tickerChannel:
|
||||
// skip attempts while the OS reports no usable network; the ticker
|
||||
// keeps running so other events remain responsive, and the guard
|
||||
// resumes via the signal/relay reconnect events once network returns
|
||||
if !g.netState.IsOnline() {
|
||||
continue
|
||||
}
|
||||
switch g.isConnectedOnAllWay() {
|
||||
case ConnStatusConnected:
|
||||
// all good, nothing to do
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func newTestGuard(status connStatusFunc) *Guard {
|
||||
srw := NewSRWatcher(nil, nil, nil, ice.Config{})
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw)
|
||||
return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil)
|
||||
}
|
||||
|
||||
// countBackoffTickerGoroutines returns how many goroutines are currently sitting
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
package peer
|
||||
|
||||
// ClientState identifies the client connection state delivered via
|
||||
// Listener.OnStateChanged.
|
||||
type ClientState int
|
||||
|
||||
// Client states. The numeric values cross the gomobile boundary (the mobile
|
||||
// bindings re-export them as integer constants), so they are a wire format:
|
||||
// append new states at the end, never reorder or insert.
|
||||
const (
|
||||
ClientStateDisconnected ClientState = iota
|
||||
ClientStateConnected
|
||||
ClientStateConnecting
|
||||
ClientStateDisconnecting
|
||||
// ClientStateNoNetwork is an overlay state: it is never stored as the
|
||||
// last notification, only derived from ClientStateConnecting while the
|
||||
// OS reports no usable network (see notifier.effectiveState).
|
||||
ClientStateNoNetwork
|
||||
)
|
||||
|
||||
// Listener is a callback type about the NetBird network connection state
|
||||
type Listener interface {
|
||||
// OnStateChanged reports every client state transition. New states are
|
||||
// delivered only through this callback; the per-state callbacks below
|
||||
// are kept for compatibility and will be removed once all consumers
|
||||
// have migrated.
|
||||
OnStateChanged(state ClientState)
|
||||
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnected()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnConnecting()
|
||||
// Deprecated: consume OnStateChanged instead.
|
||||
OnDisconnecting()
|
||||
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
@@ -4,31 +4,57 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
stateDisconnected = iota
|
||||
stateConnected
|
||||
stateConnecting
|
||||
stateDisconnecting
|
||||
)
|
||||
|
||||
type notifier struct {
|
||||
serverStateLock sync.Mutex
|
||||
listenersLock sync.Mutex
|
||||
listener Listener
|
||||
currentClientState bool
|
||||
lastNotification int
|
||||
lastNotification ClientState
|
||||
lastNumberOfPeers int
|
||||
lastFqdnAddress string
|
||||
lastIPAddress string
|
||||
networkAvailable bool
|
||||
}
|
||||
|
||||
func newNotifier() *notifier {
|
||||
return ¬ifier{}
|
||||
return ¬ifier{
|
||||
networkAvailable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveState maps the computed state to what listeners should see:
|
||||
// while the OS reports no usable network, "Connecting" would be a lie —
|
||||
// connection attempts are suspended — so it is reported as NoNetwork.
|
||||
// Caller must hold serverStateLock.
|
||||
func (n *notifier) effectiveState(state ClientState) ClientState {
|
||||
if !n.networkAvailable && state == ClientStateConnecting {
|
||||
return ClientStateNoNetwork
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// setNetworkAvailable records the OS network availability and re-notifies
|
||||
// the listener when the flag flips the effective state (Connecting <->
|
||||
// NoNetwork).
|
||||
func (n *notifier) setNetworkAvailable(available bool) {
|
||||
n.serverStateLock.Lock()
|
||||
if n.networkAvailable == available {
|
||||
n.serverStateLock.Unlock()
|
||||
return
|
||||
}
|
||||
previous := n.effectiveState(n.lastNotification)
|
||||
n.networkAvailable = available
|
||||
current := n.effectiveState(n.lastNotification)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
if previous != current {
|
||||
n.notify(current)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) setListener(listener Listener) {
|
||||
n.serverStateLock.Lock()
|
||||
lastNotification := n.lastNotification
|
||||
lastNotification := n.effectiveState(n.lastNotification)
|
||||
numOfPeers := n.lastNumberOfPeers
|
||||
fqdnAddress := n.lastFqdnAddress
|
||||
address := n.lastIPAddress
|
||||
@@ -61,43 +87,45 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) {
|
||||
}
|
||||
|
||||
n.lastNotification = calculatedState
|
||||
effective := n.effectiveState(calculatedState)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(calculatedState)
|
||||
n.notify(effective)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStart() {
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = true
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
effective := n.effectiveState(ClientStateConnecting)
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateConnecting)
|
||||
n.notify(effective)
|
||||
}
|
||||
|
||||
func (n *notifier) clientStop() {
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = stateDisconnected
|
||||
n.lastNotification = ClientStateDisconnected
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateDisconnected)
|
||||
n.notify(ClientStateDisconnected)
|
||||
}
|
||||
|
||||
func (n *notifier) clientTearDown() {
|
||||
n.serverStateLock.Lock()
|
||||
n.currentClientState = false
|
||||
n.lastNotification = stateDisconnecting
|
||||
n.lastNotification = ClientStateDisconnecting
|
||||
n.serverStateLock.Unlock()
|
||||
|
||||
n.notify(stateDisconnecting)
|
||||
n.notify(ClientStateDisconnecting)
|
||||
}
|
||||
|
||||
func (n *notifier) isServerStateChanged(newState int) bool {
|
||||
func (n *notifier) isServerStateChanged(newState ClientState) bool {
|
||||
return n.lastNotification != newState
|
||||
}
|
||||
|
||||
func (n *notifier) notify(state int) {
|
||||
func (n *notifier) notify(state ClientState) {
|
||||
n.listenersLock.Lock()
|
||||
listener := n.listener
|
||||
n.listenersLock.Unlock()
|
||||
@@ -109,20 +137,20 @@ func (n *notifier) notify(state int) {
|
||||
notifyListener(listener, state)
|
||||
}
|
||||
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) int {
|
||||
func (n *notifier) calculateState(managementConn, signalConn bool) ClientState {
|
||||
if managementConn && signalConn {
|
||||
return stateConnected
|
||||
return ClientStateConnected
|
||||
}
|
||||
|
||||
if !managementConn && !signalConn && !n.currentClientState {
|
||||
return stateDisconnected
|
||||
return ClientStateDisconnected
|
||||
}
|
||||
|
||||
if n.lastNotification == stateDisconnecting {
|
||||
return stateDisconnecting
|
||||
if n.lastNotification == ClientStateDisconnecting {
|
||||
return ClientStateDisconnecting
|
||||
}
|
||||
|
||||
return stateConnecting
|
||||
return ClientStateConnecting
|
||||
}
|
||||
|
||||
func (n *notifier) peerListChanged(numOfPeers int) {
|
||||
@@ -159,15 +187,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) {
|
||||
listener.OnAddressChanged(fqdn, address)
|
||||
}
|
||||
|
||||
func notifyListener(l Listener, state int) {
|
||||
func notifyListener(l Listener, state ClientState) {
|
||||
// legacy per-state callbacks; NoNetwork is delivered only via
|
||||
// OnStateChanged below
|
||||
switch state {
|
||||
case stateDisconnected:
|
||||
case ClientStateDisconnected:
|
||||
l.OnDisconnected()
|
||||
case stateConnected:
|
||||
case ClientStateConnected:
|
||||
l.OnConnected()
|
||||
case stateConnecting:
|
||||
case ClientStateConnecting:
|
||||
l.OnConnecting()
|
||||
case stateDisconnecting:
|
||||
case ClientStateDisconnecting:
|
||||
l.OnDisconnecting()
|
||||
}
|
||||
|
||||
l.OnStateChanged(state)
|
||||
}
|
||||
|
||||
@@ -6,29 +6,32 @@ import (
|
||||
)
|
||||
|
||||
type mocListener struct {
|
||||
lastState int
|
||||
lastState ClientState
|
||||
wg sync.WaitGroup
|
||||
peersWg sync.WaitGroup
|
||||
peers int
|
||||
}
|
||||
|
||||
func (l *mocListener) OnConnected() {
|
||||
l.lastState = stateConnected
|
||||
l.lastState = ClientStateConnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnected() {
|
||||
l.lastState = stateDisconnected
|
||||
l.lastState = ClientStateDisconnected
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnConnecting() {
|
||||
l.lastState = stateConnecting
|
||||
l.lastState = ClientStateConnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
func (l *mocListener) OnDisconnecting() {
|
||||
l.lastState = stateDisconnecting
|
||||
l.lastState = ClientStateDisconnecting
|
||||
l.wg.Done()
|
||||
}
|
||||
|
||||
func (l *mocListener) OnStateChanged(state ClientState) {
|
||||
|
||||
}
|
||||
func (l *mocListener) OnAddressChanged(host, addr string) {
|
||||
|
||||
}
|
||||
@@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) {
|
||||
|
||||
type scenario struct {
|
||||
name string
|
||||
expected int
|
||||
expected ClientState
|
||||
mgmState bool
|
||||
signalState bool
|
||||
}
|
||||
scenarios := []scenario{
|
||||
{"connected", stateConnected, true, true},
|
||||
{"mgm down", stateConnecting, false, true},
|
||||
{"signal down", stateConnecting, true, false},
|
||||
{"disconnected", stateDisconnected, false, false},
|
||||
{"connected", ClientStateConnected, true, true},
|
||||
{"mgm down", ClientStateConnecting, false, true},
|
||||
{"signal down", ClientStateConnecting, true, false},
|
||||
{"disconnected", ClientStateDisconnected, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range scenarios {
|
||||
@@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) {
|
||||
listener.setPeersWaiter()
|
||||
|
||||
n := newNotifier()
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.setListener(listener)
|
||||
listener.wait()
|
||||
listener.waitPeers()
|
||||
@@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) {
|
||||
listener.setWaiter()
|
||||
listener.setPeersWaiter()
|
||||
n := newNotifier()
|
||||
n.lastNotification = stateConnecting
|
||||
n.lastNotification = ClientStateConnecting
|
||||
n.setListener(listener)
|
||||
// setListener replays cached state on a goroutine; wait for both the state
|
||||
// and peers callbacks to finish so we don't race on listener.peers.
|
||||
|
||||
@@ -1211,6 +1211,12 @@ func (d *Status) ClientTeardown() {
|
||||
d.notifyStateChange()
|
||||
}
|
||||
|
||||
// SetNetworkAvailable records the OS-reported network availability; while
|
||||
// unavailable, listeners see NoNetwork instead of Connecting.
|
||||
func (d *Status) SetNetworkAvailable(available bool) {
|
||||
d.notifier.setNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// SetConnectionListener set a listener to the notifier
|
||||
func (d *Status) SetConnectionListener(listener Listener) {
|
||||
d.notifier.setListener(listener)
|
||||
@@ -1281,15 +1287,12 @@ func (d *Status) numOfPeers() int {
|
||||
return len(d.peers) + len(d.offlinePeers)
|
||||
}
|
||||
|
||||
// PublishEvent adds an event to the queue and distributes it to all subscribers.
|
||||
// msg is the English log-facing description; userMsg is the localizable
|
||||
// user-facing message, or nil for an internal control event that must not
|
||||
// surface as a notification.
|
||||
// PublishEvent adds an event to the queue and distributes it to all subscribers
|
||||
func (d *Status) PublishEvent(
|
||||
severity proto.SystemEvent_Severity,
|
||||
category proto.SystemEvent_Category,
|
||||
msg string,
|
||||
userMsg *proto.UserMessage,
|
||||
userMsg string,
|
||||
metadata map[string]string,
|
||||
) {
|
||||
event := &proto.SystemEvent{
|
||||
@@ -1297,10 +1300,7 @@ func (d *Status) PublishEvent(
|
||||
Severity: severity,
|
||||
Category: category,
|
||||
Message: msg,
|
||||
UserMessage: userMsg.Text(),
|
||||
MessageKey: string(userMsg.Key()),
|
||||
MessageArgs: userMsg.Args(),
|
||||
TitleKey: string(userMsg.TitleKey()),
|
||||
UserMessage: userMsg,
|
||||
Metadata: metadata,
|
||||
Timestamp: timestamppb.Now(),
|
||||
}
|
||||
|
||||
@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
// SetProfileState writes the state file of the profile identified by id. Prefer
|
||||
// it over SetActiveProfileState whenever the caller knows which profile the data
|
||||
// belongs to: an SSO login spans seconds of user interaction, and the active
|
||||
// profile can change during it, which would file the account email under
|
||||
// whichever profile happened to be active when the flow returned.
|
||||
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
return fmt.Errorf("empty profile ID")
|
||||
}
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActiveProfileState writes the state file of whichever profile is active at
|
||||
// call time. Use SetProfileState when the target profile is known.
|
||||
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoActiveProfile) {
|
||||
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
return fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
id := activeProf.ID
|
||||
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
||||
return fmt.Errorf("invalid active profile ID: %q", id)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return pm.SetProfileState(activeProf.ID, state)
|
||||
}
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
|
||||
@@ -403,7 +403,7 @@ func (w *Watcher) connectEvent(route *route.Route) {
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_NETWORK,
|
||||
"Default route added",
|
||||
proto.NewUserMessage(proto.UserMsgExitNodeConnected),
|
||||
"Exit node connected.",
|
||||
meta,
|
||||
)
|
||||
}
|
||||
@@ -423,7 +423,7 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
|
||||
|
||||
var severity proto.SystemEvent_Severity
|
||||
var message string
|
||||
var userMessage *proto.UserMessage
|
||||
var userMessage string
|
||||
meta := make(map[string]string)
|
||||
|
||||
if route != nil {
|
||||
@@ -435,22 +435,22 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
|
||||
case reasonShutdown:
|
||||
severity = proto.SystemEvent_INFO
|
||||
message = "Default route removed"
|
||||
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnected)
|
||||
userMessage = "Exit node disconnected."
|
||||
case reasonRouteUpdate:
|
||||
severity = proto.SystemEvent_INFO
|
||||
message = "Default route updated due to configuration change"
|
||||
case reasonPeerUpdate:
|
||||
severity = proto.SystemEvent_WARNING
|
||||
message = "Default route disconnected due to peer unreachability"
|
||||
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeConnectionLost)
|
||||
userMessage = "Exit node connection lost. Your internet access might be affected."
|
||||
case reasonHA:
|
||||
severity = proto.SystemEvent_INFO
|
||||
message = "Default route disconnected due to high availability change"
|
||||
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeHAChange)
|
||||
userMessage = "Exit node disconnected due to high availability change."
|
||||
default:
|
||||
severity = proto.SystemEvent_ERROR
|
||||
message = "Default route disconnected for unknown reasons"
|
||||
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnectedUnknown)
|
||||
userMessage = "Exit node disconnected for unknown reasons."
|
||||
}
|
||||
|
||||
w.statusRecorder.PublishEvent(
|
||||
|
||||
@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
|
||||
|
||||
// internalDnatFw checks if the firewall supports internal DNAT
|
||||
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
|
||||
if d.firewall == nil || runtime.GOOS != "android" {
|
||||
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
|
||||
return nil, false
|
||||
}
|
||||
fw, ok := d.firewall.(internalDNATer)
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -62,7 +61,7 @@ type Manager interface {
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
CurrentRouteRange() []string
|
||||
SetFirewall(firewall.Manager) error
|
||||
SetDNSForwarderPort(port uint16)
|
||||
ReconcilePeerAllowedIPs(peerKey string) error
|
||||
@@ -76,10 +75,8 @@ type ManagerConfig struct {
|
||||
WGInterface iface.WGIface
|
||||
StatusRecorder *peer.Status
|
||||
RelayManager *relayClient.Manager
|
||||
InitialRoutes []*route.Route
|
||||
StateManager *statemanager.Manager
|
||||
DNSServer dns.Server
|
||||
DNSFeatureFlag bool
|
||||
PeerStore *peerstore.Store
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
|
||||
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
|
||||
dm.setupRefCounters(useNoop)
|
||||
|
||||
// don't proceed with client routes if it is disabled
|
||||
if config.DisableClientRoutes {
|
||||
return dm
|
||||
}
|
||||
|
||||
if runtime.GOOS == "android" {
|
||||
dm.setupAndroidRoutes(config)
|
||||
}
|
||||
return dm
|
||||
}
|
||||
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
cr := m.initialClientRoutes(config.InitialRoutes)
|
||||
|
||||
routesForComparison := slices.Clone(cr)
|
||||
|
||||
if config.DNSFeatureFlag {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
|
||||
v4ID := uuid.NewString()
|
||||
fakeIPRoute := &route.Route{
|
||||
ID: route.ID(v4ID),
|
||||
Network: m.fakeIPManager.GetFakeIPBlock(),
|
||||
NetID: route.NetID(v4ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv4Network,
|
||||
}
|
||||
v6ID := uuid.NewString()
|
||||
fakeIPv6Route := &route.Route{
|
||||
ID: route.ID(v6ID),
|
||||
Network: m.fakeIPManager.GetFakeIPv6Block(),
|
||||
NetID: route.NetID(v6ID),
|
||||
Peer: m.pubKey,
|
||||
NetworkType: route.IPv6Network,
|
||||
}
|
||||
cr = append(cr, fakeIPRoute, fakeIPv6Route)
|
||||
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
|
||||
}
|
||||
|
||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
||||
func (m *DefaultManager) enableFakeIPRoutes() {
|
||||
m.fakeIPManager = fakeip.NewManager()
|
||||
m.notifier.NotifyRouteChange()
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
|
||||
|
||||
var merr *multierror.Error
|
||||
if !m.disableClientRoutes {
|
||||
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
|
||||
m.enableFakeIPRoutes()
|
||||
}
|
||||
|
||||
// Update route selector based on management server's isSelected status
|
||||
m.updateRouteSelectorFromManagement(clientRoutes)
|
||||
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
|
||||
m.notifier.SetListener(listener)
|
||||
}
|
||||
|
||||
// InitialRouteRange return the list of initial routes. It used by mobile systems
|
||||
func (m *DefaultManager) InitialRouteRange() []string {
|
||||
return m.notifier.GetInitialRouteRanges()
|
||||
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
|
||||
func (m *DefaultManager) CurrentRouteRange() []string {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
|
||||
if m.disableClientRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
|
||||
var nets []string
|
||||
for _, routes := range filtered {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, r.NetString())
|
||||
}
|
||||
}
|
||||
|
||||
if m.fakeIPManager != nil {
|
||||
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
|
||||
}
|
||||
|
||||
sort.Strings(nets)
|
||||
return nets
|
||||
}
|
||||
|
||||
// GetRouteSelector returns the route selector
|
||||
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
|
||||
return newServerRoutesMap, newClientRoutesIDMap
|
||||
}
|
||||
|
||||
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
|
||||
_, crMap := m.ClassifyRoutes(initialRoutes)
|
||||
rs := make([]*route.Route, 0, len(crMap))
|
||||
for _, routes := range crMap {
|
||||
rs = append(rs, routes...)
|
||||
}
|
||||
|
||||
return rs
|
||||
}
|
||||
|
||||
func isRouteSupported(route *route.Route) bool {
|
||||
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
|
||||
return true
|
||||
|
||||
@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
|
||||
func (m *MockManager) InitialRouteRange() []string {
|
||||
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
|
||||
func (m *MockManager) CurrentRouteRange() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
@@ -14,12 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoutes []*route.Route
|
||||
mu sync.Mutex
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
// currentRoutes is the last announced route set. It exists only to
|
||||
// suppress noise: without it every network map sync would trigger the
|
||||
// Java side, even when the routes did not change. The actual TUN route
|
||||
// state is owned by the route manager and pulled from there.
|
||||
currentRoutes []*route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
|
||||
}
|
||||
|
||||
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
|
||||
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
|
||||
n.fakeIPRoutes = routes
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
}
|
||||
}
|
||||
|
||||
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if !hasRouteDiff(n.currentRoutes, newRoutes) {
|
||||
return
|
||||
}
|
||||
|
||||
n.currentRoutes = newRoutes
|
||||
n.notify()
|
||||
n.notifyLocked()
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
|
||||
// Not used on Android
|
||||
}
|
||||
|
||||
func (n *Notifier) notify() {
|
||||
n.listenerMux.Lock()
|
||||
defer n.listenerMux.Unlock()
|
||||
func (n *Notifier) notifyLocked() {
|
||||
if n.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
allRoutes = append(allRoutes, n.fakeIPRoutes...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
go func(l listener.NetworkChangeListener) {
|
||||
l.OnNetworkChanged(strings.Join(routeStrings, ","))
|
||||
}(n.listener)
|
||||
n.listener.OnNetworkChanged("")
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
if !r.IsDynamic() {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
func routesToStrings(routes []*route.Route) []string {
|
||||
nets := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
nets = append(nets, r.NetString())
|
||||
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
|
||||
return nets
|
||||
}
|
||||
|
||||
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
slices.SortFunc(a, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
slices.SortFunc(b, func(x, y *route.Route) int {
|
||||
return strings.Compare(x.NetString(), y.NetString())
|
||||
})
|
||||
|
||||
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
|
||||
return x.NetString() == y.NetString()
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
initialStrings := n.routesToStrings(n.initialRoutes)
|
||||
sort.Strings(initialStrings)
|
||||
return initialStrings
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
|
||||
as := routesToStrings(a)
|
||||
bs := routesToStrings(b)
|
||||
sort.Strings(as)
|
||||
sort.Strings(bs)
|
||||
return !slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
|
||||
func (n *Notifier) NotifyRouteChange() {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) GetInitialRouteRanges() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (n *Notifier) Close() {
|
||||
// unused
|
||||
}
|
||||
|
||||
@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
|
||||
func (u *Installer) startUIAsUser() error {
|
||||
log.Infof("starting netbird-ui: %s", uiBinary)
|
||||
|
||||
// Get the current console user
|
||||
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
|
||||
output, err := cmd.Output()
|
||||
username, err := consoleUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get console user: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(string(output))
|
||||
if username == "" || username == "root" {
|
||||
return fmt.Errorf("no active user session found")
|
||||
}
|
||||
|
||||
log.Infof("starting UI for user: %s", username)
|
||||
|
||||
// Get user's UID
|
||||
userInfo, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to lookup user %s: %w", username, err)
|
||||
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
|
||||
// Start the UI process as the console user using launchctl
|
||||
// This ensures the app runs in the user's context with proper GUI access
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
|
||||
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
|
||||
|
||||
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
|
||||
log.Infof("launchCmd: %s", launchCmd.String())
|
||||
// Set the user's home directory for proper macOS app behavior
|
||||
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
|
||||
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
|
||||
|
||||
if err := launchCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start UI process: %w", err)
|
||||
}
|
||||
|
||||
// Release the process so it can run independently
|
||||
if err := launchCmd.Process.Release(); err != nil {
|
||||
log.Warnf("failed to release UI process: %v", err)
|
||||
if err := launchCmd.Run(); err != nil {
|
||||
return fmt.Errorf("run UI launch: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("netbird-ui started successfully for user %s", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func consoleUser() (string, error) {
|
||||
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get console user: %w", err)
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(string(output))
|
||||
switch username {
|
||||
case "", "root", "loginwindow", "_mbsetupuser":
|
||||
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
|
||||
}
|
||||
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
|
||||
log.Infof("installing pkg file: %s", path)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
|
||||
cProto.SystemEvent_ERROR,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"Auto-update failed",
|
||||
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, reason),
|
||||
fmt.Sprintf("Auto-update failed: %s", reason),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"Auto-update completed",
|
||||
cProto.NewUserMessage(cProto.UserMsgUpdateCompleted, cProto.ArgVersion, m.currentVersion),
|
||||
fmt.Sprintf("Your NetBird Client was auto-updated to version %s", m.currentVersion),
|
||||
nil,
|
||||
)
|
||||
return
|
||||
@@ -272,7 +272,7 @@ func (m *Manager) NotifyUI() {
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"New version available",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{"new_version_available": latestVersion.String()},
|
||||
)
|
||||
return
|
||||
@@ -283,7 +283,7 @@ func (m *Manager) NotifyUI() {
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"New version available",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"},
|
||||
)
|
||||
}
|
||||
@@ -384,7 +384,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"New version available",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{"new_version_available": updateVersion.String()},
|
||||
)
|
||||
return
|
||||
@@ -401,7 +401,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
|
||||
cProto.SystemEvent_INFO,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"New version available",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"},
|
||||
)
|
||||
}
|
||||
@@ -411,14 +411,14 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"Updating client",
|
||||
cProto.NewUserMessage(cProto.UserMsgUpdateInstalling),
|
||||
"Installing update now.",
|
||||
nil,
|
||||
)
|
||||
m.statusRecorder.PublishEvent(
|
||||
cProto.SystemEvent_CRITICAL,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{"progress_window": "show", "version": pendingVersion.String()},
|
||||
)
|
||||
|
||||
@@ -441,7 +441,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
|
||||
cProto.SystemEvent_ERROR,
|
||||
cProto.SystemEvent_SYSTEM,
|
||||
"Auto-update failed",
|
||||
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, err.Error()),
|
||||
fmt.Sprintf("Auto-update failed: %v", err),
|
||||
nil,
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/listener"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -28,11 +30,6 @@ import (
|
||||
types "github.com/netbirdio/netbird/upload-server/types"
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile
|
||||
type ConnectionListener interface {
|
||||
peer.Listener
|
||||
}
|
||||
|
||||
// RouteListener export internal RouteListener for mobile
|
||||
type NetworkChangeListener interface {
|
||||
listener.NetworkChangeListener
|
||||
@@ -79,6 +76,12 @@ type Client struct {
|
||||
onHostDnsFn func([]string)
|
||||
dnsManager dns.IosDnsManager
|
||||
loginComplete bool
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run injects it into each new ConnectClient, which
|
||||
// distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
||||
preloadedConfig *profilemanager.Config
|
||||
|
||||
@@ -101,6 +104,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
dnsManager: dnsManager,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +181,8 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
c.onHostDnsFn = func([]string) {}
|
||||
cfg.WgIface = interfaceName
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
c.setState(cfg, connectClient)
|
||||
// Persist the latest sync response so DebugBundle can include the network
|
||||
// map. On iOS this is backed by disk to keep it out of the constrained
|
||||
@@ -185,6 +191,24 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
|
||||
}
|
||||
|
||||
// SetNetworkAvailable feeds OS-reported network availability into the client
|
||||
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
|
||||
// suspend their attempts and the connection listener reports NoNetwork
|
||||
// instead of Connecting; when availability returns, the loops resume
|
||||
// immediately with a fresh backoff.
|
||||
func (c *Client) SetNetworkAvailable(available bool) {
|
||||
c.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange cuts the management, signal and relay connections
|
||||
// after the OS switched networks, so the reconnect loops redial immediately
|
||||
// on the new one. The engine and the TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
n := c.sweeper.Sweep()
|
||||
log.Infof("network change: swept %d connections", n)
|
||||
}
|
||||
|
||||
// Stop the internal client and free the resources
|
||||
func (c *Client) Stop() {
|
||||
c.ctxCancelLock.Lock()
|
||||
@@ -320,7 +344,7 @@ func (c *Client) GetStatusDetails() *StatusDetails {
|
||||
|
||||
// SetConnectionListener set the network connection listener
|
||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||
c.recorder.SetConnectionListener(listener)
|
||||
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||
}
|
||||
|
||||
// RemoveConnectionListener remove connection listener
|
||||
|
||||
43
client/ios/NetBirdSDK/connection_listener.go
Normal file
43
client/ios/NetBirdSDK/connection_listener.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// Client state values, re-exported as basic constants so gomobile emits them
|
||||
// into the generated bindings. They mirror peer.ClientState*: append-only,
|
||||
// never reorder.
|
||||
const (
|
||||
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||
ClientStateConnected = int(peer.ClientStateConnected)
|
||||
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||
)
|
||||
|
||||
// ConnectionListener export internal Listener for mobile.
|
||||
//
|
||||
// It intentionally lacks OnStateChanged for now: adding a method to a gomobile
|
||||
// interface breaks every Swift implementation, so the iOS app keeps building
|
||||
// against the legacy per-state callbacks. A follow-up will extend it together
|
||||
// with the app.
|
||||
type ConnectionListener interface {
|
||||
OnConnected()
|
||||
OnDisconnected()
|
||||
OnConnecting()
|
||||
OnDisconnecting()
|
||||
OnAddressChanged(string, string)
|
||||
OnPeersListChanged(int)
|
||||
}
|
||||
|
||||
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||
// peer.Listener.
|
||||
type connectionListenerAdapter struct {
|
||||
ConnectionListener
|
||||
}
|
||||
|
||||
// OnStateChanged is dropped on iOS until the app adopts the state callback;
|
||||
// the legacy per-state callbacks continue to fire.
|
||||
func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {}
|
||||
93
client/netstate/netstate.go
Normal file
93
client/netstate/netstate.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package netstate tracks OS-reported network availability for the client.
|
||||
//
|
||||
// A State instance is owned by the platform integration (e.g. the Android or
|
||||
// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and
|
||||
// is injected into the connection retry loops (management, signal, relay,
|
||||
// peer guards and the top-level connect loop), which consult it to avoid
|
||||
// burning CPU and battery on reconnect attempts while the device has no
|
||||
// network at all (e.g. airplane mode), and to reset their backoff as soon as
|
||||
// the network returns.
|
||||
//
|
||||
// Consumers hold a *State that may be nil — every non-mobile platform leaves
|
||||
// it unset. The read methods are safe on a nil receiver: they report online
|
||||
// and never block, so consumers behave as if this package did not exist.
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// State holds the OS-reported network availability. The zero value is not
|
||||
// usable; create instances with New.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
online bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// New creates a State that starts online.
|
||||
func New() *State {
|
||||
return &State{
|
||||
online: true,
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records whether the OS reports any usable network. Transitions wake up
|
||||
// all Wait callers immediately.
|
||||
func (s *State) Set(online bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.online == online {
|
||||
return
|
||||
}
|
||||
s.online = online
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
log.Infof("OS network availability changed: online=%t", online)
|
||||
}
|
||||
|
||||
// IsOnline reports whether the OS reports at least one usable network. On a
|
||||
// nil receiver — no State injected — it reports online.
|
||||
func (s *State) IsOnline() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.online
|
||||
}
|
||||
|
||||
// Wait blocks while the network is offline. It reports whether it had to
|
||||
// wait, so callers can reset their backoff after an outage. It returns early
|
||||
// with the context error when ctx is done. On a nil receiver — no State
|
||||
// injected — it returns immediately.
|
||||
func (s *State) Wait(ctx context.Context) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
waited := false
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if s.online {
|
||||
s.mu.Unlock()
|
||||
return waited, nil
|
||||
}
|
||||
ch := s.changed
|
||||
s.mu.Unlock()
|
||||
|
||||
if !waited {
|
||||
waited = true
|
||||
log.Debugf("network is offline, pausing connection attempts")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return waited, ctx.Err()
|
||||
case <-ch:
|
||||
}
|
||||
}
|
||||
}
|
||||
170
client/netstate/netstate_test.go
Normal file
170
client/netstate/netstate_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package netstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStateIsOnline(t *testing.T) {
|
||||
assert.True(t, New().IsOnline(), "a fresh State should start online")
|
||||
}
|
||||
|
||||
func TestSetTogglesOnlineState(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
s.Set(false)
|
||||
assert.False(t, s.IsOnline(), "state should be offline after Set(false)")
|
||||
|
||||
s.Set(true)
|
||||
assert.True(t, s.IsOnline(), "state should be online after Set(true)")
|
||||
}
|
||||
|
||||
func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
waited, err := s.Wait(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "Wait should not block when the network is online")
|
||||
}
|
||||
|
||||
func TestWaitBlocksUntilOnline(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := make(chan bool, 1)
|
||||
go func() {
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
result <- false
|
||||
return
|
||||
}
|
||||
result <- waited
|
||||
}()
|
||||
|
||||
// Verify Wait is actually blocking while offline
|
||||
select {
|
||||
case <-result:
|
||||
t.Fatal("Wait should block while the network is offline")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
s.Set(true)
|
||||
|
||||
select {
|
||||
case waited := <-result:
|
||||
assert.True(t, waited, "Wait should report that it had to wait for the network")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after the network becomes available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitReturnsOnContextCancel(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.Wait(ctx)
|
||||
result <- err
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait should return promptly after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitWakesAllWaiters(t *testing.T) {
|
||||
s := New()
|
||||
s.Set(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const waiters = 10
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, waiters)
|
||||
for i := 0; i < waiters; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
waited, err := s.Wait(ctx)
|
||||
if err != nil {
|
||||
results <- false
|
||||
return
|
||||
}
|
||||
results <- waited
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
s.Set(true)
|
||||
wg.Wait()
|
||||
|
||||
close(results)
|
||||
count := 0
|
||||
for waited := range results {
|
||||
assert.True(t, waited, "every waiter should report that it waited")
|
||||
count++
|
||||
}
|
||||
assert.Equal(t, waiters, count, "all waiters should have returned")
|
||||
}
|
||||
|
||||
func TestNilStateReadsAreNoops(t *testing.T) {
|
||||
var s *State
|
||||
|
||||
assert.True(t, s.IsOnline(), "nil State should report online")
|
||||
|
||||
waited, err := s.Wait(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, waited, "nil State's Wait should not block")
|
||||
}
|
||||
|
||||
func TestConcurrentSetAndWait(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
s.Set(j%2 == 0)
|
||||
s.IsOnline()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
if _, err := s.Wait(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
171
client/netsweep/netsweep.go
Normal file
171
client/netsweep/netsweep.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Package netsweep cuts network-bound activity when the OS switches networks:
|
||||
// a sweep closes the registered connections and aborts the in-flight dials, so
|
||||
// their owners redial immediately instead of waiting for the old sockets to
|
||||
// time out.
|
||||
//
|
||||
// A nil *Sweeper disables everything: all methods are nil-safe no-ops.
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrSwept reports that a dial finished after a network change swept its
|
||||
// registration. The connection is already closed; the caller must treat it
|
||||
// as a failed dial and redial on the new network.
|
||||
var ErrSwept = errors.New("netsweep: connection swept by network change")
|
||||
|
||||
// sweptConn deregisters itself from the sweeper when closed.
|
||||
type sweptConn struct {
|
||||
net.Conn
|
||||
sweeper *Sweeper
|
||||
id uint64
|
||||
}
|
||||
|
||||
func (c *sweptConn) Close() error {
|
||||
c.sweeper.deregister(c.id)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// Sweeper registers live connections and in-flight dials so Sweep can cut
|
||||
// everything that started before the network changed.
|
||||
type Sweeper struct {
|
||||
mu sync.Mutex
|
||||
conns map[uint64]net.Conn
|
||||
dials map[uint64]*Dial
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
// New creates an empty sweeper.
|
||||
func New() *Sweeper {
|
||||
return &Sweeper{
|
||||
conns: make(map[uint64]net.Conn),
|
||||
dials: make(map[uint64]*Dial),
|
||||
}
|
||||
}
|
||||
|
||||
// Dial tracks one dial from start to connection registration. It hands the
|
||||
// dialed connection to the sweeper atomically, so a sweep can never fall
|
||||
// between the dial finishing and the connection being registered.
|
||||
type Dial struct {
|
||||
sweeper *Sweeper
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
id uint64
|
||||
done bool // set by Sweep, WrapConn or Release; guarded by sweeper.mu
|
||||
}
|
||||
|
||||
// StartDial registers an in-flight dial. Dial with Ctx, hand the result to
|
||||
// WrapConn, and Release the dial when the attempt is over, typically deferred.
|
||||
func (s *Sweeper) StartDial(ctx context.Context) *Dial {
|
||||
if s == nil {
|
||||
return &Dial{ctx: ctx}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
d := &Dial{sweeper: s, ctx: ctx, cancel: cancel}
|
||||
|
||||
s.mu.Lock()
|
||||
d.id = s.nextID
|
||||
s.nextID++
|
||||
s.dials[d.id] = d
|
||||
s.mu.Unlock()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Ctx returns the dial's context. Sweep cancels it, so a dial started on the
|
||||
// old network aborts instead of waiting out its handshake timeout.
|
||||
func (d *Dial) Ctx() context.Context {
|
||||
return d.ctx
|
||||
}
|
||||
|
||||
// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial,
|
||||
// the connection belongs to the old network: it is closed and ErrSwept is
|
||||
// returned. Otherwise conn is registered against the next sweep and returned
|
||||
// wrapped, deregistering itself on Close. Call it once, before Release.
|
||||
func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if d.done {
|
||||
s.mu.Unlock()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept dial close error: %v", err)
|
||||
}
|
||||
return nil, ErrSwept
|
||||
}
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.conns[id] = conn
|
||||
s.mu.Unlock()
|
||||
|
||||
return &sweptConn{Conn: conn, sweeper: s, id: id}, nil
|
||||
}
|
||||
|
||||
// Release ends the dial's registration and cancels its context. It is
|
||||
// idempotent and safe after WrapConn, so callers can defer it.
|
||||
func (d *Dial) Release() {
|
||||
s := d.sweeper
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
d.done = true
|
||||
delete(s.dials, d.id)
|
||||
s.mu.Unlock()
|
||||
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
// Sweep closes every registered connection, aborts every in-flight dial, and
|
||||
// returns how many connections it closed. A dial whose connection was not
|
||||
// yet handed to WrapConn is marked, so the late WrapConn closes it instead
|
||||
// of registering it.
|
||||
func (s *Sweeper) Sweep() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
conns := s.conns
|
||||
dials := s.dials
|
||||
s.conns = make(map[uint64]net.Conn)
|
||||
s.dials = make(map[uint64]*Dial)
|
||||
for _, d := range dials {
|
||||
d.done = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(dials) > 0 {
|
||||
log.Debugf("aborting %d in-flight dials", len(dials))
|
||||
for _, d := range dials {
|
||||
d.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
for _, conn := range conns {
|
||||
log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("swept connection close error: %v", err)
|
||||
}
|
||||
}
|
||||
return len(conns)
|
||||
}
|
||||
|
||||
func (s *Sweeper) deregister(id uint64) {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
168
client/netsweep/netsweep_test.go
Normal file
168
client/netsweep/netsweep_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package netsweep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSweepClosesRegisteredConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
c1 := wrap(t, sweeper, connPair(t))
|
||||
c2 := wrap(t, sweeper, connPair(t))
|
||||
|
||||
assert.Equal(t, 2, sweeper.Sweep(), "both live connections should be closed")
|
||||
|
||||
// The wrappers must report closed now.
|
||||
buf := make([]byte, 1)
|
||||
_, err := c1.Read(buf)
|
||||
assert.Error(t, err, "first connection should be unusable after the sweep")
|
||||
_, err = c2.Read(buf)
|
||||
assert.Error(t, err, "second connection should be unusable after the sweep")
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "second sweep should find nothing")
|
||||
}
|
||||
|
||||
func TestCloseDeregisters(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "closed connection must leave the registry")
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
conn := wrap(t, sweeper, connPair(t))
|
||||
require.NoError(t, conn.Close())
|
||||
assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic")
|
||||
}
|
||||
|
||||
func TestSweepOnlyAffectsOlderConns(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.Sweep())
|
||||
|
||||
// A connection dialed after the sweep must survive until the next one.
|
||||
_ = wrap(t, sweeper, connPair(t))
|
||||
assert.Equal(t, 1, sweeper.Sweep(), "post-sweep connection belongs to the next sweep")
|
||||
}
|
||||
|
||||
func TestSweepAbortsInFlightDials(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
sweeper.Sweep()
|
||||
|
||||
assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context")
|
||||
}
|
||||
|
||||
func TestReleasedDialIsNotAborted(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
// Simulate a dial that finished before the sweep.
|
||||
released := sweeper.StartDial(context.Background())
|
||||
released.Release()
|
||||
|
||||
// A dial still in flight during the sweep.
|
||||
pending := sweeper.StartDial(context.Background())
|
||||
defer pending.Release()
|
||||
|
||||
sweeper.Sweep()
|
||||
assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted")
|
||||
}
|
||||
|
||||
func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) {
|
||||
sweeper := New()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
// The dial succeeds on the old network, then the sweep lands before the
|
||||
// connection is handed over.
|
||||
conn := connPair(t)
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "the connection is not registered yet")
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.ErrorIs(t, err, ErrSwept)
|
||||
require.Nil(t, wrapped)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
_, err = conn.Read(buf)
|
||||
assert.Error(t, err, "the old-network connection must be closed, not leaked")
|
||||
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "nothing may leak into the next sweep")
|
||||
}
|
||||
|
||||
func TestNilSweeperIsNoop(t *testing.T) {
|
||||
var sweeper *Sweeper
|
||||
|
||||
conn := connPair(t)
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged")
|
||||
assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context")
|
||||
assert.Equal(t, 0, sweeper.Sweep(), "nil sweeper closes nothing")
|
||||
}
|
||||
|
||||
// wrap registers conn with the sweeper through a completed dial.
|
||||
func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
dial := sweeper.StartDial(context.Background())
|
||||
defer dial.Release()
|
||||
|
||||
wrapped, err := dial.WrapConn(conn)
|
||||
require.NoError(t, err)
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// connPair dials a loopback TCP connection and keeps the accepted peer open
|
||||
// until the test ends: a peer that closed early would make the connection
|
||||
// unreadable on its own, so a read error after the sweep would prove nothing.
|
||||
func connPair(t *testing.T) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := l.Close(); err != nil {
|
||||
t.Logf("listener close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
close(accepted)
|
||||
return
|
||||
}
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", l.Addr().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
peer, ok := <-accepted
|
||||
require.True(t, ok, "listener must accept the dialed connection")
|
||||
t.Cleanup(func() {
|
||||
if err := peer.Close(); err != nil {
|
||||
t.Logf("peer close error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
@@ -3909,30 +3909,14 @@ func (*SubscribeRequest) Descriptor() ([]byte, []int) {
|
||||
}
|
||||
|
||||
type SystemEvent struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
|
||||
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
|
||||
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||
// userMessage is the daemon's English rendering of messageKey, kept for the
|
||||
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
|
||||
// and treat this as the fallback.
|
||||
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
// messageKey names a user-facing message in a stable, locale-independent
|
||||
// form. A UI resolves it against its own translation bundle and substitutes
|
||||
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
|
||||
// that carry no user-facing text.
|
||||
MessageKey string `protobuf:"bytes,8,opt,name=messageKey,proto3" json:"messageKey,omitempty"`
|
||||
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
|
||||
// {"version": "0.60.1"} for a "{version}" template. Values are data the
|
||||
// daemon cannot localise (versions, addresses, error text).
|
||||
MessageArgs map[string]string `protobuf:"bytes,9,rep,name=messageArgs,proto3" json:"messageArgs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
// titleKey names the notification title in the same way as messageKey. Empty
|
||||
// when the event has no dedicated title, in which case a UI composes one from
|
||||
// severity and category.
|
||||
TitleKey string `protobuf:"bytes,10,opt,name=titleKey,proto3" json:"titleKey,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
|
||||
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
|
||||
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -4016,27 +4000,6 @@ func (x *SystemEvent) GetMetadata() map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SystemEvent) GetMessageKey() string {
|
||||
if x != nil {
|
||||
return x.MessageKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SystemEvent) GetMessageArgs() map[string]string {
|
||||
if x != nil {
|
||||
return x.MessageArgs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SystemEvent) GetTitleKey() string {
|
||||
if x != nil {
|
||||
return x.TitleKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetEventsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -7369,7 +7332,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x13TracePacketResponse\x12*\n" +
|
||||
"\x06stages\x18\x01 \x03(\v2\x12.daemon.TraceStageR\x06stages\x12+\n" +
|
||||
"\x11final_disposition\x18\x02 \x01(\bR\x10finalDisposition\"\x12\n" +
|
||||
"\x10SubscribeRequest\"\xd7\x05\n" +
|
||||
"\x10SubscribeRequest\"\x93\x04\n" +
|
||||
"\vSystemEvent\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x128\n" +
|
||||
"\bseverity\x18\x02 \x01(\x0e2\x1c.daemon.SystemEvent.SeverityR\bseverity\x128\n" +
|
||||
@@ -7377,18 +7340,9 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\amessage\x18\x04 \x01(\tR\amessage\x12 \n" +
|
||||
"\vuserMessage\x18\x05 \x01(\tR\vuserMessage\x128\n" +
|
||||
"\ttimestamp\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12=\n" +
|
||||
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x12\x1e\n" +
|
||||
"\n" +
|
||||
"messageKey\x18\b \x01(\tR\n" +
|
||||
"messageKey\x12F\n" +
|
||||
"\vmessageArgs\x18\t \x03(\v2$.daemon.SystemEvent.MessageArgsEntryR\vmessageArgs\x12\x1a\n" +
|
||||
"\btitleKey\x18\n" +
|
||||
" \x01(\tR\btitleKey\x1a;\n" +
|
||||
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x1a;\n" +
|
||||
"\rMetadataEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" +
|
||||
"\x10MessageArgsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\":\n" +
|
||||
"\bSeverity\x12\b\n" +
|
||||
"\x04INFO\x10\x00\x12\v\n" +
|
||||
@@ -7706,7 +7660,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 111)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
|
||||
var file_daemon_proto_goTypes = []any{
|
||||
(LogLevel)(0), // 0: daemon.LogLevel
|
||||
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
|
||||
@@ -7822,17 +7776,16 @@ var file_daemon_proto_goTypes = []any{
|
||||
nil, // 111: daemon.Network.ResolvedIPsEntry
|
||||
(*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
|
||||
nil, // 113: daemon.SystemEvent.MetadataEntry
|
||||
nil, // 114: daemon.SystemEvent.MessageArgsEntry
|
||||
(*durationpb.Duration)(nil), // 115: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 116: google.protobuf.Timestamp
|
||||
(*durationpb.Duration)(nil), // 114: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
|
||||
}
|
||||
var file_daemon_proto_depIdxs = []int32{
|
||||
115, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
|
||||
116, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
|
||||
116, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
|
||||
116, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
|
||||
115, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
|
||||
115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
|
||||
115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
|
||||
115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
|
||||
114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
|
||||
23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
|
||||
20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
|
||||
19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
|
||||
@@ -7855,115 +7808,114 @@ var file_daemon_proto_depIdxs = []int32{
|
||||
54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
|
||||
2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
|
||||
3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
|
||||
116, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
|
||||
114, // 30: daemon.SystemEvent.messageArgs:type_name -> daemon.SystemEvent.MessageArgsEntry
|
||||
57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
|
||||
115, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
72, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
|
||||
116, // 34: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
|
||||
1, // 35: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
|
||||
104, // 36: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
|
||||
115, // 37: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
|
||||
115, // 38: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
|
||||
30, // 39: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
|
||||
5, // 40: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
|
||||
7, // 41: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
||||
9, // 42: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
||||
11, // 43: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
||||
11, // 44: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
|
||||
13, // 45: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
||||
15, // 46: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
||||
26, // 47: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
||||
28, // 48: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
28, // 49: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
4, // 50: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
||||
35, // 51: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
||||
37, // 52: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
||||
39, // 53: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
||||
44, // 54: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
||||
46, // 55: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
||||
48, // 56: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
||||
50, // 57: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
||||
53, // 58: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
||||
105, // 59: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
|
||||
107, // 60: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
|
||||
109, // 61: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
|
||||
56, // 62: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
||||
58, // 63: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
||||
41, // 64: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
|
||||
60, // 65: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
||||
62, // 66: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
||||
64, // 67: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
||||
66, // 68: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
|
||||
68, // 69: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
||||
70, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
||||
73, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
||||
75, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
||||
79, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
||||
82, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
||||
84, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
||||
86, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
||||
88, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
||||
90, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
|
||||
92, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
|
||||
94, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
|
||||
96, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
||||
98, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
||||
100, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
||||
102, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
||||
77, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
|
||||
6, // 86: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
||||
8, // 87: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
||||
10, // 88: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
||||
12, // 89: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
||||
12, // 90: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
|
||||
14, // 91: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
||||
16, // 92: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
||||
27, // 93: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
||||
29, // 94: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
29, // 95: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
34, // 96: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
||||
36, // 97: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
||||
38, // 98: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
||||
40, // 99: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
||||
45, // 100: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
||||
47, // 101: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
||||
49, // 102: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
||||
51, // 103: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
||||
55, // 104: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
||||
106, // 105: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
|
||||
108, // 106: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
|
||||
110, // 107: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
|
||||
57, // 108: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
||||
59, // 109: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
||||
42, // 110: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
|
||||
61, // 111: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
||||
63, // 112: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
||||
65, // 113: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
||||
67, // 114: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
|
||||
69, // 115: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
||||
71, // 116: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
||||
74, // 117: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
||||
76, // 118: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
||||
80, // 119: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
||||
83, // 120: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
||||
85, // 121: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
||||
87, // 122: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
||||
89, // 123: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
||||
91, // 124: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
|
||||
93, // 125: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
|
||||
95, // 126: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
|
||||
97, // 127: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
||||
99, // 128: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
||||
101, // 129: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
||||
103, // 130: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
||||
78, // 131: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
|
||||
86, // [86:132] is the sub-list for method output_type
|
||||
40, // [40:86] is the sub-list for method input_type
|
||||
40, // [40:40] is the sub-list for extension type_name
|
||||
40, // [40:40] is the sub-list for extension extendee
|
||||
0, // [0:40] is the sub-list for field type_name
|
||||
57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
|
||||
114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
|
||||
115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
|
||||
1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
|
||||
104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
|
||||
114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
|
||||
114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
|
||||
30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
|
||||
5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
|
||||
7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
||||
9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
||||
11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
||||
11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
|
||||
13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
||||
15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
||||
26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
||||
28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
||||
35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
||||
37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
||||
39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
||||
44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
||||
46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
||||
48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
||||
50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
||||
53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
||||
105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
|
||||
107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
|
||||
109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
|
||||
56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
||||
58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
||||
41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
|
||||
60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
||||
62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
||||
64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
||||
66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
|
||||
68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
||||
70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
||||
73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
||||
75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
||||
79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
||||
82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
||||
84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
||||
86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
||||
88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
||||
90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
|
||||
92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
|
||||
94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
|
||||
96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
||||
98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
||||
100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
||||
102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
||||
77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
|
||||
6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
||||
8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
||||
10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
||||
12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
||||
12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
|
||||
14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
||||
16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
||||
27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
||||
29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
||||
36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
||||
38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
||||
40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
||||
45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
||||
47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
||||
49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
||||
51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
||||
55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
||||
106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
|
||||
108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
|
||||
110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
|
||||
57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
||||
59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
||||
42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
|
||||
61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
||||
63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
||||
65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
||||
67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
|
||||
69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
||||
71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
||||
74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
||||
76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
||||
80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
||||
83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
||||
85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
||||
87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
||||
89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
||||
91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
|
||||
93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
|
||||
95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
|
||||
97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
||||
99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
||||
101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
||||
103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
||||
78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
|
||||
85, // [85:131] is the sub-list for method output_type
|
||||
39, // [39:85] is the sub-list for method input_type
|
||||
39, // [39:39] is the sub-list for extension type_name
|
||||
39, // [39:39] is the sub-list for extension extendee
|
||||
0, // [0:39] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_daemon_proto_init() }
|
||||
@@ -7995,7 +7947,7 @@ func file_daemon_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
|
||||
NumEnums: 4,
|
||||
NumMessages: 111,
|
||||
NumMessages: 110,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -677,25 +677,9 @@ message SystemEvent {
|
||||
Severity severity = 2;
|
||||
Category category = 3;
|
||||
string message = 4;
|
||||
// userMessage is the daemon's English rendering of messageKey, kept for the
|
||||
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
|
||||
// and treat this as the fallback.
|
||||
string userMessage = 5;
|
||||
google.protobuf.Timestamp timestamp = 6;
|
||||
map<string, string> metadata = 7;
|
||||
// messageKey names a user-facing message in a stable, locale-independent
|
||||
// form. A UI resolves it against its own translation bundle and substitutes
|
||||
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
|
||||
// that carry no user-facing text.
|
||||
string messageKey = 8;
|
||||
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
|
||||
// {"version": "0.60.1"} for a "{version}" template. Values are data the
|
||||
// daemon cannot localise (versions, addresses, error text).
|
||||
map<string, string> messageArgs = 9;
|
||||
// titleKey names the notification title in the same way as messageKey. Empty
|
||||
// when the event has no dedicated title, in which case a UI composes one from
|
||||
// severity and category.
|
||||
string titleKey = 10;
|
||||
}
|
||||
|
||||
message GetEventsRequest {}
|
||||
|
||||
@@ -43,10 +43,10 @@ const (
|
||||
// UIs to re-fetch their cached config + features. UserMessage is empty so
|
||||
// the change is silent; the source is carried in MetadataSourceKey.
|
||||
MetadataTypeConfigChanged = "config_changed"
|
||||
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. It is
|
||||
// the user-facing half of the pair: the daemon stamps it with the message
|
||||
// and title keys a UI localises, while the paired config_changed event stays
|
||||
// silent and only drives the cache refresh.
|
||||
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. The
|
||||
// daemon stamps it with a (non-localised) UserMessage; the UI suppresses
|
||||
// that and builds its own localised toast off the paired config_changed
|
||||
// event instead.
|
||||
MetadataTypePolicyApplied = "policy_applied"
|
||||
|
||||
// MetadataSourceKey is the SystemEvent.metadata key carrying what
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UserMessageKey names a piece of user-facing SystemEvent text in a
|
||||
// locale-independent form. The daemon has no notion of the user's language, so it
|
||||
// publishes the key and lets each UI resolve it.
|
||||
//
|
||||
// The value doubles as the lookup key in the desktop UI's translation bundles
|
||||
// (client/ui/i18n/locales/<code>/common.json), so renaming a constant here means
|
||||
// renaming the key in every bundle. The tests in client/ui/i18n lock the two
|
||||
// sides together. Keys that predate this mechanism keep their original bundle
|
||||
// names so the shipped translations still apply.
|
||||
type UserMessageKey string
|
||||
|
||||
// Message-body keys published by the daemon. Every key needs an entry in
|
||||
// UserMessageTexts below and a translation in the UI bundles.
|
||||
const (
|
||||
// UserMsgPanic is the CRITICAL event published from the recover() guard in
|
||||
// the connect loop.
|
||||
UserMsgPanic UserMessageKey = "event.panic"
|
||||
|
||||
// UserMsgDNSRecovered and UserMsgDNSUnreachable bracket a nameserver
|
||||
// group's health transitions.
|
||||
UserMsgDNSRecovered UserMessageKey = "event.dns.recovered"
|
||||
UserMsgDNSUnreachable UserMessageKey = "event.dns.unreachable"
|
||||
|
||||
// Exit-node (default route) transitions.
|
||||
UserMsgExitNodeConnected UserMessageKey = "event.exitNode.connected"
|
||||
UserMsgExitNodeDisconnected UserMessageKey = "event.exitNode.disconnected"
|
||||
UserMsgExitNodeConnectionLost UserMessageKey = "event.exitNode.connectionLost"
|
||||
UserMsgExitNodeHAChange UserMessageKey = "event.exitNode.haChange"
|
||||
UserMsgExitNodeDisconnectedUnknown UserMessageKey = "event.exitNode.disconnectedUnknown"
|
||||
|
||||
// Auto-update lifecycle. UserMsgUpdateFailed takes a {reason} argument and
|
||||
// UserMsgUpdateCompleted a {version}.
|
||||
UserMsgUpdateInstalling UserMessageKey = "event.update.installing"
|
||||
UserMsgUpdateCompleted UserMessageKey = "event.update.completed"
|
||||
UserMsgUpdateFailed UserMessageKey = "event.update.failed"
|
||||
|
||||
// UserMsgMDMPolicyApplied reports that an MDM policy replaced the config.
|
||||
UserMsgMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.body"
|
||||
|
||||
// Session-expiry events. UserMsgSessionExpiresIn takes a {remaining}
|
||||
// argument; the "soon" variant is published when the deadline has already
|
||||
// passed by the time the warning fires.
|
||||
UserMsgSessionExpiresIn UserMessageKey = "notify.sessionWarning.body"
|
||||
UserMsgSessionExpiresSoon UserMessageKey = "notify.sessionWarning.bodyGeneric"
|
||||
UserMsgSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.body"
|
||||
)
|
||||
|
||||
// Notification-title keys. An event without one leaves titleKey empty and the UI
|
||||
// composes a title from severity and category, which is what every event did
|
||||
// before message keys existed. These carry no English fallback: a UI old enough
|
||||
// to ignore titleKey already builds its own title.
|
||||
const (
|
||||
TitleMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.title"
|
||||
TitleSessionWarning UserMessageKey = "notify.sessionWarning.title"
|
||||
TitleSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.title"
|
||||
)
|
||||
|
||||
// UserMessageTitleKeys lists every title key a daemon can publish. It sits next
|
||||
// to the constants above because the two must grow together: a title key missing
|
||||
// from this slice ships untranslated, and the i18n test that would have caught it
|
||||
// reads this list.
|
||||
var UserMessageTitleKeys = []UserMessageKey{
|
||||
TitleMDMPolicyApplied,
|
||||
TitleSessionWarning,
|
||||
TitleSessionDeadlineReject,
|
||||
}
|
||||
|
||||
// ArgReason, ArgVersion and ArgRemaining are the placeholder names used by the
|
||||
// templates below. Producers pass them to NewUserMessage; the UI bundles use the
|
||||
// same names inside {}.
|
||||
const (
|
||||
ArgReason = "reason"
|
||||
ArgVersion = "version"
|
||||
ArgRemaining = "remaining"
|
||||
)
|
||||
|
||||
// UserMessageTexts is the English rendering of every body key, and the source of
|
||||
// the SystemEvent.userMessage fallback that the CLI and pre-messageKey UIs read.
|
||||
// It must match the en bundle, which the i18n test enforces.
|
||||
var UserMessageTexts = map[UserMessageKey]string{
|
||||
UserMsgPanic: "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
|
||||
UserMsgDNSRecovered: "DNS servers are reachable again.",
|
||||
UserMsgDNSUnreachable: "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
|
||||
UserMsgExitNodeConnected: "Exit node connected.",
|
||||
UserMsgExitNodeDisconnected: "Exit node disconnected.",
|
||||
UserMsgExitNodeConnectionLost: "Exit node connection lost. Your internet access might be affected.",
|
||||
UserMsgExitNodeHAChange: "Exit node disconnected due to high availability change.",
|
||||
UserMsgExitNodeDisconnectedUnknown: "Exit node disconnected for unknown reasons.",
|
||||
UserMsgUpdateInstalling: "Installing update now.",
|
||||
UserMsgUpdateCompleted: "Your NetBird client was auto-updated to version {version}.",
|
||||
UserMsgUpdateFailed: "Auto-update failed: {reason}",
|
||||
UserMsgMDMPolicyApplied: "Your NetBird configuration was updated by your IT policy.",
|
||||
UserMsgSessionExpiresIn: "Your NetBird session expires in {remaining}. Click Extend now to renew.",
|
||||
UserMsgSessionExpiresSoon: "Your NetBird session is about to expire. Click Extend now to renew.",
|
||||
UserMsgSessionDeadlineReject: "The server sent an invalid session deadline. Please sign in again.",
|
||||
}
|
||||
|
||||
// UserMessage is a localizable user-facing event message: a stable body key, an
|
||||
// optional title key, and the placeholder values to substitute. A nil
|
||||
// *UserMessage carries no user-facing text, which is how internal control events
|
||||
// are published.
|
||||
type UserMessage struct {
|
||||
key UserMessageKey
|
||||
title UserMessageKey
|
||||
args map[string]string
|
||||
}
|
||||
|
||||
// NewUserMessage builds a UserMessage from key and flat placeholder name/value
|
||||
// pairs, e.g. NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"). An
|
||||
// unpaired trailing argument is dropped.
|
||||
func NewUserMessage(key UserMessageKey, args ...string) *UserMessage {
|
||||
m := &UserMessage{key: key}
|
||||
if len(args)%2 != 0 {
|
||||
log.Debugf("user message %q: placeholder args not paired: %d items, last dropped", key, len(args))
|
||||
args = args[:len(args)-1]
|
||||
}
|
||||
if len(args) > 0 {
|
||||
m.args = make(map[string]string, len(args)/2)
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
m.args[args[i]] = args[i+1]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WithTitle attaches a notification title key and returns m, so it chains onto
|
||||
// NewUserMessage.
|
||||
func (m *UserMessage) WithTitle(key UserMessageKey) *UserMessage {
|
||||
m.title = key
|
||||
return m
|
||||
}
|
||||
|
||||
// Key returns the body key, or the empty key for a nil message.
|
||||
func (m *UserMessage) Key() UserMessageKey {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.key
|
||||
}
|
||||
|
||||
// TitleKey returns the title key, which is empty unless WithTitle was called.
|
||||
func (m *UserMessage) TitleKey() UserMessageKey {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.title
|
||||
}
|
||||
|
||||
// Args returns the placeholder values, or nil for a nil message. The map is the
|
||||
// message's own and must not be mutated by the caller; it is handed straight to
|
||||
// SystemEvent.messageArgs.
|
||||
func (m *UserMessage) Args() map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return m.args
|
||||
}
|
||||
|
||||
// Text renders the English fallback for m, with placeholders substituted. A nil
|
||||
// message, or a key with no registered template, renders empty so a UI treats
|
||||
// the event as carrying no user-facing text.
|
||||
func (m *UserMessage) Text() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
tmpl, ok := UserMessageTexts[m.key]
|
||||
if !ok {
|
||||
log.Warnf("no English template for user message key %q", m.key)
|
||||
return ""
|
||||
}
|
||||
for name, value := range m.args {
|
||||
tmpl = strings.ReplaceAll(tmpl, "{"+name+"}", value)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserMessageText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *UserMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no placeholders",
|
||||
msg: NewUserMessage(UserMsgExitNodeConnected),
|
||||
want: "Exit node connected.",
|
||||
},
|
||||
{
|
||||
name: "placeholder substituted",
|
||||
msg: NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"),
|
||||
want: "Your NetBird client was auto-updated to version 0.60.1.",
|
||||
},
|
||||
{
|
||||
// A dangling arg is a caller mistake; dropping it must still yield a
|
||||
// readable sentence rather than panicking on the wire path.
|
||||
name: "unpaired trailing arg dropped",
|
||||
msg: NewUserMessage(UserMsgUpdateFailed, ArgReason, "disk full", "extra"),
|
||||
want: "Auto-update failed: disk full",
|
||||
},
|
||||
{
|
||||
name: "unknown key renders empty so the UI treats the event as silent",
|
||||
msg: NewUserMessage("event.notRegistered"),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil message carries no text",
|
||||
msg: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, tc.msg.Text())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A nil *UserMessage is how every internal control event is published, so the
|
||||
// accessors must stay usable without a nil check at each of the ~25 call sites.
|
||||
func TestNilUserMessageAccessors(t *testing.T) {
|
||||
var msg *UserMessage
|
||||
|
||||
assert.Empty(t, msg.Key(), "nil message must have no body key")
|
||||
assert.Empty(t, msg.TitleKey(), "nil message must have no title key")
|
||||
assert.Nil(t, msg.Args(), "nil message must have no args")
|
||||
assert.Empty(t, msg.Text(), "nil message must render empty")
|
||||
}
|
||||
|
||||
func TestUserMessageArgsAndTitle(t *testing.T) {
|
||||
msg := NewUserMessage(UserMsgSessionExpiresIn, ArgRemaining, "10m").
|
||||
WithTitle(TitleSessionWarning)
|
||||
|
||||
assert.Equal(t, UserMsgSessionExpiresIn, msg.Key())
|
||||
assert.Equal(t, TitleSessionWarning, msg.TitleKey())
|
||||
assert.Equal(t, map[string]string{ArgRemaining: "10m"}, msg.Args())
|
||||
assert.Equal(t, "Your NetBird session expires in 10m. Click Extend now to renew.", msg.Text())
|
||||
}
|
||||
|
||||
// Every registered template must be reachable: a key whose text is empty would
|
||||
// publish an event with a key but no fallback for the CLI and older UIs.
|
||||
func TestUserMessageTextsAreNonEmpty(t *testing.T) {
|
||||
texts := UserMessageTexts
|
||||
assert.NotEmpty(t, texts, "the catalog must not be empty")
|
||||
|
||||
for key, text := range texts {
|
||||
assert.NotEmpty(t, text, "message key %q has an empty template", key)
|
||||
assert.NotEmpty(t, key, "the catalog must not contain an empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageTitleKeysAreUnique(t *testing.T) {
|
||||
seen := make(map[UserMessageKey]struct{})
|
||||
for _, key := range UserMessageTitleKeys {
|
||||
assert.NotEmpty(t, key, "title keys must not be empty")
|
||||
_, dup := seen[key]
|
||||
assert.False(t, dup, "title key %q listed twice", key)
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
110
client/server/login_outcome_test.go
Normal file
110
client/server/login_outcome_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A login that never reached Management is not a decision about the peer's
|
||||
// credentials, so it must come back as a retryable error rather than an SSO
|
||||
// prompt: the user cannot finish a browser login while Management is down, and
|
||||
// the CLI's own backoff resolves the outage on its own once the daemon reports
|
||||
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
|
||||
// because Management happened to be restarting when the daemon dialed it.
|
||||
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
unreachable := errors.New("create connection: dial context: context deadline exceeded")
|
||||
attempts := 0
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
attempts++
|
||||
return false, unreachable
|
||||
}
|
||||
|
||||
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
|
||||
require.Nil(t, resp, "a failed login must not answer with a login response")
|
||||
require.Equal(t, 1, attempts)
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
|
||||
|
||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, internal.StatusLoginFailed, status,
|
||||
"a peer that could not reach Management is not waiting on a login")
|
||||
}
|
||||
|
||||
// The counterpart: Management refusing the peer's credentials is a decision, and
|
||||
// the SSO flow still has to start for it. The profile carries an unusable
|
||||
// private key so the flow setup fails immediately instead of dialing, which is
|
||||
// enough to show the branch was entered — the refusal itself is never what comes
|
||||
// back out.
|
||||
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
|
||||
s, _, _, username, cfgPath := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
breakProfilePrivateKey(t, cfgPath)
|
||||
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
|
||||
require.Error(t, err)
|
||||
|
||||
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, stateErr)
|
||||
require.Equal(t, internal.StatusLoginFailed, status,
|
||||
"the SSO flow setup was never reached with the broken key")
|
||||
}
|
||||
|
||||
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
|
||||
s, _, _, username, _ := setupServerWithProfile(t)
|
||||
s.rootCtx = internal.CtxInitState(context.Background())
|
||||
|
||||
s.isLoginRequiredFn = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var keysTried []string
|
||||
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
|
||||
keysTried = append(keysTried, setupKey)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
|
||||
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
|
||||
require.NoError(t, err, "the probe's outcome leaked out as the login result")
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
|
||||
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
|
||||
|
||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, internal.StatusIdle, status)
|
||||
}
|
||||
|
||||
// breakProfilePrivateKey replaces the profile's private key with an unparseable
|
||||
// one, which makes any attempt to build a Management client fail on the spot.
|
||||
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg))
|
||||
cfg["PrivateKey"] = "not-a-key"
|
||||
|
||||
patched, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
|
||||
}
|
||||
@@ -94,12 +94,12 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
|
||||
// publishConfigChangedEvent has already fired inside
|
||||
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
|
||||
// user-visible toast so the operator knows their IT policy was
|
||||
// applied; the message and title keys let the GUI localise it.
|
||||
// applied (UserMessage != "" triggers the GUI notifier).
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"MDM policy applied",
|
||||
proto.NewUserMessage(proto.UserMsgMDMPolicyApplied).WithTitle(proto.TitleMDMPolicyApplied),
|
||||
"NetBird configuration was updated by your IT policy.",
|
||||
map[string]string{
|
||||
proto.MetadataSourceKey: proto.MetadataSourceMDM,
|
||||
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
|
||||
@@ -126,7 +126,7 @@ func (s *Server) publishConfigChangedEvent(source string) {
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
fmt.Sprintf("daemon config changed (source=%s)", source),
|
||||
nil,
|
||||
"",
|
||||
map[string]string{
|
||||
proto.MetadataSourceKey: source,
|
||||
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
|
||||
|
||||
@@ -170,7 +170,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Network selection changed",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{
|
||||
"networks": strings.Join(req.GetNetworkIDs(), ", "),
|
||||
"append": fmt.Sprint(req.GetAppend()),
|
||||
@@ -214,7 +214,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Network deselection changed",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{
|
||||
"networks": strings.Join(req.GetNetworkIDs(), ", "),
|
||||
"append": fmt.Sprint(req.GetAppend()),
|
||||
@@ -232,3 +232,4 @@ func toNetIDs(routes []string) []route.NetID {
|
||||
}
|
||||
return netIDs
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,13 @@ type Server struct {
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtCache
|
||||
|
||||
// loginAttemptFn stands in for the Management login round trip. Tests set
|
||||
// it to drive the login outcomes that need a server on the other end;
|
||||
// production leaves it nil, and every login goes through loginAttempt.
|
||||
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
||||
|
||||
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
@@ -370,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
||||
// attemptLogin runs a login round trip against Management, or the stand-in a
|
||||
// test installed in place of it.
|
||||
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||
if s.loginAttemptFn != nil {
|
||||
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
||||
}
|
||||
return s.loginAttempt(ctx, setupKey, jwtToken)
|
||||
}
|
||||
|
||||
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
|
||||
if s.isLoginRequiredFn != nil {
|
||||
return s.isLoginRequiredFn(ctx)
|
||||
}
|
||||
|
||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create auth client: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
return authClient.IsLoginRequired(ctx)
|
||||
}
|
||||
|
||||
// loginAttempt attempts to login using the provided information. It returns
|
||||
// StatusNeedsLogin when Management refused the peer's credentials and
|
||||
// StatusLoginFailed for every other failure, so callers can tell an
|
||||
// authentication decision apart from a login that never got made.
|
||||
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
||||
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
||||
if err != nil {
|
||||
@@ -623,7 +657,19 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
s.config = config
|
||||
s.mutex.Unlock()
|
||||
|
||||
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
|
||||
// A probe that errors leaves the login undecided: Management unreachable, a
|
||||
// restart mid-request, an internal error. Those are returned for the caller
|
||||
// to retry, because turning them into an SSO prompt asks the user to solve
|
||||
// something that is not theirs to solve, and a browser login cannot succeed
|
||||
// while Management is unreachable anyway. Only Management refusing the
|
||||
// peer's key is a decision, and IsLoginRequired reports that as
|
||||
// needsLogin=true rather than an error.
|
||||
needsLogin, err := s.isLoginRequired(ctx)
|
||||
if err != nil {
|
||||
state.Set(internal.StatusLoginFailed)
|
||||
return nil, err
|
||||
}
|
||||
if !needsLogin {
|
||||
state.Set(internal.StatusIdle)
|
||||
return &proto.LoginResponse{}, nil
|
||||
}
|
||||
@@ -684,7 +730,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
// which returns NeedsLogin and parks on the browser leg.
|
||||
state.Set(internal.StatusConnecting)
|
||||
|
||||
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
|
||||
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
}
|
||||
@@ -839,7 +885,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
s.oauthAuthFlow.expiresAt = time.Now()
|
||||
s.mutex.Unlock()
|
||||
|
||||
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
||||
state.Set(loginStatus)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1769,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
||||
}
|
||||
if connectClient.Engine() == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
|
||||
}
|
||||
|
||||
hint := ""
|
||||
if msg.Hint != nil {
|
||||
@@ -2181,7 +2230,7 @@ func (s *Server) publishProfileListChanged(profileName string) {
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Profile list changed",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
|
||||
)
|
||||
}
|
||||
@@ -2200,7 +2249,7 @@ func (s *Server) publishLogLevelChanged(level string) {
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"Log level changed",
|
||||
nil,
|
||||
"",
|
||||
map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,17 +26,17 @@ contents:
|
||||
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
depends:
|
||||
- libgtk-4-1
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
- libwebkitgtk-6.0-4
|
||||
- xdg-utils
|
||||
|
||||
# Distribution-specific overrides for different package formats
|
||||
overrides:
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
|
||||
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
|
||||
rpm:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
|
||||
- xdg-utils
|
||||
|
||||
# Arch Linux packages
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
result: {
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
userCode: string;
|
||||
profileId: string;
|
||||
},
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -56,7 +61,7 @@ async function runSsoLogin(
|
||||
// suspended, so a frontend-driven Up (a promise continuation) would not
|
||||
// fire until the user woke the window (e.g. hovering the tray icon).
|
||||
const waitPromise = Connection.WaitSSOLoginAndUp(
|
||||
{ userCode: result.userCode, hostname: "" },
|
||||
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
|
||||
{ profileName: "", username: "" },
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
|
||||
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const authenticate = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||
await WindowManager.CloseSessionExpiration();
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={stay}
|
||||
onClick={expired ? authenticate : stay}
|
||||
disabled={busy}
|
||||
>
|
||||
{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 { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +20,7 @@ export function SettingsGeneral() {
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
|
||||
useManagementUrl();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
@@ -57,6 +59,15 @@ export function SettingsGeneral() {
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
<FancyToggleSwitch
|
||||
value={keepConnected ?? false}
|
||||
onChange={(v) => {
|
||||
void setKeepConnectedOnQuit(v);
|
||||
}}
|
||||
loading={keepConnected === null}
|
||||
label={t("settings.general.keepConnectedOnQuit.label")}
|
||||
helpText={t("settings.general.keepConnectedOnQuit.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
|
||||
@@ -40,12 +40,6 @@ i18n/locales/<code>/common.json a target — message only
|
||||
|
||||
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
|
||||
|
||||
The `event.*` keys are a special group: the background service names them when it
|
||||
publishes a notification, and the app looks them up here. Their names are part of
|
||||
a Go↔JSON contract (`client/proto/usermsg.go`), so they are even less renameable
|
||||
than the rest — and a missing one shows the user English. Tests fail the build if
|
||||
any locale drops one.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
|
||||
|
||||
@@ -126,29 +126,18 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
|
||||
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
|
||||
// the default language, then to the key itself so a miss is visible in the UI.
|
||||
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
|
||||
if v, ok := b.Lookup(lang, key, args...); ok {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Lookup resolves key like Translate but reports whether it was found in the
|
||||
// requested or the default bundle. Callers holding a better fallback than the
|
||||
// raw key — a daemon-supplied English string for a key this build predates —
|
||||
// use this to tell a miss from a hit.
|
||||
func (b *Bundle) Lookup(lang LanguageCode, key string, args ...string) (string, bool) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if v, ok := b.bundles[lang][key]; ok {
|
||||
return applyPlaceholders(v, args), true
|
||||
return applyPlaceholders(v, args)
|
||||
}
|
||||
if lang != DefaultLanguage {
|
||||
if v, ok := b.bundles[DefaultLanguage][key]; ok {
|
||||
return applyPlaceholders(v, args), true
|
||||
return applyPlaceholders(v, args)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return key
|
||||
}
|
||||
|
||||
// applyPlaceholders substitutes {name} in s using args as flat name/value
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// shippedBundle loads the real locale tree rather than the fstest fixture the
|
||||
// other tests use: these checks exist to catch a key the daemon publishes but no
|
||||
// bundle translates, which only the shipped files can prove.
|
||||
func shippedBundle(t *testing.T) *Bundle {
|
||||
t.Helper()
|
||||
b, err := NewBundle(os.DirFS("locales"))
|
||||
require.NoError(t, err, "the shipped locale tree must load")
|
||||
return b
|
||||
}
|
||||
|
||||
// The daemon publishes a message key and each UI resolves it locally, so a key
|
||||
// with no en entry degrades to the daemon's English fallback and silently stops
|
||||
// being translatable. Fail the build instead.
|
||||
func TestUserMessageKeysExistInEnglishBundle(t *testing.T) {
|
||||
b := shippedBundle(t)
|
||||
|
||||
for key, text := range proto.UserMessageTexts {
|
||||
got, ok := b.Lookup(DefaultLanguage, string(key))
|
||||
if !assert.True(t, ok, "message key %q has no en translation", key) {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, text, got,
|
||||
"en translation of %q must match the daemon's English fallback", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageTitleKeysExistInEnglishBundle(t *testing.T) {
|
||||
b := shippedBundle(t)
|
||||
|
||||
for _, key := range proto.UserMessageTitleKeys {
|
||||
_, ok := b.Lookup(DefaultLanguage, string(key))
|
||||
assert.True(t, ok, "title key %q has no en translation", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Every shipped locale must translate the daemon's keys, not just en. A missing
|
||||
// one still renders (Lookup falls back to en) but the notification would show up
|
||||
// in English for that user, which is the bug this whole mechanism exists to fix.
|
||||
func TestUserMessageKeysTranslatedInEveryLanguage(t *testing.T) {
|
||||
b := shippedBundle(t)
|
||||
|
||||
keys := make([]proto.UserMessageKey, 0, len(proto.UserMessageTexts))
|
||||
for key := range proto.UserMessageTexts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
keys = append(keys, proto.UserMessageTitleKeys...)
|
||||
|
||||
for _, lang := range b.Languages() {
|
||||
bundle, err := b.BundleFor(lang.Code)
|
||||
require.NoError(t, err, "BundleFor(%q)", lang.Code)
|
||||
|
||||
for _, key := range keys {
|
||||
text, ok := bundle[string(key)]
|
||||
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
|
||||
continue
|
||||
}
|
||||
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tray composes a title from these when an event carries no title key, so a
|
||||
// gap here would render "event.severity.warning: DNS" to the user.
|
||||
func TestEventTitleKeysTranslatedInEveryLanguage(t *testing.T) {
|
||||
b := shippedBundle(t)
|
||||
|
||||
keys := []string{
|
||||
"event.title",
|
||||
"event.severity.info", "event.severity.warning",
|
||||
"event.severity.error", "event.severity.critical",
|
||||
"event.category.network", "event.category.dns",
|
||||
"event.category.authentication", "event.category.connectivity",
|
||||
"event.category.system",
|
||||
}
|
||||
|
||||
for _, lang := range b.Languages() {
|
||||
bundle, err := b.BundleFor(lang.Code)
|
||||
require.NoError(t, err, "BundleFor(%q)", lang.Code)
|
||||
|
||||
for _, key := range keys {
|
||||
text, ok := bundle[key]
|
||||
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
|
||||
continue
|
||||
}
|
||||
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
|
||||
}
|
||||
}
|
||||
|
||||
// The composed title is useless without both slots.
|
||||
title, ok := b.Lookup(DefaultLanguage, "event.title", "severity", "Warning", "category", "DNS")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Warning: DNS", title, "event.title must substitute both placeholders")
|
||||
}
|
||||
|
||||
func TestBundleLookupReportsMisses(t *testing.T) {
|
||||
b, err := NewBundle(fakeLocales())
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := b.Lookup("en", "tray.menu.connect")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Connect", got)
|
||||
|
||||
// An absent key must report a miss rather than echo the key, so callers can
|
||||
// substitute their own fallback.
|
||||
got, ok = b.Lookup("en", "tray.missing")
|
||||
assert.False(t, ok, "unknown key must report a miss")
|
||||
assert.Empty(t, got, "a miss must not return the key")
|
||||
|
||||
// Empty keys reach Lookup from events that carry no title key at all.
|
||||
_, ok = b.Lookup("en", "")
|
||||
assert.False(t, ok, "empty key must report a miss")
|
||||
}
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Info"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Warnung"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Fehler"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Kritisch"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Netzwerk"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Authentifizierung"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Konnektivität"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "System"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "Der NetBird-Dienst ist abgestürzt. Bitte starten Sie den Dienst neu und senden Sie einen Fehlerbericht mit den Client-Protokollen."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "DNS-Server sind wieder erreichbar."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Ein oder mehrere DNS-Server sind nicht erreichbar. Das kann die Verbindung zu einigen Diensten beeinträchtigen."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Exit Node verbunden."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Exit Node getrennt."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Verbindung zum Exit Node verloren. Ihr Internetzugang kann beeinträchtigt sein."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Exit Node aufgrund einer Änderung der Hochverfügbarkeit getrennt."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Exit Node aus unbekannten Gründen getrennt."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Update wird jetzt installiert."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Ihr NetBird-Client wurde automatisch auf Version {version} aktualisiert."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Automatisches Update fehlgeschlagen: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Abbrechen"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Ändern des Autostarts fehlgeschlagen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Nach dem Beenden verbunden bleiben",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Anzeigesprache"
|
||||
},
|
||||
|
||||
@@ -239,90 +239,6 @@
|
||||
"message": "Your NetBird configuration was updated by your IT policy.",
|
||||
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}",
|
||||
"description": "Notification title for a daemon event, composed from severity and category, e.g. \"Warning: DNS\". Keep both placeholders; use your locale's colon spacing."
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Info",
|
||||
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Warning",
|
||||
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Error",
|
||||
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Critical",
|
||||
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Network",
|
||||
"description": "Event category label used in the {category} slot of event.title. Refers to the overlay network."
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS",
|
||||
"description": "Event category label used in the {category} slot of event.title. Acronym, do not translate."
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Authentication",
|
||||
"description": "Event category label used in the {category} slot of event.title. Refers to signing in to the management server."
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Connectivity",
|
||||
"description": "Event category label used in the {category} slot of event.title. Refers to reaching peers."
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "System",
|
||||
"description": "Event category label used in the {category} slot of event.title. Refers to the local machine and the NetBird service."
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
|
||||
"description": "Notification body after the NetBird background service crashed. \"Service\" is the daemon, not a remote service."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "DNS servers are reachable again.",
|
||||
"description": "Notification body when previously unreachable upstream DNS servers respond again."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
|
||||
"description": "Notification body when one or more upstream DNS servers stop responding."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Exit node connected.",
|
||||
"description": "Notification body when a full-tunnel exit node becomes active."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Exit node disconnected.",
|
||||
"description": "Notification body when the user or the client shuts the exit node down deliberately."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Exit node connection lost. Your internet access might be affected.",
|
||||
"description": "Notification body when the exit node peer became unreachable. \"Internet access\" means the user's own browsing."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Exit node disconnected due to high availability change.",
|
||||
"description": "Notification body when a high-availability group switched away from this exit node. High availability is the standard IT term."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Exit node disconnected for unknown reasons.",
|
||||
"description": "Notification body when the exit node dropped for a reason the client could not classify."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Installing update now.",
|
||||
"description": "Notification body shown as an automatic client update starts installing."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Your NetBird client was auto-updated to version {version}.",
|
||||
"description": "Notification body after an automatic client update succeeded. {version} is a version number, keep verbatim."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Auto-update failed: {reason}",
|
||||
"description": "Notification body when an automatic client update failed. {reason} is an untranslated technical error string; keep your locale's colon spacing."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancel",
|
||||
"description": "Generic Cancel button label, reused across dialogs. Keep short."
|
||||
@@ -819,6 +735,14 @@
|
||||
"message": "Autostart Change Failed",
|
||||
"description": "Error-dialog title when changing the autostart setting fails."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Stay Connected After Quitting",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Display Language",
|
||||
"description": "Label for the display-language picker."
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Su configuración de NetBird fue actualizada por su política de TI."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Información"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Advertencia"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Error"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Crítico"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Red"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Autenticación"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Conectividad"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "El servicio de NetBird falló de forma inesperada. Reinicie el servicio y envíe un informe de error con los registros del cliente."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "Los servidores DNS vuelven a estar accesibles."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "No se puede acceder a uno o más servidores DNS. Esto puede afectar la conexión a algunos servicios."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Nodo de salida conectado."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Nodo de salida desconectado."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Se perdió la conexión con el nodo de salida. Su acceso a Internet puede verse afectado."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Nodo de salida desconectado por un cambio de alta disponibilidad."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Nodo de salida desconectado por motivos desconocidos."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Instalando la actualización ahora."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Su cliente de NetBird se actualizó automáticamente a la versión {version}."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "La actualización automática falló: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Error al cambiar el inicio automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado al salir",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de la interfaz"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity} : {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Info"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Avertissement"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Erreur"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Critique"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Réseau"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Authentification"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Connectivité"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Système"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "Le service NetBird s'est arrêté brutalement. Veuillez redémarrer le service et envoyer un rapport de bug avec les journaux du client."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "Les serveurs DNS sont de nouveau joignables."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Impossible de joindre un ou plusieurs serveurs DNS. Cela peut affecter la connexion à certains services."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Nœud de sortie connecté."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Nœud de sortie déconnecté."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Connexion au nœud de sortie perdue. Votre accès à Internet peut être affecté."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Nœud de sortie déconnecté suite à un changement de haute disponibilité."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Nœud de sortie déconnecté pour une raison inconnue."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Installation de la mise à jour en cours."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Votre client NetBird a été mis à jour automatiquement vers la version {version}."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Échec de la mise à jour automatique : {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Annuler"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Échec de la modification du démarrage automatique"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Rester connecté après la fermeture",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Langue d’affichage"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Információ"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Figyelmeztetés"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Hiba"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Kritikus"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Hálózat"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Hitelesítés"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Kapcsolat"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Rendszer"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "A NetBird szolgáltatás összeomlott. Kérjük, indítsa újra a szolgáltatást, és küldjön hibajelentést a kliens naplóival."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "A DNS-kiszolgálók ismét elérhetők."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Egy vagy több DNS-kiszolgáló nem érhető el. Ez befolyásolhatja egyes szolgáltatások elérését."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Exit Node csatlakoztatva."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Exit Node leválasztva."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Megszakadt a kapcsolat az Exit Node-dal. Ez érintheti az internetelérést."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Az Exit Node leválasztva a magas rendelkezésre állás változása miatt."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Az Exit Node ismeretlen okból leválasztva."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "A frissítés telepítése folyamatban."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "A NetBird kliens automatikusan a {version} verzióra frissült."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Az automatikus frissítés sikertelen: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Mégse"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Az automatikus indítás módosítása sikertelen"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Kapcsolat megtartása kilépéskor",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Megjelenítési nyelv"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Info"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Avviso"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Errore"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Critico"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Rete"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Autenticazione"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Connettività"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "Il servizio NetBird si è arrestato in modo anomalo. Riavvii il servizio e invii una segnalazione di bug con i log del client."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "I server DNS sono di nuovo raggiungibili."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Impossibile raggiungere uno o più server DNS. Questo potrebbe influire sulla connessione ad alcuni servizi."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Nodo di uscita connesso."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Nodo di uscita disconnesso."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Connessione al nodo di uscita perduta. L'accesso a Internet potrebbe essere compromesso."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Nodo di uscita disconnesso a causa di una modifica dell'alta disponibilità."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Nodo di uscita disconnesso per motivi sconosciuti."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Installazione dell'aggiornamento in corso."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Il client NetBird è stato aggiornato automaticamente alla versione {version}."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Aggiornamento automatico non riuscito: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Annulla"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Modifica avvio automatico non riuscita"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Resta connesso dopo la chiusura",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Lingua dell'interfaccia"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "NetBird の構成が IT ポリシーによって更新されました。"
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "情報"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "警告"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "エラー"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "重大"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "ネットワーク"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "認証"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "接続"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "システム"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "NetBird サービスがクラッシュしました。サービスを再起動し、クライアントログを添えてバグを報告してください。"
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "DNS サーバーに再び到達できるようになりました。"
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "1 つ以上の DNS サーバーに到達できません。一部のサービスへの接続に影響する可能性があります。"
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "出口ノードに接続しました。"
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "出口ノードの接続を解除しました。"
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "出口ノードとの接続が失われました。インターネット接続に影響する可能性があります。"
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "高可用性の変更により出口ノードの接続が解除されました。"
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "不明な理由により出口ノードの接続が解除されました。"
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "更新をインストールしています。"
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "NetBird クライアントがバージョン {version} に自動更新されました。"
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "自動更新に失敗しました: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "キャンセル"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "自動起動の変更に失敗しました"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "終了後も接続を維持",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "表示言語"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Informação"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Aviso"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Erro"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Crítico"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Rede"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Autenticação"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Conectividade"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "O serviço NetBird falhou de forma inesperada. Reinicie o serviço e envie um relatório de erro com os registros do cliente."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "Os servidores DNS estão novamente acessíveis."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Não é possível acessar um ou mais servidores DNS. Isto pode afetar a conexão a alguns serviços."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Nó de saída conectado."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Nó de saída desconectado."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Conexão com o nó de saída perdida. O seu acesso à Internet pode ser afetado."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Nó de saída desconectado devido a uma alteração de alta disponibilidade."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Nó de saída desconectado por motivos desconhecidos."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Instalando a atualização agora."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "O seu cliente NetBird foi atualizado automaticamente para a versão {version}."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Falha na atualização automática: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Falha ao alterar o início automático"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Permanecer conectado ao sair",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Idioma de exibição"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}: {category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "Информация"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "Предупреждение"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "Ошибка"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "Критично"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "Сеть"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "Аутентификация"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "Связь"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "Система"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "Служба NetBird аварийно завершилась. Перезапустите службу и отправьте отчёт об ошибке с журналами клиента."
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "DNS-серверы снова доступны."
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "Не удалось связаться с одним или несколькими DNS-серверами. Это может повлиять на подключение к некоторым сервисам."
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "Выходной узел подключён."
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "Выходной узел отключён."
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "Соединение с выходным узлом потеряно. Доступ в интернет может быть нарушен."
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "Выходной узел отключён из-за изменения конфигурации высокой доступности."
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "Выходной узел отключён по неизвестной причине."
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "Устанавливается обновление."
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "Клиент NetBird автоматически обновлён до версии {version}."
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "Не удалось выполнить автоматическое обновление: {reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Отмена"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "Не удалось изменить автозапуск"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "Оставаться подключённым после выхода",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "Язык интерфейса"
|
||||
},
|
||||
|
||||
@@ -179,69 +179,6 @@
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "您的 NetBird 配置已根据 IT 策略更新。"
|
||||
},
|
||||
"event.title": {
|
||||
"message": "{severity}:{category}"
|
||||
},
|
||||
"event.severity.info": {
|
||||
"message": "信息"
|
||||
},
|
||||
"event.severity.warning": {
|
||||
"message": "警告"
|
||||
},
|
||||
"event.severity.error": {
|
||||
"message": "错误"
|
||||
},
|
||||
"event.severity.critical": {
|
||||
"message": "严重"
|
||||
},
|
||||
"event.category.network": {
|
||||
"message": "网络"
|
||||
},
|
||||
"event.category.dns": {
|
||||
"message": "DNS"
|
||||
},
|
||||
"event.category.authentication": {
|
||||
"message": "身份验证"
|
||||
},
|
||||
"event.category.connectivity": {
|
||||
"message": "连接"
|
||||
},
|
||||
"event.category.system": {
|
||||
"message": "系统"
|
||||
},
|
||||
"event.panic": {
|
||||
"message": "NetBird 服务发生崩溃。请重启该服务,并附上客户端日志提交错误报告。"
|
||||
},
|
||||
"event.dns.recovered": {
|
||||
"message": "DNS 服务器已恢复可访问。"
|
||||
},
|
||||
"event.dns.unreachable": {
|
||||
"message": "无法访问一个或多个 DNS 服务器。这可能影响您连接部分服务。"
|
||||
},
|
||||
"event.exitNode.connected": {
|
||||
"message": "出口节点已连接。"
|
||||
},
|
||||
"event.exitNode.disconnected": {
|
||||
"message": "出口节点已断开。"
|
||||
},
|
||||
"event.exitNode.connectionLost": {
|
||||
"message": "与出口节点的连接已丢失。您的互联网访问可能受到影响。"
|
||||
},
|
||||
"event.exitNode.haChange": {
|
||||
"message": "由于高可用性变更,出口节点已断开。"
|
||||
},
|
||||
"event.exitNode.disconnectedUnknown": {
|
||||
"message": "出口节点因未知原因已断开。"
|
||||
},
|
||||
"event.update.installing": {
|
||||
"message": "正在安装更新。"
|
||||
},
|
||||
"event.update.completed": {
|
||||
"message": "NetBird 客户端已自动更新到版本 {version}。"
|
||||
},
|
||||
"event.update.failed": {
|
||||
"message": "自动更新失败:{reason}"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "取消"
|
||||
},
|
||||
@@ -614,6 +551,14 @@
|
||||
"settings.general.autostart.errorTitle": {
|
||||
"message": "更改自启动设置失败"
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.label": {
|
||||
"message": "退出后保持连接",
|
||||
"description": "Toggle label: keep the VPN connection up after quitting the UI."
|
||||
},
|
||||
"settings.general.keepConnectedOnQuit.help": {
|
||||
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
|
||||
"description": "Helper text for the stay-connected-after-quitting toggle."
|
||||
},
|
||||
"settings.general.language.label": {
|
||||
"message": "显示语言"
|
||||
},
|
||||
|
||||
@@ -63,20 +63,6 @@ func (l *Localizer) T(key string, args ...string) string {
|
||||
return l.bundle.Translate(lang, key, args...)
|
||||
}
|
||||
|
||||
// Lookup resolves a key supplied at runtime by the daemon, substituting args as
|
||||
// {placeholder}/value pairs. It reports false when the key is in no bundle, so
|
||||
// the caller can fall back to the daemon's own English text instead of showing a
|
||||
// bare key. An empty key never resolves.
|
||||
func (l *Localizer) Lookup(key string, args map[string]string) (string, bool) {
|
||||
if l == nil || l.bundle == nil || key == "" {
|
||||
return "", false
|
||||
}
|
||||
l.mu.RLock()
|
||||
lang := l.lang
|
||||
l.mu.RUnlock()
|
||||
return l.bundle.Lookup(lang, key, flattenArgs(args)...)
|
||||
}
|
||||
|
||||
// Watch invokes cb on each language change, after the cached language is
|
||||
// updated so cb may call l.T with the new locale. Replaces any prior subscription.
|
||||
func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) {
|
||||
@@ -142,17 +128,3 @@ func (l *Localizer) StatusLabel(status string) string {
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// flattenArgs turns a placeholder map into the flat name/value slice the bundle
|
||||
// takes. Iteration order is irrelevant: each pair substitutes an independent
|
||||
// {name}.
|
||||
func flattenArgs(args map[string]string) []string {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(args)*2)
|
||||
for name, value := range args {
|
||||
out = append(out, name, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
@@ -63,7 +62,7 @@ type registeredServices struct {
|
||||
profiles *services.Profiles
|
||||
update *services.Update
|
||||
daemonFeed *services.DaemonFeed
|
||||
notifier *notifications.NotificationService
|
||||
notifier *Notifier
|
||||
compat *services.Compat
|
||||
profileSwitcher *services.ProfileSwitcher
|
||||
bundle *i18n.Bundle
|
||||
@@ -102,7 +101,7 @@ func main() {
|
||||
updaterHolder := updater.NewHolder(app.Event)
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||
notifier := notifications.New()
|
||||
notifier := newNotifier()
|
||||
compat := services.NewCompat(conn)
|
||||
// macOS shows no toast until permission is requested. Run it after
|
||||
// ApplicationStarted so the notifier's Startup has initialised the
|
||||
@@ -181,6 +180,7 @@ func main() {
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
Preferences: prefStore,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
@@ -210,7 +210,7 @@ func main() {
|
||||
// requestNotificationAuthorization prompts for macOS notification permission.
|
||||
// The request blocks until the user responds (up to 3 minutes), so callers run
|
||||
// it in a goroutine. No-op on Linux/Windows.
|
||||
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
|
||||
func requestNotificationAuthorization(notifier *Notifier) {
|
||||
authorized, err := notifier.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
logrus.Debugf("check notification authorization: %v", err)
|
||||
|
||||
101
client/ui/notifier.go
Normal file
101
client/ui/notifier.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
var errNotificationsUnavailable = errors.New("notifications unavailable")
|
||||
|
||||
// Notifier wraps the Wails notification service so an unavailable backend
|
||||
// disables notifications instead of aborting the app. Startup fails for
|
||||
// environment reasons (a bare unbundled binary on macOS has no bundle
|
||||
// identifier, a headless Linux session has no D-Bus session bus), and Wails
|
||||
// treats a service startup error as fatal. After a failed startup every call
|
||||
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
|
||||
// identifier raises an Objective-C exception that recover() cannot catch.
|
||||
type Notifier struct {
|
||||
inner *notifications.NotificationService
|
||||
available atomic.Bool
|
||||
}
|
||||
|
||||
func newNotifier() *Notifier {
|
||||
return &Notifier{inner: notifications.New()}
|
||||
}
|
||||
|
||||
// ServiceName implements the Wails service-name hook for startup logs.
|
||||
func (n *Notifier) ServiceName() string {
|
||||
return n.inner.ServiceName()
|
||||
}
|
||||
|
||||
// ServiceStartup starts the platform notifier, downgrading failure to a
|
||||
// warning so the app keeps running without notifications.
|
||||
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
if err := n.inner.ServiceStartup(ctx, options); err != nil {
|
||||
log.Warnf("notifications disabled: %v", err)
|
||||
return nil
|
||||
}
|
||||
n.available.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) ServiceShutdown() error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
}
|
||||
return n.inner.ServiceShutdown()
|
||||
}
|
||||
|
||||
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
|
||||
if !n.available.Load() {
|
||||
return false, errNotificationsUnavailable
|
||||
}
|
||||
return n.inner.CheckNotificationAuthorization()
|
||||
}
|
||||
|
||||
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
|
||||
if !n.available.Load() {
|
||||
return false, errNotificationsUnavailable
|
||||
}
|
||||
return n.inner.RequestNotificationAuthorization()
|
||||
}
|
||||
|
||||
// SendNotification delivers a notification, silently dropping it when the
|
||||
// backend never started (notifications are best-effort everywhere).
|
||||
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
|
||||
if !n.available.Load() {
|
||||
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||
return nil
|
||||
}
|
||||
return n.inner.SendNotification(options)
|
||||
}
|
||||
|
||||
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
|
||||
if !n.available.Load() {
|
||||
log.Debugf("notifications disabled, dropping %q", options.ID)
|
||||
return nil
|
||||
}
|
||||
return n.inner.SendNotificationWithActions(options)
|
||||
}
|
||||
|
||||
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
|
||||
if !n.available.Load() {
|
||||
return nil
|
||||
}
|
||||
return n.inner.RegisterNotificationCategory(category)
|
||||
}
|
||||
|
||||
// OnNotificationResponse registers the response callback. Pure Go state, so
|
||||
// it is safe (and simply inert) when the backend never started.
|
||||
//
|
||||
//wails:ignore
|
||||
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
|
||||
n.inner.OnNotificationResponse(callback)
|
||||
}
|
||||
@@ -58,6 +58,10 @@ type UIPreferences struct {
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
// and is never reset, so the default-on flow runs at most once, ever.
|
||||
AutostartInitialized bool `json:"autostartInitialized"`
|
||||
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
|
||||
// Its false zero value preserves the historical disconnect-on-quit
|
||||
// behaviour for preference files written before the field existed.
|
||||
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
|
||||
}
|
||||
|
||||
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
|
||||
@@ -183,6 +187,26 @@ func (s *Store) SetAutostartInitialized(done bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
|
||||
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
|
||||
s.mu.Lock()
|
||||
if s.current.KeepConnectedOnQuit == keep {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.KeepConnectedOnQuit = keep
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
|
||||
if lang == "" {
|
||||
@@ -246,6 +270,7 @@ func (s *Store) ExistedAtLoad() bool {
|
||||
func (s *Store) load() error {
|
||||
if _, err := os.Stat(s.path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Infof("no ui preferences file at %s; using defaults", s.path)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat preferences: %w", err)
|
||||
|
||||
@@ -238,6 +238,42 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
|
||||
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
emitter := &recordingEmitter{}
|
||||
s, err := NewStore(nil, emitter)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
|
||||
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
|
||||
|
||||
require.NoError(t, s.SetKeepConnectedOnQuit(true))
|
||||
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
|
||||
|
||||
reloaded, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
|
||||
}
|
||||
|
||||
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
// A preferences file written before the field existed must keep the
|
||||
// historical disconnect-on-quit behaviour rather than silently opting out.
|
||||
path, err := preferencesPath()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
|
||||
|
||||
s, err := NewStore(nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
|
||||
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
|
||||
}
|
||||
|
||||
func TestStore_ExistedAtLoad(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
|
||||
|
||||
@@ -33,12 +33,21 @@ type LoginResult struct {
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
// ProfileID is the ID of the profile this login ran against, or "" when the
|
||||
// caller named the profile itself and no ID was resolved. Pass it back in
|
||||
// WaitSSOParams so the account email lands on this profile even if the
|
||||
// active one changes during SSO.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to waitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
// ProfileID is the profile the login was started for, used to file the
|
||||
// account email against it rather than against whichever profile is active
|
||||
// when the flow returns. Optional: empty falls back to the active profile.
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
|
||||
// UpParams selects the profile to bring up.
|
||||
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
// Fall back to the daemon's active profile and the current OS user.
|
||||
profileName := p.ProfileName
|
||||
username := p.Username
|
||||
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
|
||||
// a handle — a display name or an ID prefix resolve too — and the state file
|
||||
// is named after the ID, so passing a handle on would name the wrong file.
|
||||
profileID := ""
|
||||
if profileName == "" {
|
||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
profileID = profileName
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
UserCode: resp.GetUserCode(),
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
ProfileID: profileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
log.Infof("SSO login completed, daemon reported success")
|
||||
|
||||
// Persist the account email the same way the CLI does after its own
|
||||
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||
// root and the per-profile state file is user-owned (see Logout below).
|
||||
// Without this the profile has no email, so Profiles.List shows no account
|
||||
// and later logins and session extends go out without a login_hint —
|
||||
// leaving the IdP to guess which account was meant.
|
||||
if email := resp.GetEmail(); email != "" {
|
||||
state := &profilemanager.ProfileState{Email: email}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
|
||||
// Against the profile the login was started for: SSO spans seconds of
|
||||
// user interaction, and a profile switch in that window would otherwise
|
||||
// file the email under the wrong profile.
|
||||
if p.ProfileID != "" {
|
||||
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
|
||||
} else {
|
||||
err = pm.SetActiveProfileState(state)
|
||||
}
|
||||
if err != nil {
|
||||
// Non-fatal: the login itself succeeded.
|
||||
log.Warnf("failed to store account email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,5 +49,11 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
if app == nil || app.Screen == nil {
|
||||
return p, true
|
||||
}
|
||||
// The wails GTK3 backend caches screens from the active window; a tray app
|
||||
// has none at startup, so the cache is empty and PhysicalToDipPoint would
|
||||
// dereference a nil nearest screen. Raw pixels are correct there anyway.
|
||||
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
|
||||
return p, true
|
||||
}
|
||||
return app.Screen.PhysicalToDipPoint(p), true
|
||||
}
|
||||
|
||||
@@ -59,21 +59,13 @@ type Emitter interface {
|
||||
|
||||
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
|
||||
type SystemEvent struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
UserMessage string `json:"userMessage"`
|
||||
// MessageKey names the localizable body for this event; empty on control
|
||||
// events and on events from a daemon that predates the field. Resolve it
|
||||
// against the UI bundle and fall back to UserMessage on a miss.
|
||||
MessageKey string `json:"messageKey"`
|
||||
MessageArgs map[string]string `json:"messageArgs"`
|
||||
// TitleKey names the localizable notification title, empty when the event
|
||||
// has none and the consumer should compose one from severity and category.
|
||||
TitleKey string `json:"titleKey"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
UserMessage string `json:"userMessage"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// PeerStatus is the frontend-facing shape of a daemon PeerState.
|
||||
@@ -571,9 +563,6 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
||||
Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")),
|
||||
Message: e.GetMessage(),
|
||||
UserMessage: e.GetUserMessage(),
|
||||
MessageKey: e.GetMessageKey(),
|
||||
MessageArgs: e.GetMessageArgs(),
|
||||
TitleKey: e.GetTitleKey(),
|
||||
Metadata: map[string]string{},
|
||||
}
|
||||
if ts := e.GetTimestamp(); ts != nil {
|
||||
|
||||
@@ -34,3 +34,7 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
|
||||
return s.store.SetKeepConnectedOnQuit(keep)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"context"
|
||||
"os/user"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
// user-owned state file holding the account email behind (same split as
|
||||
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||
// generated ID, so a recreated profile of the same name would inherit the
|
||||
// deleted one's email and offer it as the login_hint.
|
||||
//
|
||||
// Keyed on the ID the daemon resolved, not on the request handle: that may
|
||||
// have been a display name or an ID prefix, which would name a different
|
||||
// file (or none).
|
||||
if id := resp.GetId(); id != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
|
||||
// Non-fatal: the profile itself is gone.
|
||||
log.Warnf("failed to remove profile state for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDMDMPolicy = "netbird-mdm-policy"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
@@ -43,14 +45,15 @@ type TrayServices struct {
|
||||
Profiles *services.Profiles
|
||||
Networks *services.Networks
|
||||
DaemonFeed *services.DaemonFeed
|
||||
Notifier *notifications.NotificationService
|
||||
Notifier *Notifier
|
||||
Update *services.Update
|
||||
ProfileSwitcher *services.ProfileSwitcher
|
||||
WindowManager *services.WindowManager
|
||||
// Session is bound to authsession directly because the services wrapper
|
||||
// only re-exposes the React subset.
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
Session *authsession.Session
|
||||
Localizer *Localizer
|
||||
Preferences *preferences.Store
|
||||
}
|
||||
|
||||
type Tray struct {
|
||||
@@ -460,10 +463,12 @@ func (t *Tray) handleQuit() {
|
||||
t.profileMu.Unlock()
|
||||
t.svc.DaemonFeed.CancelProfileSwitch()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
||||
log.Errorf("disconnect on quit: %v", err)
|
||||
}
|
||||
}
|
||||
t.app.Quit()
|
||||
}
|
||||
|
||||
@@ -4,17 +4,26 @@ package main
|
||||
|
||||
// bindTrayClick wires the tray icon's left-click handler on Linux.
|
||||
//
|
||||
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
|
||||
// fires the registered clickHandler:
|
||||
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
|
||||
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
|
||||
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
|
||||
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
|
||||
// call itself (xembed_host_linux.go), so it routes through the same hook.
|
||||
// Registering OnClick here therefore covers both paths with one handler — no
|
||||
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
|
||||
// main window; right-click still opens the menu via Wails' default
|
||||
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
|
||||
// Expected behaviour per tray host:
|
||||
//
|
||||
// Host Left click Right click
|
||||
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
|
||||
// GNOME Shell + AppIndicator menu only menu only
|
||||
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
|
||||
//
|
||||
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
|
||||
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
|
||||
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
|
||||
//
|
||||
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
|
||||
// on ANY click and only reports the menu opening via dbusmenu
|
||||
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
|
||||
// both buttons raised the main window on top of the menu, and on KDE/Waybar
|
||||
// a right click raised it over the freshly opened menu. The netbirdio/wails
|
||||
// fork (go.mod replace) drops that heuristic: a menu open never fires
|
||||
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
|
||||
// entry; left-click-opens-window is not achievable there anyway, since the
|
||||
// host always opens the menu itself.
|
||||
//
|
||||
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
|
||||
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's
|
||||
|
||||
@@ -21,14 +21,32 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// config_changed carries no user-facing message, so handle it before the gate below.
|
||||
// config_changed carries no UserMessage, so handle it before the message gate below.
|
||||
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
|
||||
log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey])
|
||||
go t.refreshRestrictions()
|
||||
go t.loadConfig()
|
||||
// MDM gets a localised toast here; the daemon's English "policy_applied"
|
||||
// event is suppressed in shouldSkipSystemEvent. Other sources stay silent.
|
||||
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
|
||||
t.profileMu.Lock()
|
||||
enabled := t.notificationsEnabled
|
||||
t.profileMu.Unlock()
|
||||
if enabled {
|
||||
t.notify(
|
||||
t.loc.T("notify.mdm.policyApplied.title"),
|
||||
t.loc.T("notify.mdm.policyApplied.body"),
|
||||
notifyIDMDMPolicy,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if se.MessageKey == "" && se.UserMessage == "" {
|
||||
// Session-warning and deadline-rejected events build their body locally from
|
||||
// metadata; every other event needs a UserMessage.
|
||||
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
|
||||
isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != ""
|
||||
if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" {
|
||||
return
|
||||
}
|
||||
if shouldSkipSystemEvent(se) {
|
||||
@@ -43,56 +61,56 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
body := t.localizedEventMessage(se)
|
||||
// Session-warning events route via stable metadata flags rather than
|
||||
// category/severity so a daemon-side reword still lands here. Final warning
|
||||
// auto-opens the SessionExpiration dialog with no notification (the dialog is
|
||||
// the last-chance reminder; doubling up would be noise).
|
||||
if isDeadlineRejected {
|
||||
t.notify(
|
||||
t.loc.T("notify.sessionDeadlineRejected.title"),
|
||||
t.loc.T("notify.sessionDeadlineRejected.body"),
|
||||
notifyIDSessionExpired,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The final session warning auto-opens the SessionExpiration dialog instead of
|
||||
// toasting: the dialog is the last-chance reminder and doubling up would be
|
||||
// noise. This routes on metadata rather than the message key because it is a
|
||||
// behavioural distinction, not a wording one.
|
||||
if se.Metadata[authsession.MetaWarning] == "true" {
|
||||
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
|
||||
if se.Metadata[authsession.MetaFinal] == "true" {
|
||||
t.openSessionExpiration()
|
||||
return
|
||||
}
|
||||
t.notifySessionWarning(t.eventTitle(se), body)
|
||||
t.notifySessionWarning(
|
||||
t.loc.T("notify.sessionWarning.title"),
|
||||
t.buildSessionWarningBody(se.Metadata),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
body := se.UserMessage
|
||||
if id := se.Metadata["id"]; id != "" {
|
||||
body += fmt.Sprintf(" ID: %s", id)
|
||||
}
|
||||
t.notify(t.eventTitle(se), body, notifyIDEvent+se.ID)
|
||||
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
|
||||
}
|
||||
|
||||
// localizedEventMessage resolves the daemon's message key against the active
|
||||
// locale. A key this build does not ship — a daemon newer than the UI — falls
|
||||
// back to the daemon's own English rendering rather than showing a bare key.
|
||||
func (t *Tray) localizedEventMessage(se services.SystemEvent) string {
|
||||
if body, ok := t.loc.Lookup(se.MessageKey, se.MessageArgs); ok {
|
||||
return body
|
||||
// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication".
|
||||
func eventTitle(e services.SystemEvent) string {
|
||||
prefix := titleCase(e.Severity)
|
||||
if prefix == "" {
|
||||
prefix = "Info"
|
||||
}
|
||||
if se.MessageKey != "" {
|
||||
log.Debugf("no translation for event message key %q, using the daemon's text", se.MessageKey)
|
||||
category := titleCase(e.Category)
|
||||
if category == "" {
|
||||
category = "System"
|
||||
}
|
||||
return se.UserMessage
|
||||
return prefix + ": " + category
|
||||
}
|
||||
|
||||
// eventTitle resolves the event's own title key, falling back to a title
|
||||
// composed from severity and category, e.g. "Critical: DNS" in English. An enum
|
||||
// value this build does not know falls back to the Info and System labels.
|
||||
func (t *Tray) eventTitle(se services.SystemEvent) string {
|
||||
if title, ok := t.loc.Lookup(se.TitleKey, nil); ok {
|
||||
return title
|
||||
func titleCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
severity, ok := t.loc.Lookup("event.severity."+strings.ToLower(se.Severity), nil)
|
||||
if !ok {
|
||||
severity = t.loc.T("event.severity.info")
|
||||
}
|
||||
category, ok := t.loc.Lookup("event.category."+strings.ToLower(se.Category), nil)
|
||||
if !ok {
|
||||
category = t.loc.T("event.category.system")
|
||||
}
|
||||
return t.loc.T("event.title", "severity", severity, "category", category)
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
|
||||
// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as
|
||||
@@ -101,6 +119,11 @@ func (t *Tray) eventTitle(se services.SystemEvent) string {
|
||||
// - install-progress signals (consumed by the install-progress window)
|
||||
// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted)
|
||||
func shouldSkipSystemEvent(se services.SystemEvent) bool {
|
||||
// "policy_applied" carries a hardcoded English message; the localised toast
|
||||
// fires on the paired config_changed (source=mdm) event instead.
|
||||
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
|
||||
return true
|
||||
}
|
||||
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
// trayWithLocalizer builds the minimum Tray the message/title resolvers touch:
|
||||
// they read t.loc and nothing else, so no app, window or daemon connection is
|
||||
// needed. The shipped locale tree is used so the assertions below exercise the
|
||||
// real bundles rather than a fixture.
|
||||
func trayWithLocalizer(t *testing.T) *Tray {
|
||||
t.Helper()
|
||||
bundle, err := i18n.NewBundle(os.DirFS("i18n/locales"))
|
||||
require.NoError(t, err, "the shipped locale tree must load")
|
||||
return &Tray{loc: NewLocalizer(bundle, nil)}
|
||||
}
|
||||
|
||||
func TestLocalizedEventMessageResolvesKey(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
got := tray.localizedEventMessage(services.SystemEvent{
|
||||
MessageKey: string(proto.UserMsgExitNodeConnected),
|
||||
// A daemon always ships its English rendering too; the key must win.
|
||||
UserMessage: "should not be used",
|
||||
})
|
||||
assert.Equal(t, "Exit node connected.", got)
|
||||
}
|
||||
|
||||
func TestLocalizedEventMessageSubstitutesArgs(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
got := tray.localizedEventMessage(services.SystemEvent{
|
||||
MessageKey: string(proto.UserMsgUpdateCompleted),
|
||||
MessageArgs: map[string]string{proto.ArgVersion: "0.60.1"},
|
||||
})
|
||||
assert.Equal(t, "Your NetBird client was auto-updated to version 0.60.1.", got)
|
||||
}
|
||||
|
||||
// A daemon newer than the UI can publish a key this build has never heard of.
|
||||
// Showing the raw key would be a visible regression, so the daemon's own English
|
||||
// text has to win instead.
|
||||
func TestLocalizedEventMessageFallsBackToDaemonText(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
got := tray.localizedEventMessage(services.SystemEvent{
|
||||
MessageKey: "event.somethingThisBuildNeverHeardOf",
|
||||
UserMessage: "A message from a newer daemon.",
|
||||
})
|
||||
assert.Equal(t, "A message from a newer daemon.", got)
|
||||
}
|
||||
|
||||
// An old daemon sends no key at all, only userMessage.
|
||||
func TestLocalizedEventMessageWithoutKey(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
got := tray.localizedEventMessage(services.SystemEvent{UserMessage: "Legacy English text."})
|
||||
assert.Equal(t, "Legacy English text.", got)
|
||||
}
|
||||
|
||||
func TestEventTitlePrefersTitleKey(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
got := tray.eventTitle(services.SystemEvent{
|
||||
Severity: "critical",
|
||||
Category: "authentication",
|
||||
TitleKey: string(proto.TitleSessionWarning),
|
||||
})
|
||||
assert.Equal(t, "Session expires soon", got, "a title key must beat the composed title")
|
||||
}
|
||||
|
||||
func TestEventTitleComposesFromSeverityAndCategory(t *testing.T) {
|
||||
tray := trayWithLocalizer(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
severity string
|
||||
category string
|
||||
want string
|
||||
}{
|
||||
{"warning dns", "warning", "dns", "Warning: DNS"},
|
||||
{"critical system", "critical", "system", "Critical: System"},
|
||||
{"info network", "info", "network", "Info: Network"},
|
||||
{"error authentication", "error", "authentication", "Error: Authentication"},
|
||||
// Enum values this build does not know, and the empty severity/category
|
||||
// an event carries before the daemon fills them in.
|
||||
{"unknown severity", "apocalyptic", "dns", "Info: DNS"},
|
||||
{"unknown category", "warning", "quantum", "Warning: System"},
|
||||
{"empty", "", "", "Info: System"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tray.eventTitle(services.SystemEvent{Severity: tc.severity, Category: tc.category})
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipSystemEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ev services.SystemEvent
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "update announcement handled by the tray updater",
|
||||
ev: services.SystemEvent{Metadata: map[string]string{"new_version_available": "0.60.1"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "install progress belongs to the progress window",
|
||||
ev: services.SystemEvent{Metadata: map[string]string{"progress_window": "show"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "the v6 half of a dual-stack default route is already toasted as v4",
|
||||
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "::/0"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "the v4 default route is the one that toasts",
|
||||
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "0.0.0.0/0"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// policy_applied used to be suppressed here while the tray toasted
|
||||
// off the paired config_changed event; it now carries its own keys.
|
||||
name: "mdm policy applied surfaces normally",
|
||||
ev: services.SystemEvent{
|
||||
MessageKey: string(proto.UserMsgMDMPolicyApplied),
|
||||
Metadata: map[string]string{proto.MetadataTypeKey: proto.MetadataTypePolicyApplied},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, shouldSkipSystemEvent(tc.ev))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
|
||||
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
|
||||
// is reachable but too old for this UI. A probe error means the daemon isn't
|
||||
// reachable (not outdated), so it is left to the normal connection flow.
|
||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
|
||||
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
|
||||
ready, err := compat.DaemonReady(context.Background())
|
||||
if err != nil {
|
||||
log.Debugf("daemon compatibility probe: %v", err)
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
@@ -25,11 +27,10 @@ const (
|
||||
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() {
|
||||
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
|
||||
if t.window != nil {
|
||||
t.window.SetURL("/#/login")
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
@@ -194,6 +195,25 @@ func (t *Tray) registerSessionWarningCategory() {
|
||||
})
|
||||
}
|
||||
|
||||
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
|
||||
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
|
||||
// Falls back to a generic string when metadata is missing or unparsable.
|
||||
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
|
||||
if meta == nil {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
raw := meta[authsession.MetaExpiresAt]
|
||||
if raw == "" {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
deadline, err := authsession.ParseExpiresAt(raw)
|
||||
if err != nil {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
|
||||
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
|
||||
}
|
||||
|
||||
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
|
||||
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
|
||||
func (t *Tray) notifySessionWarning(title, body string) {
|
||||
@@ -287,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
|
||||
}
|
||||
seconds := int(time.Until(deadline).Seconds())
|
||||
if seconds <= 0 {
|
||||
if t.window != nil {
|
||||
t.window.SetURL("/#/login")
|
||||
t.window.Show()
|
||||
t.window.Focus()
|
||||
}
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
return
|
||||
}
|
||||
if t.svc.WindowManager == nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ type trayUpdater struct {
|
||||
app *application.App
|
||||
window *application.WebviewWindow
|
||||
update *services.Update
|
||||
notifier *notifications.NotificationService
|
||||
notifier *Notifier
|
||||
loc *Localizer
|
||||
onIconChange func()
|
||||
// onMenuChange drives a full tray relayout: the update row lives in the
|
||||
@@ -36,7 +36,7 @@ type trayUpdater struct {
|
||||
progressWindowOpen bool
|
||||
}
|
||||
|
||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
|
||||
u := &trayUpdater{
|
||||
app: app,
|
||||
window: window,
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build linux && !gtk3 && !(linux && 386)
|
||||
|
||||
#include "xembed_tray_linux.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.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
|
||||
|
||||
// A cluster different from the pinned one must be rejected; echoing the
|
||||
// pinned one back is valid.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr("attacker.cluster.invalid"),
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
})
|
||||
requireClientError(t, err)
|
||||
|
||||
// Restore the original toggles.
|
||||
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
|
||||
Cluster: ptr(before.Cluster),
|
||||
EnableLogCollection: before.EnableLogCollection,
|
||||
EnablePromptCollection: before.EnablePromptCollection,
|
||||
RedactPii: before.RedactPii,
|
||||
|
||||
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/netfilter v0.5.2
|
||||
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/zcalusic/sysinfo v1.1.3
|
||||
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/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/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@@ -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/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user