diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 0661e0c71..9b6a0edfd 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm
+FROM golang:1.26.7-bookworm
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends\
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 004b78b3e..f24dfbe9d 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -233,7 +233,7 @@ jobs:
-e GOCACHE=${CONTAINER_GOCACHE} \
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
-e CONTAINER=${CONTAINER} \
- golang:1.25-alpine \
+ golang:1.26.7-alpine \
sh -c ' \
apk update; apk add --no-cache \
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
@@ -730,6 +730,11 @@ jobs:
- name: Install modules
run: go mod tidy
+ - name: Run Mage
+ uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0
+ with:
+ install-only: true
+
- name: check git status
run: git --no-pager diff --exit-code
@@ -738,9 +743,7 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=integration -coverprofile=coverage.txt \
- -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
- -timeout 20m ./management/server/http/...
+ mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4d1945451..c1bbe9c44 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -215,7 +215,7 @@ jobs:
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
- name: Install goversioninfo
- run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
+ run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
- name: Generate windows syso amd64
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
- name: Generate windows syso arm64
@@ -435,7 +435,7 @@ jobs:
tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
- name: Install goversioninfo
- run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
+ run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
- name: Install wails3 CLI
# Version derived from go.mod so the binding generator always matches
# the wails runtime the binary links against.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9dea37ec8..aef749cfa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done.
### Requirements
-#### Go 1.25
+#### Go 1.26
Follow the installation guide from https://go.dev/
@@ -200,7 +200,7 @@ Follow the installation guide from https://go.dev/
The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
-- Go ≥ 1.25
+- Go ≥ 1.26
- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
diff --git a/README.md b/README.md
index 3bcb4a035..336332043 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
Start using NetBird at netbird.io
- See Documentation
+ See Documentation
Join our Slack channel or our Community forum
diff --git a/agent-network/README.md b/agent-network/README.md
index 5211fe8f9..029ada299 100644
--- a/agent-network/README.md
+++ b/agent-network/README.md
@@ -96,6 +96,42 @@ components:
— the management-side control plane: providers, policies, guardrails, limits, routing,
and usage/access logs.
+## Access roles
+
+Agent Network permissions build on the account permission matrix
+([`management/server/permissions/`](../management/server/permissions)). The
+`agent_network` area is split into dotted submodules (`agent_network.providers`,
+`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may
+grant a single submodule or the parent, which cascades to all of them.
+
+Two roles delegate Agent Network access without account-admin rights:
+
+- **`agent_network_admin`** — full control over the whole `agent_network` area plus
+ read-only users, groups, peers, and account info (needed to build policies).
+ Nothing else in the account.
+- **`usage_viewer`** — the regular User baseline plus read on
+ `agent_network.usage` (the aggregated usage and cost overview) and read-only
+ access to the resources the usage filters resolve against: users, groups,
+ peers, and the provider list (connection config redacted — no upstream URLs
+ or operator-supplied header values). No policies, and no account-wide
+ request-level access logs; like any caller, it still reads its own requests
+ through the self-scoped endpoints below.
+
+Every authenticated user, regardless of role, can read the caller-scoped
+self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers,
+and models the caller's own policies allow — what a local AI tool needs and nothing
+more). The regular usage and access-log endpoints self-scope instead of denying:
+a caller without the account-wide grant gets their own rows back, so "my usage"
+and "my requests" are the same endpoints the admin dashboard uses. The provider
+list self-scopes the same way — a caller without the providers grant gets the
+providers their own policies authorize, reduced to the display surface, with
+each provider's model list cut to what the caller's policy guardrails and the
+provider's declared models effectively permit (the same computation the setup
+answer and the proxy use). This feeds the dashboard's provider and model
+filters. Role
+definitions live in
+[`management/server/permissions/roles/`](../management/server/permissions/roles).
+
## Documentation
Full documentation, architecture, and quickstart:
diff --git a/client/android/split_tunnel.go b/client/android/split_tunnel.go
new file mode 100644
index 000000000..59ac539f9
--- /dev/null
+++ b/client/android/split_tunnel.go
@@ -0,0 +1,106 @@
+package android
+
+// Split tunnelling modes, stored as strings so an unknown value written by a
+// newer build degrades to "off" rather than to some other mode's behaviour.
+const (
+ SplitTunnelModeOff = "off"
+ SplitTunnelModeExclude = "exclude"
+ SplitTunnelModeInclude = "include"
+)
+
+type splitTunnelSection struct {
+ Mode string `json:"mode"`
+ Excluded []string `json:"excluded"`
+ Included []string `json:"included"`
+}
+
+// PackageList wraps []string for gomobile compatibility.
+type PackageList struct {
+ items []string
+}
+
+// NewPackageList creates an empty list to fill via Add.
+func NewPackageList() *PackageList {
+ return &PackageList{}
+}
+
+// Add appends a package name, ignoring empty ones.
+func (l *PackageList) Add(s string) {
+ if s == "" {
+ return
+ }
+ l.items = append(l.items, s)
+}
+
+// Size returns the number of entries.
+func (l *PackageList) Size() int {
+ return len(l.items)
+}
+
+// Get returns the entry at index i, or an empty string when out of range.
+func (l *PackageList) Get(i int) string {
+ if i < 0 || i >= len(l.items) {
+ return ""
+ }
+ return l.items[i]
+}
+
+// SplitTunnelSettings is one profile's choice of which applications the tunnel
+// carries. The two selections are kept apart because the platform applies one
+// or the other and never both, and so that switching mode does not throw away
+// the picks made in the other one.
+type SplitTunnelSettings struct {
+ Mode string
+ Excluded *PackageList
+ Included *PackageList
+}
+
+// NewSplitTunnelSettings creates settings that carry every application.
+func NewSplitTunnelSettings() *SplitTunnelSettings {
+ return &SplitTunnelSettings{
+ Mode: SplitTunnelModeOff,
+ Excluded: NewPackageList(),
+ Included: NewPackageList(),
+ }
+}
+
+func packagesOf(list *PackageList) []string {
+ if list == nil {
+ return nil
+ }
+ out := make([]string, 0, len(list.items))
+ out = append(out, list.items...)
+ return out
+}
+
+func normalizeSplitTunnelMode(mode string) string {
+ switch mode {
+ case SplitTunnelModeExclude, SplitTunnelModeInclude:
+ return mode
+ default:
+ return SplitTunnelModeOff
+ }
+}
+
+func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings {
+ out := NewSplitTunnelSettings()
+ out.Mode = normalizeSplitTunnelMode(section.Mode)
+ for _, pkg := range section.Excluded {
+ out.Excluded.Add(pkg)
+ }
+ for _, pkg := range section.Included {
+ out.Included.Add(pkg)
+ }
+ return out
+}
+
+func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection {
+ if settings == nil {
+ settings = NewSplitTunnelSettings()
+ }
+ return splitTunnelSection{
+ Mode: normalizeSplitTunnelMode(settings.Mode),
+ Excluded: packagesOf(settings.Excluded),
+ Included: packagesOf(settings.Included),
+ }
+}
diff --git a/client/android/split_tunnel_store.go b/client/android/split_tunnel_store.go
new file mode 100644
index 000000000..f54e0c8ef
--- /dev/null
+++ b/client/android/split_tunnel_store.go
@@ -0,0 +1,34 @@
+//go:build android
+
+package android
+
+const splitTunnelNamespace = "split-tunnel"
+
+// SplitTunnelStore reads and writes a profile's split tunnelling settings.
+type SplitTunnelStore struct {
+ prefs prefsStore
+}
+
+// NewSplitTunnelStore opens the split tunnelling store of the given profile.
+func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) {
+ prefs, err := newProfilePrefs(configDir, profileID)
+ if err != nil {
+ return nil, err
+ }
+ return &SplitTunnelStore{prefs: prefs}, nil
+}
+
+// Load returns the stored settings, or settings that carry every application
+// when the profile has none saved.
+func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) {
+ var section splitTunnelSection
+ if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil {
+ return nil, err
+ }
+ return settingsFromSection(section), nil
+}
+
+// Save replaces the stored settings.
+func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error {
+ return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings))
+}
diff --git a/client/android/split_tunnel_test.go b/client/android/split_tunnel_test.go
new file mode 100644
index 000000000..b8465e8ef
--- /dev/null
+++ b/client/android/split_tunnel_test.go
@@ -0,0 +1,109 @@
+package android
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestNormalizeSplitTunnelMode(t *testing.T) {
+ tests := []struct {
+ name string
+ mode string
+ want string
+ }{
+ {name: "exclude is kept", mode: SplitTunnelModeExclude, want: SplitTunnelModeExclude},
+ {name: "include is kept", mode: SplitTunnelModeInclude, want: SplitTunnelModeInclude},
+ {name: "off is kept", mode: SplitTunnelModeOff, want: SplitTunnelModeOff},
+ {name: "empty falls back to off", mode: "", want: SplitTunnelModeOff},
+ {name: "a mode from a newer build falls back to off", mode: "only-work-apps", want: SplitTunnelModeOff},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := normalizeSplitTunnelMode(tt.mode); got != tt.want {
+ t.Errorf("normalizeSplitTunnelMode(%q) = %q, want %q", tt.mode, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSettingsFromSection(t *testing.T) {
+ got := settingsFromSection(splitTunnelSection{
+ Mode: SplitTunnelModeExclude,
+ Excluded: []string{"com.example.a", "com.example.b"},
+ Included: []string{"com.example.c"},
+ })
+
+ if got.Mode != SplitTunnelModeExclude {
+ t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeExclude)
+ }
+ if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" {
+ t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded))
+ }
+ if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" {
+ t.Errorf("included = %v, want the stored package", packagesOf(got.Included))
+ }
+}
+
+// A profile that has never stored anything decodes into an empty section, and
+// must come back as settings that carry every application rather than as nil
+// lists the caller would have to guard against.
+func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) {
+ got := settingsFromSection(splitTunnelSection{})
+
+ if got.Mode != SplitTunnelModeOff {
+ t.Errorf("mode = %q, want %q", got.Mode, SplitTunnelModeOff)
+ }
+ if got.Excluded == nil || got.Included == nil {
+ t.Fatal("both selections must be usable lists, not nil")
+ }
+ if got.Excluded.Size() != 0 || got.Included.Size() != 0 {
+ t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included))
+ }
+}
+
+func TestSectionFromSettingsRoundTrip(t *testing.T) {
+ settings := NewSplitTunnelSettings()
+ settings.Mode = SplitTunnelModeInclude
+ settings.Included.Add("com.example.a")
+ settings.Excluded.Add("com.example.b")
+
+ section := sectionFromSettings(settings)
+ back := settingsFromSection(section)
+
+ if back.Mode != SplitTunnelModeInclude {
+ t.Errorf("mode = %q, want %q", back.Mode, SplitTunnelModeInclude)
+ }
+ if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) {
+ t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included))
+ }
+ // The inactive selection survives, so switching mode back does not make the
+ // user pick their applications again.
+ if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) {
+ t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded))
+ }
+}
+
+func TestSectionFromNilSettings(t *testing.T) {
+ section := sectionFromSettings(nil)
+
+ if section.Mode != SplitTunnelModeOff {
+ t.Errorf("mode = %q, want %q", section.Mode, SplitTunnelModeOff)
+ }
+ if len(section.Excluded) != 0 || len(section.Included) != 0 {
+ t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included)
+ }
+}
+
+func TestPackageListIgnoresEmptyAndBounds(t *testing.T) {
+ list := NewPackageList()
+ list.Add("com.example.a")
+ list.Add("")
+
+ if list.Size() != 1 {
+ t.Errorf("size = %d, want 1", list.Size())
+ }
+ if list.Get(-1) != "" || list.Get(5) != "" {
+ t.Error("out of range access must return an empty string")
+ }
+}
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 893b1e248..98fe53626 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -3,7 +3,6 @@ package cmd
import (
"context"
"fmt"
- "os/user"
"strings"
"time"
@@ -114,7 +113,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
if err != nil {
return fmt.Errorf("get active profile: %v", err)
}
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %v", err)
}
diff --git a/client/cmd/jobs.go b/client/cmd/jobs.go
new file mode 100644
index 000000000..36aab3570
--- /dev/null
+++ b/client/cmd/jobs.go
@@ -0,0 +1,13 @@
+package cmd
+
+// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug
+// bundles) requested by the management server. It defaults to false: remote
+// jobs are an explicit opt-in, and enabling it is a privileged change (see the
+// daemon gate in client/server), mirroring the SSH server opt-in.
+const remoteJobsAllowedFlag = "allow-remote-jobs"
+
+var remoteJobsAllowed bool
+
+func init() {
+ upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer")
+}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index 6aa019896..f703b32c4 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
- "os/user"
"strings"
log "github.com/sirupsen/logrus"
@@ -53,7 +52,7 @@ var loginCmd = &cobra.Command{
// nolint
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName)
}
- username, err := user.Current()
+ username, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %v", err)
}
@@ -74,7 +73,7 @@ var loginCmd = &cobra.Command{
if providedSetupKey != "" {
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
}
- if err := doExtendSession(ctx, cmd); err != nil {
+ if err := doExtendSession(ctx, cmd, activeProf); err != nil {
return fmt.Errorf("extend session failed: %v", err)
}
return nil
@@ -176,7 +175,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
// (browser + verification URL) and the resulting JWT is forwarded to the
// management server's ExtendAuthSession RPC. The tunnel stays up
// throughout — no Down/Up, no network-map resync.
-func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
+func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
//nolint
@@ -190,14 +189,12 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
// the CLI runs in the user's session, the daemon does not: tell it what we can see
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
- // Pre-fill the IdP login hint from the active profile so the user
+ // Pre-fill the IdP login hint from the resolved profile so the user
// doesn't have to retype their email. Best-effort: we still proceed
// without a hint if the lookup fails.
pm := profilemanager.NewProfileManager()
- if active, perr := pm.GetActiveProfile(); perr == nil {
- if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
- req.Hint = &profState.Email
- }
+ if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" {
+ req.Hint = &profState.Email
}
startResp, err := client.RequestExtendAuthSession(ctx, req)
@@ -235,9 +232,11 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
// switch profile if provided
if profileName != "" {
- if err := switchProfileOnDaemon(ctx, pm, profileName, username); err != nil {
+ prof, err := switchProfileOnDaemon(ctx, pm, profileName, username)
+ if err != nil {
return nil, fmt.Errorf("switch profile: %v", err)
}
+ return prof, nil
}
activeProf, err := pm.GetActiveProfile()
@@ -251,20 +250,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
return activeProf, nil
}
-func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
+func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) (*profilemanager.Profile, error) {
resolvedID, err := switchProfile(ctx, handle, username)
if err != nil {
- return fmt.Errorf("switch profile on daemon: %v", err)
+ return nil, fmt.Errorf("switch profile on daemon: %v", err)
}
if err := pm.SwitchProfile(resolvedID); err != nil {
- return fmt.Errorf("switch profile: %v", err)
+ return nil, fmt.Errorf("switch profile: %v", err)
}
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
- log.Errorf("failed to connect to service CLI interface %v", err)
- return err
+ return nil, fmt.Errorf("connect to service CLI interface: %w", err)
}
defer conn.Close()
@@ -272,17 +270,17 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
status, err := client.Status(ctx, &proto.StatusRequest{})
if err != nil {
- return fmt.Errorf("unable to get daemon status: %v", err)
+ return nil, fmt.Errorf("unable to get daemon status: %v", err)
}
if status.Status == string(internal.StatusConnected) {
if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil {
log.Errorf("call service down method: %v", err)
- return err
+ return nil, err
}
}
- return nil
+ return &profilemanager.Profile{ID: resolvedID}, nil
}
// switchProfile asks the daemon to switch to the profile identified by
diff --git a/client/cmd/logout.go b/client/cmd/logout.go
index dcd7b5075..cf2a4e446 100644
--- a/client/cmd/logout.go
+++ b/client/cmd/logout.go
@@ -3,11 +3,11 @@ package cmd
import (
"context"
"fmt"
- "os/user"
"time"
"github.com/spf13/cobra"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
@@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{
if profileName != "" {
req.ProfileName = &profileName
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %v", err)
}
diff --git a/client/cmd/profile.go b/client/cmd/profile.go
index 268034e70..2d6653537 100644
--- a/client/cmd/profile.go
+++ b/client/cmd/profile.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
- "os/user"
"strings"
"text/tabwriter"
"time"
@@ -97,7 +96,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
}
defer conn.Close()
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
@@ -138,7 +137,7 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
return err
}
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
@@ -179,7 +178,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error {
}
defer conn.Close()
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
@@ -233,7 +232,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
}
defer conn.Close()
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
@@ -261,7 +260,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
profileManager := profilemanager.NewProfileManager()
handle := args[0]
- currUser, err := user.Current()
+ currUser, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index ccad78942..be6479440 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
+ "github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -31,6 +32,8 @@ const (
dnsResolverAddress = "dns-resolver-address"
enableRosenpassFlag = "enable-rosenpass"
rosenpassPermissiveFlag = "rosenpass-permissive"
+ enableLocalMetricsFlag = "enable-local-metrics"
+ localMetricsAddressFlag = "local-metrics-address"
preSharedKeyFlag = "preshared-key"
interfaceNameFlag = "interface-name"
wireguardPortFlag = "wireguard-port"
@@ -80,6 +83,8 @@ var (
updateSettingsDisabled bool
captureEnabled bool
networksDisabled bool
+ localMetricsEnabled bool
+ localMetricsAddr string
rootCmd = &cobra.Command{
Use: "netbird",
@@ -215,6 +220,8 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
+ upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
+ upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index f40056f83..328a15454 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
- networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
+ networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
diff --git a/client/cmd/up.go b/client/cmd/up.go
index 9f4fa8c33..2e53224df 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -2,10 +2,10 @@ package cmd
import (
"context"
+ "errors"
"fmt"
"net"
"net/netip"
- "os/user"
"runtime"
"strings"
"time"
@@ -48,6 +48,8 @@ const (
profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used."
)
+var errDaemonActiveProfileUnsupported = errors.New("daemon does not support active profile lookup")
+
var (
foregroundMode bool
dnsLabels []string
@@ -122,23 +124,25 @@ func upFunc(cmd *cobra.Command, args []string) error {
pm := profilemanager.NewProfileManager()
- username, err := user.Current()
+ username, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %v", err)
}
+ var activeProf *profilemanager.Profile
var profileSwitched bool
// switch profile if provided
if profileName != "" {
- if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
+ activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username)
+ if err != nil {
return fmt.Errorf("switch profile: %v", err)
}
profileSwitched = true
- }
-
- activeProf, err := pm.GetActiveProfile()
- if err != nil {
- return fmt.Errorf("get active profile: %v", err)
+ } else {
+ activeProf, err = pm.GetActiveProfile()
+ if err != nil {
+ return fmt.Errorf("get active profile: %v", err)
+ }
}
if foregroundMode {
@@ -150,13 +154,15 @@ func upFunc(cmd *cobra.Command, args []string) error {
// switchOrCreateProfile switches the active profile to the one identified by
// handle, creating it first when it does not exist yet. This restores the
// pre-0.73 behaviour where `netbird up --profile ` auto-creates a
-// missing profile instead of failing.
-func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
+// missing profile instead of failing. Returns the daemon-resolved profile so
+// callers act on it directly instead of re-reading the local state, which is
+// not updated under sudo.
+func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) (*profilemanager.Profile, error) {
resolvedID, err := switchProfile(ctx, handle, username)
if err != nil {
st, ok := gstatus.FromError(err)
if !ok || st.Code() != codes.NotFound {
- return err
+ return nil, err
}
// Don't fail immediately on a create error: a concurrent run may
// have created the profile between the NotFound above and this
@@ -165,16 +171,16 @@ func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManage
_, createErr := createProfile(ctx, handle, username)
if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
if createErr != nil {
- return fmt.Errorf("create profile: %w", createErr)
+ return nil, fmt.Errorf("create profile: %w", createErr)
}
- return err
+ return nil, err
}
}
if err := pm.SwitchProfile(resolvedID); err != nil {
- return err
+ return nil, err
}
- return nil
+ return &profilemanager.Profile{ID: resolvedID}, nil
}
// createProfile dials the daemon and creates a new profile with the given
@@ -302,6 +308,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
return fmt.Errorf("unable to get daemon status: %v", err)
}
+ // Under sudo the invoking user's local active-profile mirror is never
+ // written (the SwitchProfile write is a no-op), and plain root has no
+ // invoking user at all — so the mirror read into activeProf above is stale
+ // or defaulted and must not drive the daemon. With no --profile to make the
+ // choice explicit, take the profile the daemon already holds for this user
+ // instead: it stays on the user's current profile rather than silently
+ // switching to the mirror's default, and refuses when the daemon is on
+ // another user's profile.
+ if profileName == "" && !profilemanager.MirrorIsAuthoritative() {
+ u, err := profilemanager.InvokingUser()
+ if err != nil {
+ return fmt.Errorf("get current user: %v", err)
+ }
+ resolved, err := daemonActiveProfileForUser(ctx, client, u.Username)
+ switch {
+ case errors.Is(err, errDaemonActiveProfileUnsupported):
+ log.Warnf("keeping the locally resolved profile: %v", err)
+ case err != nil:
+ return err
+ default:
+ activeProf = resolved
+ }
+ }
+
if status.Status == string(internal.StatusConnected) {
if !profileSwitched {
cmd.Println("Already connected")
@@ -314,7 +344,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
}
}
- username, err := user.Current()
+ username, err := profilemanager.InvokingUser()
if err != nil {
return fmt.Errorf("get current user: %v", err)
}
@@ -398,26 +428,21 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return nil
}
-func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
- var req proto.SetConfigRequest
- req.ProfileName = profileName
- req.Username = username
-
- req.ManagementUrl = managementURL
- req.AdminURL = adminURL
- req.NatExternalIPs = natExternalIPs
- req.CustomDNSAddress = customDNSAddressConverted
- req.ExtraIFaceBlacklist = extraIFaceBlackList
- req.DnsLabels = dnsLabelsValidated.ToPunycodeList()
- req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0
- req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0
-
- if cmd.Flag(enableRosenpassFlag).Changed {
- req.RosenpassEnabled = &rosenpassEnabled
- }
- if cmd.Flag(rosenpassPermissiveFlag).Changed {
- req.RosenpassPermissive = &rosenpassPermissive
+// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was
+// explicitly set on cmd. It collapses the repeated
+// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into
+// a single call, keeping their cognitive complexity within bounds.
+func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) {
+ if cmd.Flag(name).Changed {
+ dst2 := val
+ *dst = &dst2
}
+}
+
+// setSSHSetConfigFields copies the SSH server flags the user actually
+// passed into req, leaving the rest unset so the daemon keeps the
+// persisted values.
+func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) {
if cmd.Flag(serverSSHAllowedFlag).Changed {
req.ServerSSHAllowed = &serverSSHAllowed
}
@@ -440,6 +465,31 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
req.SshJWTCacheTTL = &sshJWTCacheTTL32
}
+}
+
+func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
+ var req proto.SetConfigRequest
+ req.ProfileName = profileName
+ req.Username = username
+
+ req.ManagementUrl = managementURL
+ req.AdminURL = adminURL
+ req.NatExternalIPs = natExternalIPs
+ req.CustomDNSAddress = customDNSAddressConverted
+ req.ExtraIFaceBlacklist = extraIFaceBlackList
+ req.DnsLabels = dnsLabelsValidated.ToPunycodeList()
+ req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0
+ req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0
+
+ if cmd.Flag(enableRosenpassFlag).Changed {
+ req.RosenpassEnabled = &rosenpassEnabled
+ }
+ if cmd.Flag(rosenpassPermissiveFlag).Changed {
+ req.RosenpassPermissive = &rosenpassPermissive
+ }
+ setSSHSetConfigFields(&req, cmd)
+ setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed)
+
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
log.Errorf("parse interface name: %v", err)
@@ -499,6 +549,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
+ if cmd.Flag(enableLocalMetricsFlag).Changed {
+ req.EnableLocalMetrics = &localMetricsEnabled
+ }
+ if cmd.Flag(localMetricsAddressFlag).Changed {
+ req.LocalMetricsAddress = &localMetricsAddr
+ }
+
return &req
}
@@ -523,6 +580,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
if cmd.Flag(serverSSHAllowedFlag).Changed {
ic.ServerSSHAllowed = &serverSSHAllowed
}
+ setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed)
if cmd.Flag(enableSSHRootFlag).Changed {
ic.EnableSSHRoot = &enableSSHRoot
@@ -616,9 +674,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
ic.DisableIPv6 = &disableIPv6
}
+ if cmd.Flag(enableLocalMetricsFlag).Changed {
+ ic.LocalMetricsEnabled = &localMetricsEnabled
+ }
+
+ if cmd.Flag(localMetricsAddressFlag).Changed {
+ ic.LocalMetricsAddress = &localMetricsAddr
+ }
+
return &ic, nil
}
+// setSSHLoginFields copies the SSH server flags the user actually passed
+// into req, leaving the rest unset so the daemon keeps the persisted
+// values.
+func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) {
+ if cmd.Flag(serverSSHAllowedFlag).Changed {
+ req.ServerSSHAllowed = &serverSSHAllowed
+ }
+ if cmd.Flag(enableSSHRootFlag).Changed {
+ req.EnableSSHRoot = &enableSSHRoot
+ }
+ if cmd.Flag(enableSSHSFTPFlag).Changed {
+ req.EnableSSHSFTP = &enableSSHSFTP
+ }
+ if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
+ req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
+ }
+ if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
+ req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
+ }
+ if cmd.Flag(disableSSHAuthFlag).Changed {
+ req.DisableSSHAuth = &disableSSHAuth
+ }
+ if cmd.Flag(sshJWTCacheTTLFlag).Changed {
+ sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
+ req.SshJWTCacheTTL = &sshJWTCacheTTL32
+ }
+}
+
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
@@ -645,39 +739,21 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.RosenpassPermissive = &rosenpassPermissive
}
- if cmd.Flag(serverSSHAllowedFlag).Changed {
- loginRequest.ServerSSHAllowed = &serverSSHAllowed
- }
-
- if cmd.Flag(enableSSHRootFlag).Changed {
- loginRequest.EnableSSHRoot = &enableSSHRoot
- }
-
- if cmd.Flag(enableSSHSFTPFlag).Changed {
- loginRequest.EnableSSHSFTP = &enableSSHSFTP
- }
-
- if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
- loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
- }
-
- if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
- loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
- }
-
- if cmd.Flag(disableSSHAuthFlag).Changed {
- loginRequest.DisableSSHAuth = &disableSSHAuth
- }
-
- if cmd.Flag(sshJWTCacheTTLFlag).Changed {
- sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
- loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
- }
+ setSSHLoginFields(&loginRequest, cmd)
+ setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed)
if cmd.Flag(disableAutoConnectFlag).Changed {
loginRequest.DisableAutoConnect = &autoConnectDisabled
}
+ if cmd.Flag(enableLocalMetricsFlag).Changed {
+ loginRequest.EnableLocalMetrics = &localMetricsEnabled
+ }
+
+ if cmd.Flag(localMetricsAddressFlag).Changed {
+ loginRequest.LocalMetricsAddress = &localMetricsAddr
+ }
+
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
return nil, err
@@ -849,3 +925,31 @@ func isValidAddrPort(input string) bool {
_, err := netip.ParseAddrPort(input)
return err == nil
}
+
+// daemonActiveProfileForUser returns the profile the daemon currently holds for
+// username, for the no --profile case where the local mirror is not
+// authoritative (sudo or plain root). It returns that profile when the daemon
+// owns it for this user or when the profile is unowned (empty username, as on a
+// fresh install), so the caller acts on the daemon's real state instead of the
+// stale mirror. It denies with a --profile hint when the daemon is on another
+// user's profile, when the lookup fails, or when the daemon reports no active
+// profile. Returns errDaemonActiveProfileUnsupported when the daemon predates
+// the RPC; the caller keeps the mirror-derived profile in that case.
+func daemonActiveProfileForUser(ctx context.Context, client proto.DaemonServiceClient, username string) (*profilemanager.Profile, error) {
+ active, err := client.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
+ if err != nil {
+ if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unimplemented {
+ return nil, fmt.Errorf("%w: %v", errDaemonActiveProfileUnsupported, err)
+ }
+ return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon's active profile could not be verified: %v", err)
+ }
+ if active.GetId() == "" {
+ return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon reported no active profile")
+ }
+ if active.GetUsername() != "" && active.GetUsername() != username {
+ return nil, fmt.Errorf(
+ "pass --profile to choose the profile explicitly: the daemon's active profile is %q (user %q) but this invocation runs for %q",
+ active.GetProfileName(), active.GetUsername(), username)
+ }
+ return &profilemanager.Profile{ID: profilemanager.ID(active.GetId())}, nil
+}
diff --git a/client/cmd/up_test.go b/client/cmd/up_test.go
new file mode 100644
index 000000000..9b5f9fbea
--- /dev/null
+++ b/client/cmd/up_test.go
@@ -0,0 +1,88 @@
+package cmd
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+type fakeActiveProfileClient struct {
+ proto.DaemonServiceClient
+ resp *proto.GetActiveProfileResponse
+ err error
+}
+
+func (f *fakeActiveProfileClient) GetActiveProfile(_ context.Context, _ *proto.GetActiveProfileRequest, _ ...grpc.CallOption) (*proto.GetActiveProfileResponse, error) {
+ return f.resp, f.err
+}
+
+func TestDaemonActiveProfileForUserReturnsOwnProfile(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "root"}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.NoError(t, err)
+ require.NotNil(t, prof)
+ assert.Equal(t, profilemanager.ID("default"), prof.ID)
+}
+
+func TestDaemonActiveProfileForUserReturnsUnownedProfile(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: ""}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.NoError(t, err)
+ require.NotNil(t, prof)
+ assert.Equal(t, profilemanager.ID("default"), prof.ID)
+}
+
+func TestDaemonActiveProfileForUserKeepsDaemonProfileOverStaleMirror(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "misha")
+ require.NoError(t, err)
+ require.NotNil(t, prof)
+ assert.Equal(t, profilemanager.ID("ab12"), prof.ID)
+}
+
+func TestDaemonActiveProfileForUserRejectsOtherUsersProfile(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.Error(t, err)
+ assert.Nil(t, prof)
+ assert.Contains(t, err.Error(), "--profile")
+}
+
+func TestDaemonActiveProfileForUserRejectsOtherUsersDefaultProfile(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "misha"}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.Error(t, err)
+ assert.Nil(t, prof)
+ assert.Contains(t, err.Error(), "--profile")
+}
+
+func TestDaemonActiveProfileForUserRejectsLookupError(t *testing.T) {
+ client := &fakeActiveProfileClient{err: gstatus.Error(codes.Internal, "boom")}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.Error(t, err)
+ assert.Nil(t, prof)
+ assert.Contains(t, err.Error(), "--profile")
+}
+
+func TestDaemonActiveProfileForUserRejectsEmptyResponse(t *testing.T) {
+ client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{}}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.Error(t, err)
+ assert.Nil(t, prof)
+ assert.Contains(t, err.Error(), "--profile")
+}
+
+func TestDaemonActiveProfileForUserKeepsMirrorWhenDaemonWithoutRPC(t *testing.T) {
+ client := &fakeActiveProfileClient{err: gstatus.Error(codes.Unimplemented, "unknown method")}
+ prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
+ require.ErrorIs(t, err, errDaemonActiveProfileUnsupported)
+ assert.Nil(t, prof)
+}
diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go
index 27beb8934..4ff5c9978 100644
--- a/client/embed/embed_test.go
+++ b/client/embed/embed_test.go
@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
- networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
+ networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
require.NoError(t, err)
diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go
index b3a9e1158..939df3a21 100644
--- a/client/internal/auth/auth.go
+++ b/client/internal/auth/auth.go
@@ -368,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.EnableSSHLocalPortForwarding,
a.config.EnableSSHRemotePortForwarding,
a.config.DisableSSHAuth,
+ a.config.RemoteJobsAllowed,
)
}
diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go
index 9dec7cf53..3592e589d 100644
--- a/client/internal/auth/device_flow.go
+++ b/client/internal/auth/device_flow.go
@@ -304,6 +304,16 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
+ // Same as the PKCE flow: the account the token belongs to is what
+ // callers store to send back as the login_hint. Without it a client
+ // driven through the device flow — Android TV and tvOS — never binds
+ // an account to its profile and every later login goes out blind.
+ if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
+ log.Warnf("failed to parse email from ID token: %v", err)
+ } else {
+ tokenInfo.Email = email
+ }
+
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
return tokenInfo, err
}
diff --git a/client/internal/connect.go b/client/internal/connect.go
index d670a66ae..c0b1a1082 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -250,7 +250,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
wrapErr := state.Wrap
myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey)
if err != nil {
- log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error())
+ log.Errorf("failed parsing Wireguard key: %s", err)
return wrapErr(err)
}
@@ -661,6 +661,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
RosenpassEnabled: config.RosenpassEnabled,
RosenpassPermissive: config.RosenpassPermissive,
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
+ RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed),
EnableSSHRoot: config.EnableSSHRoot,
EnableSSHSFTP: config.EnableSSHSFTP,
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
@@ -758,6 +759,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.EnableSSHLocalPortForwarding,
config.EnableSSHRemotePortForwarding,
config.DisableSSHAuth,
+ config.RemoteJobsAllowed,
)
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
}
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 1d31c75ca..b362ae293 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -711,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
if g.internalConfig.ServerSSHAllowed != nil {
configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed))
}
+ if g.internalConfig.RemoteJobsAllowed != nil {
+ configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed))
+ }
if g.internalConfig.EnableSSHRoot != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot))
}
@@ -737,6 +740,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
+ configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
+ configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 7fe93a5c1..17d520358 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -839,12 +839,13 @@ COMMIT`
// the excluded set with a justification.
func TestAddConfig_AllFieldsCovered(t *testing.T) {
excluded := map[string]string{
- "PrivateKey": "sensitive: WireGuard private key",
- "PreSharedKey": "sensitive: WireGuard pre-shared key",
- "SSHKey": "sensitive: SSH private key",
- "ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
- "Name": "non-config: profile name is not needed for debug purposes",
- "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
+ "PrivateKey": "sensitive: WireGuard private key",
+ "PreSharedKey": "sensitive: WireGuard pre-shared key",
+ "SSHKey": "sensitive: SSH private key",
+ "ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "Name": "non-config: profile name is not needed for debug purposes",
+ "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
+ "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
}
mURL, _ := url.Parse("https://api.example.com:443")
@@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
RosenpassEnabled: true,
RosenpassPermissive: true,
ServerSSHAllowed: &bTrue,
+ RemoteJobsAllowed: &bTrue,
EnableSSHRoot: &bTrue,
EnableSSHSFTP: &bTrue,
EnableSSHLocalPortForwarding: &bTrue,
@@ -886,6 +888,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertPath: "/tmp/cert",
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
+ DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
@@ -903,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
g.addCommonConfigFields(&sb)
rendered := sb.String() + renderAddConfigSpecific(g)
+ // DebugBundleUploadURL is an MDM-provided value that can carry
+ // credentials or signed query tokens. It is deliberately excluded
+ // above; assert it never reaches the rendered bundle — neither the
+ // field name nor the token — in either anonymize mode.
+ assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
+ assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")
+
val := reflect.ValueOf(cfg).Elem()
typ := val.Type()
var missing []string
diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go
index 53380b2aa..948000a3d 100644
--- a/client/internal/dns/host_windows.go
+++ b/client/internal/dns/host_windows.go
@@ -6,8 +6,10 @@ import (
"fmt"
"io"
"net/netip"
+ "os"
"os/exec"
"slices"
+ "strconv"
"strings"
"syscall"
"time"
@@ -34,10 +36,16 @@ var (
// Registry locations of the host DNS configuration this package programs,
// exported so a diagnostic reader reports the same locations that are written.
const (
- // NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
- // Older versions used different layouts under the same prefix: a single
- // unsuffixed key, then one key per domain, now one key per batch of domains.
- NRPTKeyPrefix = "NetBird-Match"
+ // NRPTKeyPrefix starts the name of every NRPT rule key this client creates:
+ // the match rules, the catch-all, and the .local exemption. Cleanup
+ // enumerates by this prefix, so a new kind of rule is removed by existing
+ // code as long as its key starts here.
+ NRPTKeyPrefix = "NetBird-"
+
+ // nrptMatchKeyName names the match-domain rules. Older versions used
+ // different layouts under the same name: a single unsuffixed key, then one
+ // key per domain, now one key per batch of domains.
+ nrptMatchKeyName = NRPTKeyPrefix + "Match"
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
@@ -53,8 +61,24 @@ const (
)
const (
- dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
- gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
+ dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName
+ gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName
+
+ dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal`
+ gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal`
+
+ nrptCatchAllNamespace = "."
+ // nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast
+ // resolver must not answer for it. The catch-all rule would hand it to us
+ // anyway, so it gets an exemption rule of its own.
+ nrptLocalNamespace = ".local"
+
+ // envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's
+ // NameServer alone, leaving the OS free to query other adapters' resolvers in
+ // parallel. An escape hatch for setups that depend on a resolver of theirs
+ // still being reachable while connected, at the cost of the leak and of the
+ // race the catch-all rule exists to close.
+ envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION"
dnsPolicyConfigVersionKey = "Version"
dnsPolicyConfigVersionValue = 2
@@ -293,6 +317,13 @@ func (r *registryConfigurator) disableWINSForInterface() error {
}
func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error {
+ // Clear every rule the previous apply installed before installing any new
+ // one, including a leftover catch-all: removal is unconditional so a rule
+ // from an earlier run cannot survive into a config that no longer wants it.
+ if err := r.removeDNSMatchPolicies(); err != nil {
+ log.Errorf("cleanup old dns match policies: %s", err)
+ }
+
if config.RouteAll {
if err := r.addDNSSetupForAll(config.ServerIP); err != nil {
return fmt.Errorf("add dns setup: %w", err)
@@ -318,8 +349,22 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, "."))
}
- if err := r.removeDNSMatchPolicies(); err != nil {
- log.Errorf("cleanup old dns match policies: %s", err)
+ // The root namespace is a match domain like any other: it just happens to
+ // match every name. Without it the adapter's NameServer only adds one more
+ // resolver to the set Windows queries in parallel, keeping whichever answer
+ // comes back first — which leaks every query to the local network and lets a
+ // resolver other than ours answer for a name we are authoritative for.
+ if config.RouteAll {
+ if parseBoolEnv(envLegacyDNSResolution) {
+ log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP)
+ } else {
+ matchDomains = append(matchDomains, nrptCatchAllNamespace)
+ log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP)
+
+ if err := r.addDNSExemptLocalPolicy(); err != nil {
+ return fmt.Errorf("add dns exempt policy: %w", err)
+ }
+ }
}
if len(matchDomains) != 0 {
@@ -397,6 +442,42 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
return nil
}
+// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762
+// reserves it for multicast DNS, so forwarding those names to a unicast
+// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and
+// anything else announcing itself on the link - and the answer is authoritative
+// enough that Windows stops looking. A rule naming the namespace with no
+// servers hands it back to the DNS client untouched. A more specific rule still
+// wins, so a match domain under .local keeps going through us.
+func (r *registryConfigurator) addDNSExemptLocalPolicy() error {
+ var noServers netip.Addr
+
+ if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil {
+ return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err)
+ }
+
+ if r.gpo {
+ if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil {
+ return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err)
+ }
+ if err := refreshGroupPolicy(); err != nil {
+ log.Warnf("failed to refresh group policy: %v", err)
+ }
+ }
+
+ log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace)
+ return nil
+}
+
+// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption
+// rule: the namespace with an empty server list, which tells the DNS client to
+// resolve those names the way it would without any rule at all.
+//
+// The empty string is the whole difference, and it has to be written: dropping
+// the value and clearing ConfigOptions instead produces a rule Windows treats
+// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in
+// favour of the catch-all. 0x8 says the server list is the meaningful part of
+// the rule, and an empty list then means "no server, resolve normally".
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil {
return fmt.Errorf("remove existing dns policy: %w", err)
@@ -416,7 +497,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s
return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err)
}
- if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil {
+ var servers string
+ if ip.IsValid() {
+ servers = ip.String()
+ }
+ if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil {
return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err)
}
@@ -514,8 +599,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
}
func (r *registryConfigurator) restoreHostDNS() error {
+ // Propagated, unlike in applyDNSConfig: there we are about to write fresh
+ // rules over whatever survived, here we are leaving, and a rule left behind
+ // keeps sending every query to an address that is about to disappear.
if err := r.removeDNSMatchPolicies(); err != nil {
- log.Errorf("remove dns match policies: %s", err)
+ return fmt.Errorf("remove dns match policies: %w", err)
}
if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil {
@@ -598,9 +686,17 @@ func listNRPTRuleKeys(root string) ([]string, error) {
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
- if err != nil {
- log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err)
+ switch {
+ case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
+ // nothing to remove, which is the normal case for a rule this config
+ // never installed
+ log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath)
return nil
+ case err != nil:
+ // anything else has to reach the caller: reporting success here would
+ // leave the rule in force while claiming it was removed, which is how a
+ // stale rule outlives the interface it points at
+ return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)
}
closer(k)
@@ -636,6 +732,20 @@ func refreshGroupPolicy() error {
return nil
}
+func parseBoolEnv(key string) bool {
+ val := os.Getenv(key)
+ if val == "" {
+ return false
+ }
+
+ parsed, err := strconv.ParseBool(val)
+ if err != nil {
+ log.Warnf("failed to parse %s=%q: %v", key, val, err)
+ return false
+ }
+ return parsed
+}
+
func closer(closer io.Closer) {
if err := closer.Close(); err != nil {
log.Errorf("failed to close: %s", err)
diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go
index 861613c95..7aef64590 100644
--- a/client/internal/dns/host_windows_test.go
+++ b/client/internal/dns/host_windows_test.go
@@ -94,6 +94,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains")
}
+// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the
+// match rule instead of a rule of its own, that .local is carved back out with
+// an empty server list, and that both go away when RouteAll is cleared or the
+// host DNS is restored.
+func TestNRPTCatchAllRule(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping registry integration test in short mode")
+ }
+
+ defer cleanupRegistryKeys(t)
+ cleanupRegistryKeys(t)
+
+ testIP := netip.MustParseAddr("100.64.0.1")
+ testGUID := "{12345678-1234-1234-1234-123456789ABC}"
+ interfacePath := InterfaceConfigPath + `\` + testGUID
+ testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
+ require.NoError(t, err, "Should create test interface registry key")
+ require.NoError(t, testKey.Close(), "close test interface registry key")
+ defer func() {
+ assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key")
+ }()
+
+ cfg := ®istryConfigurator{guid: testGUID}
+
+ matchOnly := HostDNSConfig{
+ ServerIP: testIP,
+ Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}},
+ }
+ primary := HostDNSConfig{
+ ServerIP: testIP,
+ RouteAll: true,
+ Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}},
+ }
+ firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)
+
+ // The root namespace is not a rule of its own: it rides in the match rule,
+ // which is the point of it not being a special case.
+ require.NoError(t, cfg.applyDNSConfig(matchOnly, nil))
+ names := ruleNamespaces(t, firstRule)
+ assert.Contains(t, names, ".example.com")
+ assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace")
+
+ require.NoError(t, cfg.applyDNSConfig(primary, nil))
+ names = ruleNamespaces(t, firstRule)
+ assert.Contains(t, names, ".example.com")
+ assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule")
+
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE)
+ require.NoError(t, err)
+ servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey)
+ require.NoError(t, err)
+ assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver")
+ require.NoError(t, k.Close(), "close match rule key")
+
+ // .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a
+ // rule of its own — it is the one rule with a different server list.
+ ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE)
+ require.NoError(t, err, "exemption rule should exist once the root namespace is claimed")
+
+ exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey)
+ require.NoError(t, err)
+ assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace")
+
+ exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey)
+ require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule")
+ assert.Empty(t, exemptServers, "an exemption rule lists no servers")
+
+ exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey)
+ require.NoError(t, err)
+ assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption")
+ require.NoError(t, ek.Close(), "close exemption rule key")
+
+ require.NoError(t, cfg.applyDNSConfig(matchOnly, nil))
+ names = ruleNamespaces(t, firstRule)
+ assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace")
+
+ exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath)
+ require.NoError(t, err)
+ assert.False(t, exists, "exemption rule should go with the namespace it carves out of")
+
+ require.NoError(t, cfg.applyDNSConfig(primary, nil))
+ require.NoError(t, cfg.restoreHostDNS())
+ exists, err = registryKeyExists(firstRule)
+ require.NoError(t, err)
+ assert.False(t, exists, "restore should leave no rule behind")
+}
+
+// ruleNamespaces returns the namespaces an NRPT rule key claims.
+func ruleNamespaces(t *testing.T, path string) []string {
+ t.Helper()
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
+ require.NoError(t, err, "rule key %s should exist", path)
+ defer k.Close()
+
+ names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey)
+ require.NoError(t, err)
+ return names
+}
+
+// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION
+// leaves the root namespace unclaimed, so no rule is written for a RouteAll
+// config that carries no match domains.
+func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping registry integration test in short mode")
+ }
+
+ defer cleanupRegistryKeys(t)
+ cleanupRegistryKeys(t)
+
+ t.Setenv(envLegacyDNSResolution, "true")
+
+ testGUID := "{12345678-1234-1234-1234-123456789ABC}"
+ interfacePath := InterfaceConfigPath + `\` + testGUID
+ testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
+ require.NoError(t, err, "Should create test interface registry key")
+ require.NoError(t, testKey.Close(), "close test interface registry key")
+ defer func() {
+ assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key")
+ }()
+
+ cfg := ®istryConfigurator{guid: testGUID}
+ config := HostDNSConfig{
+ ServerIP: netip.MustParseAddr("100.64.0.1"),
+ RouteAll: true,
+ }
+
+ require.NoError(t, cfg.applyDNSConfig(config, nil))
+
+ // RouteAll with no match domains and the switch set leaves nothing to write.
+ exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath))
+ require.NoError(t, err)
+ assert.False(t, exists, "no rule should be written when the legacy env var is set")
+
+ exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath)
+ require.NoError(t, err)
+ assert.False(t, exists, "no exemption without a claimed root namespace")
+}
+
func registryKeyExists(path string) (bool, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
if err != nil {
diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go
index e15cc8fb7..031431efe 100644
--- a/client/internal/dns_test.go
+++ b/client/internal/dns_test.go
@@ -8,7 +8,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/dns"
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestCreatePTRRecord_IPv4(t *testing.T) {
@@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) {
assert.Len(t, reverseZone.Records, 1)
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
}
+
+// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag
+// through the legacy DNSConfig path. A non-authoritative zone is match-only:
+// the local resolver falls through to the upstream for an in-zone name it does
+// not define. The built-in peer zone is the authoritative one and must stay
+// that way, so the flag has to travel per zone rather than be derived.
+func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) {
+ config := toDNSConfig(&mgmProto.DNSConfig{
+ ServiceEnable: true,
+ CustomZones: []*mgmProto.CustomZone{
+ {
+ Domain: "netbird.cloud.",
+ Records: []*mgmProto.SimpleRecord{
+ {Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"},
+ },
+ },
+ {
+ Domain: "corp.internal.",
+ NonAuthoritative: true,
+ SearchDomainDisabled: true,
+ Records: []*mgmProto.SimpleRecord{
+ {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
+ },
+ },
+ },
+ }, wgaddr.Address{
+ IP: netip.MustParseAddr("100.64.0.1"),
+ Network: netip.MustParsePrefix("100.64.0.0/16"),
+ })
+
+ zones := make(map[string]nbdns.CustomZone, len(config.CustomZones))
+ for _, zone := range config.CustomZones {
+ zones[zone.Domain] = zone
+ }
+
+ peerZone, ok := zones["netbird.cloud."]
+ require.True(t, ok, "peer zone must survive")
+ assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative")
+
+ accountZone, ok := zones["corp.internal."]
+ require.True(t, ok, "account zone must survive")
+ assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed")
+ assert.True(t, accountZone.SearchDomainDisabled)
+}
+
+// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause
+// in toDNSConfig: a config carrying exactly one zone is treated as
+// authoritative no matter what the server said, because servers that predate
+// the NonAuthoritative field send only the peer FQDN zone.
+//
+// The clause can only ever downgrade an explicit true to false, so a server
+// that legitimately sends a single non-authoritative zone — an account whose
+// only zone is a custom one, with no peer records to build the built-in zone
+// from — gets that zone's whole apex black-holed on the client. Real accounts
+// always carry the peer zone alongside, which is why this is latent. Narrowing
+// it needs a way to tell "unset" from "false" on the wire, or the account
+// domain passed down here; until then this test states the contract so a
+// change to it is deliberate.
+func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) {
+ config := toDNSConfig(&mgmProto.DNSConfig{
+ ServiceEnable: true,
+ CustomZones: []*mgmProto.CustomZone{
+ {
+ Domain: "corp.internal.",
+ NonAuthoritative: true,
+ Records: []*mgmProto.SimpleRecord{
+ {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
+ },
+ },
+ },
+ }, wgaddr.Address{
+ IP: netip.MustParseAddr("100.64.0.1"),
+ Network: netip.MustParsePrefix("100.64.0.0/16"),
+ })
+
+ require.NotEmpty(t, config.CustomZones)
+ assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain)
+ assert.False(t, config.CustomZones[0].NonAuthoritative,
+ "a lone zone is forced authoritative for pre-NonAuthoritative servers")
+
+ // The reverse zone the config gains afterwards must not feed back into the
+ // decision: the compat gate counts the zones the server sent.
+ require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix")
+ assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain)
+}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index dd9b2eff1..3484c0476 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -138,6 +138,7 @@ type EngineConfig struct {
RosenpassPermissive bool
ServerSSHAllowed bool
+ RemoteJobsAllowed bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -1268,6 +1269,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
+ &e.config.RemoteJobsAllowed,
)
}
@@ -1355,6 +1357,13 @@ func (e *Engine) receiveJobEvents() {
ID: msg.ID,
Status: mgmProto.JobStatus_failed,
}
+ // Remote jobs are an explicit opt-in. When not enabled on this
+ // peer, every job is refused before any work is done.
+ if !e.config.RemoteJobsAllowed {
+ log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)")
+ resp.Reason = []byte("remote jobs are not enabled on this peer")
+ return &resp
+ }
switch params := msg.WorkloadParameters.(type) {
case *mgmProto.JobRequest_Bundle:
bundleResult, err := e.handleBundle(params.Bundle)
@@ -1384,7 +1393,25 @@ func (e *Engine) receiveJobEvents() {
}
func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
- log.Infof("handle remote debug bundle request: %s", params.String())
+ // The upload URL can carry a host, credentials, or query tokens, so it is
+ // kept out of the info-level line; the full parameters stay available at
+ // debug level for troubleshooting.
+ log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d",
+ params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime())
+ log.Debugf("remote debug bundle request parameters: %s", params.String())
+
+ // Resolve the upload destination: an MDM override, when set, takes
+ // precedence over the management-supplied URL. Both are validated the same
+ // way; an empty result falls back to the default upload server downstream.
+ uploadURL := params.GetUploadUrl()
+ if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" {
+ log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value")
+ uploadURL = override
+ }
+ if err := validateBundleUploadURL(uploadURL); err != nil {
+ return nil, err
+ }
+
syncResponse, err := e.GetLatestSyncResponse()
if err != nil {
log.Warnf("get latest sync response: %v", err)
@@ -1412,7 +1439,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
waitFor := time.Duration(params.BundleForTime) * time.Minute
- uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
+ uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL)
if err != nil {
return nil, err
}
@@ -1425,6 +1452,16 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
return response, nil
}
+// validateBundleUploadURL sanity-checks a management-supplied upload URL for a
+// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL
+// so the executor and the MDM policy override share one definition of the rule
+// (empty accepted; otherwise a well-formed https URL with a host) and cannot
+// drift. The host is deliberately left unconstrained pending a decision on
+// management-directed uploads.
+func validateBundleUploadURL(raw string) error {
+ return profilemanager.ValidateBundleUploadURL(raw)
+}
+
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
// E.g. when a new peer has been registered and we are allowed to connect to it.
func (e *Engine) receiveManagementEvents() {
diff --git a/client/internal/engine_bundle_test.go b/client/internal/engine_bundle_test.go
new file mode 100644
index 000000000..20b40a8a6
--- /dev/null
+++ b/client/internal/engine_bundle_test.go
@@ -0,0 +1,36 @@
+package internal
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestValidateBundleUploadURL covers the sanity check applied to a
+// management-supplied upload URL before a remote debug bundle is generated.
+func TestValidateBundleUploadURL(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ raw string
+ wantErr bool
+ }{
+ {name: "empty falls back to default", raw: ""},
+ {name: "https with host", raw: "https://upload.debug.netbird.io/upload"},
+ {name: "https self-hosted host", raw: "https://upload.example.com"},
+ {name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true},
+ {name: "missing host rejected", raw: "https:///upload", wantErr: true},
+ {name: "port-only authority rejected", raw: "https://:443", wantErr: true},
+ {name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true},
+ {name: "garbage rejected", raw: "://not a url", wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := validateBundleUploadURL(tc.raw)
+ if tc.wantErr {
+ require.Error(t, err, "an invalid upload URL must be rejected")
+ return
+ }
+ assert.NoError(t, err, "a valid or empty upload URL must be accepted")
+ })
+ }
+}
diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go
index 032992464..1428b742c 100644
--- a/client/internal/engine_privileged_test.go
+++ b/client/internal/engine_privileged_test.go
@@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
if err != nil {
return nil, "", err
diff --git a/client/internal/localmetrics/localmetrics.go b/client/internal/localmetrics/localmetrics.go
new file mode 100644
index 000000000..f829fa132
--- /dev/null
+++ b/client/internal/localmetrics/localmetrics.go
@@ -0,0 +1,274 @@
+// Package localmetrics exposes client connection state as a local
+// Prometheus /metrics endpoint.
+package localmetrics
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "net/netip"
+ "sync"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ dto "github.com/prometheus/client_model/go"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+// DefaultListenAddress is used when local metrics are enabled without an explicit address.
+const DefaultListenAddress = "127.0.0.1:9191"
+
+const (
+ shutdownTimeout = 3 * time.Second
+ readHeaderTimeout = 5 * time.Second
+ readTimeout = 10 * time.Second
+ writeTimeout = 30 * time.Second
+ idleTimeout = time.Minute
+)
+
+// statusSource provides the connection state snapshots the collector reads on scrape.
+type statusSource interface {
+ GetPeerStates() []peer.State
+ GetManagementState() peer.ManagementState
+ GetSignalState() peer.SignalState
+}
+
+// GathererProvider returns the current client metrics gatherer, or nil when
+// no engine is running. It is called on every scrape.
+type GathererProvider func() prometheus.Gatherer
+
+// Manager runs the local /metrics HTTP endpoint according to the active
+// client configuration. Reconcile is safe to call on every config change.
+type Manager struct {
+ status statusSource
+ clientMetrics GathererProvider
+
+ mu sync.Mutex
+ srv *http.Server
+ addr string
+}
+
+// NewManager creates a manager that serves metrics from status and
+// clientMetrics and shuts down when ctx is canceled.
+func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
+ m := &Manager{status: status, clientMetrics: clientMetrics}
+ go func() {
+ <-ctx.Done()
+ m.Stop()
+ }()
+ return m
+}
+
+// Reconcile starts, stops, or restarts the metrics endpoint to match the
+// desired state. An empty addr falls back to DefaultListenAddress.
+func (m *Manager) Reconcile(enabled bool, addr string) {
+ if addr == "" {
+ addr = DefaultListenAddress
+ }
+ warnIfNotLoopback(addr)
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if !enabled {
+ m.stop()
+ return
+ }
+ if m.srv != nil && m.addr == addr {
+ return
+ }
+ m.stop()
+
+ registry := prometheus.NewRegistry()
+ registry.MustRegister(newCollector(m.status))
+
+ gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
+ if m.clientMetrics == nil {
+ return nil, nil
+ }
+ g := m.clientMetrics()
+ if g == nil {
+ return nil, nil
+ }
+ return g.Gather()
+ })}
+
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadHeaderTimeout: readHeaderTimeout,
+ ReadTimeout: readTimeout,
+ WriteTimeout: writeTimeout,
+ IdleTimeout: idleTimeout,
+ }
+ m.srv = srv
+ m.addr = addr
+
+ log.Infof("serving local metrics on http://%s/metrics", addr)
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Errorf("failed to serve local metrics on %s: %v", addr, err)
+ m.clear(srv)
+ }
+ }()
+}
+
+// clear drops the reference to srv so a later Reconcile with the same
+// address restarts it. A newer server may already have replaced it, in
+// which case the reference must stay.
+func (m *Manager) clear(srv *http.Server) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.srv != srv {
+ return
+ }
+ m.srv = nil
+ m.addr = ""
+}
+
+// Stop shuts down the metrics endpoint if it is running.
+func (m *Manager) Stop() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.stop()
+}
+
+// stop shuts down the running server. Callers must hold m.mu.
+func (m *Manager) stop() {
+ if m.srv == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if err := m.srv.Shutdown(ctx); err != nil {
+ log.Debugf("failed to shut down local metrics server: %v", err)
+ }
+ m.srv = nil
+ m.addr = ""
+}
+
+// collector converts status recorder snapshots into Prometheus metrics at scrape time.
+type collector struct {
+ status statusSource
+
+ managementConnected *prometheus.Desc
+ signalConnected *prometheus.Desc
+ peersTotal *prometheus.Desc
+ peersConnected *prometheus.Desc
+ peerLatency *prometheus.Desc
+}
+
+func newCollector(status statusSource) *collector {
+ return &collector{
+ status: status,
+ managementConnected: prometheus.NewDesc(
+ "netbird_management_connected",
+ "Whether the client is connected to the management service (1 connected, 0 disconnected).",
+ nil, nil,
+ ),
+ signalConnected: prometheus.NewDesc(
+ "netbird_signal_connected",
+ "Whether the client is connected to the signal service (1 connected, 0 disconnected).",
+ nil, nil,
+ ),
+ peersTotal: prometheus.NewDesc(
+ "netbird_peers",
+ "Number of peers known to this client.",
+ nil, nil,
+ ),
+ peersConnected: prometheus.NewDesc(
+ "netbird_peers_connected",
+ "Number of connected peers by connection type.",
+ []string{"connection_type"}, nil,
+ ),
+ peerLatency: prometheus.NewDesc(
+ "netbird_peer_latency_seconds",
+ "Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
+ []string{"peer"}, nil,
+ ),
+ }
+}
+
+// Describe implements prometheus.Collector.
+func (c *collector) Describe(ch chan<- *prometheus.Desc) {
+ ch <- c.managementConnected
+ ch <- c.signalConnected
+ ch <- c.peersTotal
+ ch <- c.peersConnected
+ ch <- c.peerLatency
+}
+
+// Collect implements prometheus.Collector.
+func (c *collector) Collect(ch chan<- prometheus.Metric) {
+ ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
+ ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
+
+ peers := c.status.GetPeerStates()
+ ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
+
+ var p2p, relayed float64
+ for _, p := range peers {
+ if p.ConnStatus != peer.StatusConnected {
+ continue
+ }
+ if p.Relayed {
+ relayed++
+ continue
+ }
+ p2p++
+
+ if latency := p.Latency.Seconds(); latency > 0 {
+ ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
+ }
+ }
+ ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
+ ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
+}
+
+func boolToFloat(b bool) float64 {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+// IsLoopback reports whether addr binds the endpoint to the local host only.
+// An empty address means DefaultListenAddress. It fails closed: an address
+// that cannot be confirmed loopback, including an unparseable one, is not.
+func IsLoopback(addr string) bool {
+ if addr == "" {
+ addr = DefaultListenAddress
+ }
+
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ return false
+ }
+ if host == "localhost" {
+ return true
+ }
+
+ ip, err := netip.ParseAddr(host)
+ if err != nil {
+ return false
+ }
+ return ip.Unmap().IsLoopback()
+}
+
+// warnIfNotLoopback logs a warning when the listen address cannot be
+// confirmed to be local-only, since the endpoint exposes peer and
+// connectivity details without authentication.
+func warnIfNotLoopback(addr string) {
+ if IsLoopback(addr) {
+ return
+ }
+ log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
+}
diff --git a/client/internal/localmetrics/localmetrics_test.go b/client/internal/localmetrics/localmetrics_test.go
new file mode 100644
index 000000000..727137077
--- /dev/null
+++ b/client/internal/localmetrics/localmetrics_test.go
@@ -0,0 +1,151 @@
+package localmetrics
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/peer"
+)
+
+type stubStatus struct {
+ peers []peer.State
+ management peer.ManagementState
+ signal peer.SignalState
+}
+
+func (s *stubStatus) GetPeerStates() []peer.State { return s.peers }
+func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
+func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal }
+
+func testStatus() *stubStatus {
+ return &stubStatus{
+ management: peer.ManagementState{Connected: true},
+ signal: peer.SignalState{Connected: true},
+ peers: []peer.State{
+ {FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
+ {FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
+ {FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
+ {FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
+ },
+ }
+}
+
+func TestCollector(t *testing.T) {
+ c := newCollector(testStatus())
+
+ expected := `
+# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
+# TYPE netbird_management_connected gauge
+netbird_management_connected 1
+# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
+# TYPE netbird_peer_latency_seconds gauge
+netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
+netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
+# HELP netbird_peers Number of peers known to this client.
+# TYPE netbird_peers gauge
+netbird_peers 4
+# HELP netbird_peers_connected Number of connected peers by connection type.
+# TYPE netbird_peers_connected gauge
+netbird_peers_connected{connection_type="p2p"} 2
+netbird_peers_connected{connection_type="relay"} 1
+# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
+# TYPE netbird_signal_connected gauge
+netbird_signal_connected 1
+`
+ require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
+}
+
+func TestServe(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "must find a free port")
+ addr := ln.Addr().String()
+ require.NoError(t, ln.Close())
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ m := NewManager(ctx, testStatus(), nil)
+ m.Reconcile(true, addr)
+
+ var body string
+ require.Eventually(t, func() bool {
+ resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ return false
+ }
+ body = string(data)
+ return true
+ }, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
+
+ assert.Contains(t, body, "netbird_peers 4")
+ assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
+ assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
+}
+
+// A server that never came up must not be remembered, otherwise reconciling the
+// same address again is a no-op and the endpoint never recovers.
+func TestReconcileForgetsAFailedServer(t *testing.T) {
+ blocker, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "must find a free port")
+ t.Cleanup(func() { _ = blocker.Close() })
+ addr := blocker.Addr().String()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ m := NewManager(ctx, testStatus(), nil)
+ m.Reconcile(true, addr)
+
+ require.Eventually(t, func() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.srv == nil && m.addr == ""
+ }, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped")
+
+ require.NoError(t, blocker.Close())
+ m.Reconcile(true, addr)
+
+ require.Eventually(t, func() bool {
+ resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode == http.StatusOK
+ }, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind")
+}
+
+func TestIsLoopback(t *testing.T) {
+ tests := map[string]bool{
+ "": true,
+ "127.0.0.1:9191": true,
+ "127.9.9.9:9191": true,
+ "[::1]:9191": true,
+ "[::ffff:127.0.0.1]:9191": true,
+ "localhost:9191": true,
+ "0.0.0.0:9191": false,
+ "[::]:9191": false,
+ "192.168.1.10:9191": false,
+ "not-an-address": false,
+ "example.com:9191": false,
+ }
+
+ for addr, want := range tests {
+ t.Run(addr, func(t *testing.T) {
+ assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr)
+ })
+ }
+}
diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 4ba14bf44..717544f6a 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
isReconnection bool,
timestamps ConnectionStageTimestamps,
) {
- var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
-
- if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
- signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
- }
-
- if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
- connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
- }
-
- if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
- totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
- }
-
- attemptType := "initial"
- if isReconnection {
- attemptType = "reconnection"
- }
+ signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
connTypeStr := connectionType.String()
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
agentInfo.DeploymentType.String(),
connTypeStr,
- attemptType,
+ attemptType(isReconnection),
agentInfo.Version,
agentInfo.OS,
agentInfo.Arch,
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
m.trimLocked()
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
- agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
+ agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
}
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index cfe477107..5edf1d9c7 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct {
WgHandshakeSuccess time.Time
}
+// Durations returns the stage durations in seconds. A duration is zero when
+// either of its timestamps is missing.
+func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
+ if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
+ signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
+ }
+ if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
+ connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
+ }
+ if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
+ total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
+ }
+ return signalingToConnection, connectionToWgHandshake, total
+}
+
// String returns a human-readable representation of the connection stage timestamps
func (c ConnectionStageTimestamps) String() string {
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
@@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() {
c.wg.Wait()
c.push.Store(nil)
}
+
+// attemptType returns the metric label for an initial vs reconnection attempt.
+func attemptType(isReconnection bool) string {
+ if isReconnection {
+ return "reconnection"
+ }
+ return "initial"
+}
diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go
index 927ab51d1..3798adab6 100644
--- a/client/internal/metrics/metrics_default.go
+++ b/client/internal/metrics/metrics_default.go
@@ -2,10 +2,24 @@
package metrics
+import "github.com/prometheus/client_golang/prometheus"
+
// NewClientMetrics creates a new ClientMetrics instance
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
return &ClientMetrics{
- impl: newInfluxDBMetrics(),
+ impl: newPrometheusMetrics(newInfluxDBMetrics()),
agentInfo: agentInfo,
}
}
+
+// PrometheusGatherer returns the registry with the mirrored Prometheus
+// metrics, or nil when unavailable.
+func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
+ if c == nil {
+ return nil
+ }
+ if pm, ok := c.impl.(*prometheusMetrics); ok {
+ return pm.Gatherer()
+ }
+ return nil
+}
diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go
new file mode 100644
index 000000000..7f5020ea9
--- /dev/null
+++ b/client/internal/metrics/prometheus.go
@@ -0,0 +1,119 @@
+//go:build !js
+
+package metrics
+
+import (
+ "context"
+ "io"
+ "strconv"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+// prometheusMetrics mirrors recorded client metrics into a Prometheus
+// registry for the local /metrics endpoint, then delegates to the wrapped
+// implementation. Export and Reset pass through untouched: Prometheus
+// metrics are cumulative and pull-based.
+type prometheusMetrics struct {
+ next metricsImplementation
+ registry *prometheus.Registry
+
+ connectionStages *prometheus.HistogramVec
+ syncDuration prometheus.Histogram
+ syncPhaseDuration *prometheus.HistogramVec
+ loginDuration *prometheus.HistogramVec
+}
+
+func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
+ connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
+
+ m := &prometheusMetrics{
+ next: next,
+ registry: prometheus.NewRegistry(),
+ connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "netbird_peer_connection_stage_duration_seconds",
+ Help: "Duration of peer connection establishment stages.",
+ Buckets: connectionBuckets,
+ }, []string{"stage", "connection_type", "attempt_type"}),
+ syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
+ Name: "netbird_sync_duration_seconds",
+ Help: "Duration of management sync message processing.",
+ Buckets: prometheus.DefBuckets,
+ }),
+ syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "netbird_sync_phase_duration_seconds",
+ Help: "Duration of individual sync processing phases.",
+ Buckets: prometheus.DefBuckets,
+ }, []string{"phase"}),
+ loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "netbird_login_duration_seconds",
+ Help: "Duration of logins to the management service.",
+ Buckets: prometheus.DefBuckets,
+ }, []string{"success"}),
+ }
+
+ m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
+ return m
+}
+
+// Gatherer returns the registry holding the mirrored metrics.
+func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
+ return m.registry
+}
+
+// RecordConnectionStages implements metricsImplementation.
+func (m *prometheusMetrics) RecordConnectionStages(
+ ctx context.Context,
+ agentInfo AgentInfo,
+ connectionPairID string,
+ connectionType ConnectionType,
+ isReconnection bool,
+ timestamps ConnectionStageTimestamps,
+) {
+ attempt := attemptType(isReconnection)
+ connType := connectionType.String()
+
+ signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
+ if signalingToConnection > 0 {
+ m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
+ }
+ if connectionToWgHandshake > 0 {
+ m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
+ }
+ if total > 0 {
+ m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
+ }
+
+ m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
+}
+
+// RecordSyncDuration implements metricsImplementation.
+func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
+ m.syncDuration.Observe(duration.Seconds())
+ m.next.RecordSyncDuration(ctx, agentInfo, duration)
+}
+
+// RecordSyncPhase implements metricsImplementation.
+func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+ m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
+ m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
+// RecordLoginDuration implements metricsImplementation.
+func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
+ m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
+ m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
+}
+
+// Export implements metricsImplementation by delegating to the wrapped
+// implementation; Prometheus metrics are pulled via the registry instead.
+func (m *prometheusMetrics) Export(w io.Writer) error {
+ return m.next.Export(w)
+}
+
+// Reset implements metricsImplementation by delegating to the wrapped
+// implementation; Prometheus metrics must not be cleared on push.
+func (m *prometheusMetrics) Reset() {
+ m.next.Reset()
+}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index 24e3e7fac..bf36b944b 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
return maps.Clone(d.resolvedDomainsStates)
}
+// GetPeerStates returns a snapshot of all known peer states, including offline peers.
+func (d *Status) GetPeerStates() []State {
+ d.mux.RLock()
+ defer d.mux.RUnlock()
+
+ states := make([]State, 0, d.numOfPeers())
+ for _, state := range d.peers {
+ states = append(states, state)
+ }
+ return append(states, d.offlinePeers...)
+}
+
// GetFullStatus gets full status
func (d *Status) GetFullStatus() FullStatus {
fullStatus := FullStatus{
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 29404d413..82dff0d6f 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
}
+// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with
+// GetFullStatus: offline peers are known peers, so a consumer counting peers
+// must see the same total the status command reports.
+func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1"))
+ status.ReplaceOfflinePeers([]State{
+ {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle},
+ })
+
+ states := status.GetPeerStates()
+ req.Len(states, 2, "snapshot must carry both the online and the offline peer")
+
+ keys := make([]string, 0, len(states))
+ for _, s := range states {
+ keys = append(keys, s.PubKey)
+ }
+ req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go
index 83cac13f5..d17f6e693 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -64,6 +64,9 @@ type WorkerICE struct {
// portForwardAttempted tracks if we've already tried port forwarding this session
portForwardAttempted bool
+
+ // dialFunc, when non-nil, replaces agentDial in connect(). Only for tests.
+ dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error)
}
func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
@@ -123,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.log.Errorf("failed to create new session ID: %s", err)
}
w.sessionID = sessionID
- w.agent = nil
+ w.abandonNegotiation()
}
var preferredCandidateTypes []ice.CandidateType
@@ -151,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.remoteSessionID = ""
}
- go w.connect(dialerCtx, agent, remoteOfferAnswer)
+ // Capture the cancel func at spawn time: connect reads it from the argument
+ // instead of the field, which a newer OnNewOffer may already have replaced.
+ go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
}
// OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
@@ -200,16 +205,16 @@ func (w *WorkerICE) Close() {
w.muxAgent.Lock()
defer w.muxAgent.Unlock()
- if w.agent == nil {
- return
+ if w.agent != nil {
+ w.agentDialerCancel()
+ if err := w.agent.Close(); err != nil {
+ w.log.Warnf("failed to close ICE agent: %s", err)
+ }
}
-
- w.agentDialerCancel()
- if err := w.agent.Close(); err != nil {
- w.log.Warnf("failed to close ICE agent: %s", err)
- }
-
- w.agent = nil
+ // Unconditional: a dial goroutine racing this Close skips its own cleanup
+ // (closeAgent finds a nil agent), so the flags must be dropped here too or
+ // the reconnection guard reads the stale state as Connected forever.
+ w.abandonNegotiation()
}
func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
@@ -247,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID {
// will block until connection succeeded
// but it won't release if ICE Agent went into Disconnected or Failed state,
// so we have to cancel it with the provided context once agent detected a broken connection
-func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
+func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
w.log.Debugf("gather candidates")
if err := agent.GatherCandidates(); err != nil {
w.log.Warnf("failed to gather candidates: %s", err)
- w.closeAgent(agent, w.agentDialerCancel)
+ w.closeAgent(agent, dialerCancel)
return
}
w.log.Debugf("agent dial")
- remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
+ dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) {
+ return w.agentDial(ctx, agent, remoteOfferAnswer)
+ }
+ if w.dialFunc != nil {
+ dial = w.dialFunc
+ }
+ remoteConn, err := dial(ctx, agent, remoteOfferAnswer)
if err != nil {
w.log.Debugf("failed to dial the remote peer: %s", err)
- w.closeAgent(agent, w.agentDialerCancel)
+ w.closeAgent(agent, dialerCancel)
return
}
w.log.Debugf("agent dial succeeded")
+ // A newer negotiation may have replaced our agent while agentDial was
+ // blocked. Drop the dead connection before running pair retrieval, port
+ // punching or candidate work against a closed agent. The commit-point
+ // check below still guards a replacement arriving after this point.
+ w.muxAgent.Lock()
+ stale := w.agent != agent
+ w.muxAgent.Unlock()
+ if stale {
+ if err := remoteConn.Close(); err != nil {
+ w.log.Warnf("failed to close stale ICE connection: %s", err)
+ }
+ w.log.Warnf("discarding connection from a stale ICE negotiation")
+ return
+ }
+
pair, err := agent.GetSelectedCandidatePair()
if err != nil {
- w.closeAgent(agent, w.agentDialerCancel)
+ w.closeAgent(agent, dialerCancel)
return
}
if pair == nil {
w.log.Warnf("selected candidate pair is nil, cannot proceed")
- w.closeAgent(agent, w.agentDialerCancel)
+ w.closeAgent(agent, dialerCancel)
return
}
@@ -301,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
w.muxAgent.Lock()
+ // Authoritative ownership guard: a negotiation that lost w.agent to a newer
+ // one between the post-dial check and the commit must not clear agentConnecting,
+ // record lastSuccess or report the connection, so the state commit has to be
+ // atomic with the check.
+ if w.agent != agent {
+ w.muxAgent.Unlock()
+ if err := remoteConn.Close(); err != nil {
+ w.log.Warnf("failed to close stale ICE connection: %s", err)
+ }
+ w.log.Warnf("discarding connection from a stale ICE negotiation")
+ return
+ }
w.agentConnecting = false
w.lastSuccess = time.Now()
w.muxAgent.Unlock()
// todo: the potential problem is a race between the onConnectionStateChange
+ // and the delivery below: after this unlock, a newer offer can replace
+ // w.agent before onICEConnectionIsReady runs, delivering this (now stale)
+ // connection. The newer negotiation overwrites it with its own delivery,
+ // so the window only ever downgrades an endpoint transiently.
w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
}
@@ -321,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
sessionChanged := w.remoteSessionChanged
w.remoteSessionChanged = false
+ // Only the owner of the current session may reset its state: a stale dial
+ // goroutine waking after a newer attempt must not clobber it.
if w.agent == agent {
- // consider to remove from here and move to the OnNewOffer
sessionID, err := NewICESessionID()
if err != nil {
w.log.Errorf("failed to create new session ID: %s", err)
}
w.sessionID = sessionID
- w.agent = nil
- w.agentConnecting = false
- w.remoteSessionID = ""
+ w.abandonNegotiation()
}
return sessionChanged
}
+// abandonNegotiation drops all recorded ICE session state so the worker treats the
+// next offer as a fresh start instead of a duplicate of a dead negotiation. The
+// agent and agentConnecting flags must change together: leaving one stale wedges
+// the reconnection guard into reporting Connected forever. It neither cancels an
+// in-flight dial nor closes an agent — callers dispose of those themselves first,
+// so a stale goroutine can never cancel another session's dial through this path.
+// Caller must hold muxAgent.
+func (w *WorkerICE) abandonNegotiation() {
+ w.agent = nil
+ w.agentConnecting = false
+ w.remoteSessionID = ""
+}
+
func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
// wait local endpoint configuration
time.Sleep(time.Second)
diff --git a/client/internal/peer/worker_ice_close_test.go b/client/internal/peer/worker_ice_close_test.go
new file mode 100644
index 000000000..834a4dd6d
--- /dev/null
+++ b/client/internal/peer/worker_ice_close_test.go
@@ -0,0 +1,257 @@
+package peer
+
+import (
+ "context"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ sProto "github.com/netbirdio/netbird/shared/signal/proto"
+)
+
+// stubSignalClient satisfies signal.Client as a no-op so the candidate
+// goroutine spawned by a real GatherCandidates never dereferences a nil
+// signaler in tests.
+type stubSignalClient struct{}
+
+func (stubSignalClient) Close() error { return nil }
+func (stubSignalClient) StreamConnected() bool { return false }
+func (stubSignalClient) GetStatus() signal.Status { return signal.StreamDisconnected }
+func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil }
+func (stubSignalClient) Ready() bool { return false }
+func (stubSignalClient) IsHealthy() bool { return false }
+func (stubSignalClient) WaitStreamConnected(context.Context) {}
+func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error { return nil }
+func (stubSignalClient) Send(*sProto.Message) error { return nil }
+func (stubSignalClient) SetOnReconnectedListener(func()) {}
+
+// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling.
+func newTestWorkerICE(t *testing.T) *WorkerICE {
+ t.Helper()
+
+ config := connConf
+ stunTurn := &icemaker.StunTurn{}
+ stunTurn.Store(nil)
+ config.ICEConfig.StunTurn = stunTurn
+
+ w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil,
+ NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false)
+ require.NoError(t, err, "worker setup must succeed")
+ return w
+}
+
+// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race
+// through the real dial goroutine instead of simulating its cleanup.
+//
+// The real-world sequence this models:
+// 1. OnNewOffer starts a negotiation: agent set, agentConnecting = true,
+// go connect()
+// 2. The network dies and connect() stays blocked inside GatherCandidates/Dial
+// 3. A WG handshake timeout calls Close(): the agent is released and the dial
+// context cancelled, but agentConnecting is not reset
+// 4. The real goroutine wakes with an error and runs its own cleanup
+// (closeAgent), where `w.agent == agent` is now false, so the flag reset
+// is skipped
+//
+// There is no remote responder, so Dial can never succeed: whatever point the
+// goroutine is at, closing first forces it down the error path. Before the fix
+// the flag stays true forever and the deadline below expires.
+func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) {
+ w := newTestWorkerICE(t)
+
+ sid := ICESessionID("test-session-id")
+ w.OnNewOffer(&OfferAnswer{
+ IceCredentials: IceCredentials{
+ UFrag: "testufrag",
+ Pwd: "testpwdtestpwdtestpwd12",
+ },
+ SessionID: &sid,
+ })
+ require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress")
+
+ // Teardown wins the race while connect() is still running.
+ w.Close()
+
+ // Close drops the flags synchronously, so the assertion below does not
+ // converge on the goroutine: the deadline only absorbs the dial goroutine
+ // waking up in the background, proving nothing re-wedges it afterwards.
+ require.Eventually(t, func() bool {
+ return !w.InProgress()
+ }, 10*time.Second, 50*time.Millisecond,
+ "Close must leave the negotiation idle even while the dial goroutine is still winding down")
+
+ // abandonNegotiation owns these three fields together; the worker is idle
+ // only when all of them are dropped.
+ w.muxAgent.Lock()
+ defer w.muxAgent.Unlock()
+ assert.Nil(t, w.agent, "no agent may survive the teardown")
+ assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent")
+ assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger")
+}
+
+// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose
+// agent is already gone but whose flag is stuck on true, e.g. after an aborted
+// recreate in OnNewOffer or after a first Close raced a dial goroutine.
+func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) {
+ w := newTestWorkerICE(t)
+
+ w.muxAgent.Lock()
+ w.agentConnecting = true
+ w.muxAgent.Unlock()
+
+ w.Close()
+
+ assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent")
+
+ w.muxAgent.Lock()
+ defer w.muxAgent.Unlock()
+ assert.Nil(t, w.agent)
+ assert.False(t, w.agentConnecting)
+ assert.Empty(t, w.remoteSessionID)
+}
+
+// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in
+// closeAgent: a late-waking dial goroutine from an older session must not reset
+// the state of a newer negotiation that reused the worker. The newer session
+// must survive wholesale - agent, flag and remote session identity alike.
+func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) {
+ w := newTestWorkerICE(t)
+ t.Cleanup(w.Close)
+
+ sidA := ICESessionID("session-a")
+ w.OnNewOffer(&OfferAnswer{
+ IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
+ SessionID: &sidA,
+ })
+ w.muxAgent.Lock()
+ oldAgent := w.agent
+ oldCancel := w.agentDialerCancel
+ w.muxAgent.Unlock()
+ require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent")
+
+ w.Close()
+
+ sidB := ICESessionID("session-b")
+ w.OnNewOffer(&OfferAnswer{
+ IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
+ SessionID: &sidB,
+ })
+ require.True(t, w.InProgress(), "the second negotiation must be in flight")
+
+ w.muxAgent.Lock()
+ newAgent := w.agent
+ w.muxAgent.Unlock()
+
+ // The old dial goroutine finally wakes and cleans up its captured agent.
+ w.closeAgent(oldAgent, oldCancel)
+
+ w.muxAgent.Lock()
+ defer w.muxAgent.Unlock()
+ assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup")
+ assert.True(t, w.agentConnecting, "the current negotiation must stay in flight")
+ // Read live under the lock: a snapshot captured before the stale cleanup
+ // would pass even if the cleanup wiped current state.
+ assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved")
+}
+
+// closeTrackConn records Close calls so a test can assert that a discarded
+// connection was actually released.
+type closeTrackConn struct {
+ net.Conn
+ closed atomic.Bool
+}
+
+func (c *closeTrackConn) Close() error {
+ c.closed.Store(true)
+ return c.Conn.Close()
+}
+
+// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard
+// in connect()'s success path: a dial that came back after a newer negotiation
+// replaced the agent must discard its connection and leave the newer session's
+// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact.
+//
+// The dial hook holds session A's goroutine open until session B is installed,
+// then returns a live connection, mimicking the vendored pion dial which hands
+// out a live *ice.Conn when a pair is selected without checking afterwards
+// whether the agent was replaced meanwhile. Releasing A's dial therefore
+// exercises the stale-success commit path deterministically instead of racing
+// real ICE.
+func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) {
+ w := newTestWorkerICE(t)
+ t.Cleanup(w.Close)
+
+ dialStarted := make(chan struct{})
+ releaseDial := make(chan struct{})
+ staleConn := &closeTrackConn{}
+
+ var calls atomic.Int32
+ w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) {
+ if calls.Add(1) == 1 {
+ // Session A: hold the goroutine open until session B is installed,
+ // then return a live connection, mimicking the vendored pion dial
+ // which hands out a live *ice.Conn once a pair is selected without
+ // re-checking whether the agent was replaced meanwhile. Releasing
+ // the dial therefore exercises the stale-success commit path
+ // deterministically instead of racing real ICE.
+ close(dialStarted)
+ <-releaseDial
+ client, _ := net.Pipe()
+ staleConn.Conn = client
+ return staleConn, nil
+ }
+ // A newer negotiation parks on its dialer context, cancelled by the
+ // t.Cleanup Close at test end.
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+
+ sidA := ICESessionID("session-a")
+ w.OnNewOffer(&OfferAnswer{
+ IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
+ SessionID: &sidA,
+ })
+ require.True(t, w.InProgress(), "session A must be in flight")
+
+ // Session A's goroutine is now parked in the dial hook.
+ <-dialStarted
+
+ sidB := ICESessionID("session-b")
+ w.OnNewOffer(&OfferAnswer{
+ IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
+ SessionID: &sidB,
+ })
+
+ w.muxAgent.Lock()
+ agentB := w.agent
+ w.lastSuccess = time.Time{}
+ w.muxAgent.Unlock()
+ require.NotNil(t, agentB, "session B must have created an ICE agent")
+ require.True(t, w.InProgress(), "session B must be in flight")
+
+ // Release session A's dial: it must be recognized as stale and discarded.
+ close(releaseDial)
+ require.Eventually(t, func() bool {
+ return staleConn.closed.Load()
+ }, 10*time.Second, 10*time.Millisecond,
+ "the stale connection must be closed by the ownership guard")
+
+ w.muxAgent.Lock()
+ defer w.muxAgent.Unlock()
+ assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent")
+ assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag")
+ assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity")
+ assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B")
+ // The commit block guards agentConnecting, lastSuccess and
+ // onICEConnectionIsReady together, so the state assertions above imply the
+ // callback never ran for session A; the nil conn would have panicked the
+ // stale goroutine on any invocation.
+}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index e1668238e..10c1758d1 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -70,6 +70,7 @@ type ConfigInput struct {
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
+ RemoteJobsAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -103,6 +104,9 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
+
+ LocalMetricsEnabled *bool
+ LocalMetricsAddress *string
}
// Config Configuration type
@@ -124,6 +128,7 @@ type Config struct {
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
+ RemoteJobsAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -144,6 +149,11 @@ type Config struct {
DNSLabels domain.List
+ // LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
+ LocalMetricsEnabled bool
+ // LocalMetricsAddress is the listen address of the local /metrics endpoint.
+ LocalMetricsAddress string
+
// SSHKey is a private SSH key in a PEM format
SSHKey string
@@ -184,6 +194,12 @@ type Config struct {
// Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"`
+ // DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override.
+ // When set, it takes precedence over the management-supplied upload URL for
+ // remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each
+ // load, never persisted.
+ DebugBundleUploadURL string `json:"-"`
+
MTU uint16
// policy is the MDM policy that produced the currently-set values for
@@ -217,6 +233,12 @@ func getConfigDir() (string, error) {
}
configDir := filepath.Join(base, "netbird")
+ // Under sudo this is the invoking user's directory and strictly read-only:
+ // anything root creates in it would be root-owned and break the user's own
+ // runs. Reads of a missing directory fall through to defaults.
+ if sudoActive() {
+ return configDir, nil
+ }
if err := os.MkdirAll(configDir, 0o755); err != nil {
return "", err
}
@@ -224,6 +246,16 @@ func getConfigDir() (string, error) {
}
func baseConfigDir() (string, error) {
+ if u, ok := sudoInvokingUser(); ok {
+ return userBaseConfigDir(u)
+ }
+ // Fail closed instead of falling through to root's own config directory:
+ // reading root's active-profile and email state for what is actually the
+ // invoking user's invocation is the very confusion this resolution exists
+ // to prevent.
+ if sudoActive() {
+ return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser))
+ }
if runtime.GOOS == "darwin" {
if u, err := user.Current(); err == nil && u.HomeDir != "" {
return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
@@ -265,7 +297,10 @@ func createNewConfig(input ConfigInput) (*Config, error) {
config := &Config{
// defaults to false only for new (post 0.26) configurations
ServerSSHAllowed: util.False(),
- WgPort: iface.DefaultWgPort,
+ // Remote jobs are an explicit opt-in and default off, including for
+ // legacy configs (a nil value materializes to false at connect time).
+ RemoteJobsAllowed: util.False(),
+ WgPort: iface.DefaultWgPort,
}
if _, err := config.apply(input); err != nil {
@@ -388,6 +423,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
+ if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
+ log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
+ config.LocalMetricsEnabled = *input.LocalMetricsEnabled
+ updated = true
+ }
+
+ if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
+ log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
+ config.LocalMetricsAddress = *input.LocalMetricsAddress
+ updated = true
+ }
+
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor
@@ -456,6 +503,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
+ if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) {
+ if *input.RemoteJobsAllowed {
+ log.Infof("enabling remote jobs")
+ } else {
+ log.Infof("disabling remote jobs")
+ }
+ config.RemoteJobsAllowed = input.RemoteJobsAllowed
+ updated = true
+ } else if config.RemoteJobsAllowed == nil {
+ // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
+ // with no value defaults to disabled rather than being turned on.
+ config.RemoteJobsAllowed = util.False()
+ updated = true
+ }
+
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
if *input.EnableSSHRoot {
log.Infof("enabling SSH root login")
@@ -665,6 +727,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
// for the key, so per-field rejection of user writes still applies).
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
config.policy = policy
+
+ // DebugBundleUploadURL is a runtime-only override re-derived from MDM on
+ // every apply. Resolve it unconditionally (before the IsEmpty early return)
+ // so a policy that drops the key, becomes empty, or carries an invalid
+ // value can never leave a previously-enforced upload target active on a
+ // reused Config instance.
+ config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy)
+
if policy.IsEmpty() {
return
}
@@ -712,12 +782,19 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
}
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
+ applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv })
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
+ applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
+
+ if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
+ config.LocalMetricsAddress = v
+ logApplied(mdm.KeyLocalMetricsAddress, v)
+ }
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
@@ -739,6 +816,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
config.LazyConnection = state
logApplied(mdm.KeyLazyConnection, state)
}
+
+}
+
+// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty
+// value is accepted — the executor falls back to the default upload service. A
+// non-empty value must be a well-formed https URL with a host; a malformed
+// value or a plaintext scheme is rejected. It deliberately does not constrain
+// which host may receive the bundle. This is the single source of truth for the
+// rule, shared by the remote-job executor (client/internal) and the MDM policy
+// override below so the two validation paths cannot drift.
+func ValidateBundleUploadURL(raw string) error {
+ if raw == "" {
+ return nil
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("parse upload URL: %w", err)
+ }
+ // Hostname(), not Host: an authority like ":443" is non-empty but has no
+ // host, and would fail the actual upload.
+ if parsed.Scheme != "https" || parsed.Hostname() == "" {
+ return fmt.Errorf("upload URL must be an https URL with a host")
+ }
+ return nil
+}
+
+// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL
+// override from the policy, returning the empty string when the policy does
+// not carry a valid KeyBundleUploadURL. An absent or invalid value fails
+// closed to "" so it falls back to the management-supplied or default upload
+// target rather than a previously-enforced one. The URL is never logged: it
+// can embed credentials or signed query tokens (KeyBundleUploadURL is in
+// mdm.SecretKeys).
+func mdmDebugBundleUploadURL(policy *mdm.Policy) string {
+ v, ok := policy.GetString(mdm.KeyBundleUploadURL)
+ if !ok || v == "" {
+ return ""
+ }
+ // Must be a well-formed https URL with a host, matching the client's
+ // remote-job upload-URL validation (shared validator, single source of truth).
+ if err := ValidateBundleUploadURL(v); err != nil {
+ log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override")
+ return ""
+ }
+ log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL)
+ return v
}
// parseURL parses and validates the URL for the named service. The URL
diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go
index c6a688ab2..f8dfddb33 100644
--- a/client/internal/profilemanager/config_mdm_test.go
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
}
+func TestApply_MDMLocalMetrics(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "config.json")
+
+ // Seed without MDM.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+ _, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: tmp,
+ LocalMetricsEnabled: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyEnableLocalMetrics: true,
+ mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
+ assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress))
+}
+
func TestApply_MDMLazyConnection(t *testing.T) {
cases := []struct {
name string
diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go
index 736ff3412..248920b5e 100644
--- a/client/internal/profilemanager/config_test.go
+++ b/client/internal/profilemanager/config_test.go
@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/util"
)
@@ -271,6 +272,83 @@ func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
}
}
+func TestUpdateConfigRemoteJobsAllowed(t *testing.T) {
+ // Unlike SSH (which defaults on for legacy configs), remote jobs are an
+ // explicit opt-in: a pre-existing config with no value materializes to off.
+ t.Run("legacy config defaults off", func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{ConfigPath: configPath})
+ require.NoError(t, err)
+ require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized")
+ assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off")
+ })
+
+ for _, tt := range []struct {
+ name string
+ input *bool
+ want bool
+ }{
+ {"enable", util.True(), true},
+ {"disable", util.False(), false},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input})
+ require.NoError(t, err)
+ require.NotNil(t, config.RemoteJobsAllowed)
+ assert.Equal(t, tt.want, *config.RemoteJobsAllowed)
+ })
+ }
+}
+
+func TestApplyMDMPolicyRemoteJobs(t *testing.T) {
+ t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) {
+ cfg := &Config{}
+ cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
+ mdm.KeyRemoteJobsAllowed: true,
+ mdm.KeyBundleUploadURL: "https://upload.example.com",
+ }))
+ require.NotNil(t, cfg.RemoteJobsAllowed)
+ assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag")
+ assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied")
+ })
+
+ t.Run("a non-https upload URL is rejected", func(t *testing.T) {
+ cfg := &Config{}
+ cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
+ mdm.KeyBundleUploadURL: "http://insecure.example.com",
+ }))
+ assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped")
+ })
+
+ t.Run("dropping the key clears a previously-applied override", func(t *testing.T) {
+ cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
+ // A replacement policy that no longer carries the key must not leave
+ // the old upload target directing bundles.
+ cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true}))
+ assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared")
+ })
+
+ t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) {
+ cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
+ // A policy that becomes empty entirely hits the IsEmpty early return;
+ // the override must still be cleared rather than surviving on the
+ // reused Config instance.
+ cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{}))
+ assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties")
+ })
+
+ t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) {
+ cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
+ cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"}))
+ assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target")
+ })
+}
+
func TestUpdateOldManagementURL(t *testing.T) {
origProber := newMgmProber
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go
new file mode 100644
index 000000000..c86a6ce43
--- /dev/null
+++ b/client/internal/profilemanager/invoking_user.go
@@ -0,0 +1,100 @@
+package profilemanager
+
+import (
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "runtime"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const envSudoUser = "SUDO_USER"
+
+var (
+ geteuid = os.Geteuid
+ lookupUser = user.Lookup
+)
+
+// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
+// the user who ran sudo, not root: privileged flags force commands through
+// sudo, and resolving profiles as root would silently switch the daemon to
+// root's (default) profile instead of the invoking user's. Privilege decisions
+// are not made here — those stay on the kernel credentials of the daemon
+// connection, which SUDO_USER (a plain environment variable) can never
+// influence; a forged value only selects a profile root could select anyway.
+func InvokingUser() (*user.User, error) {
+ if u, ok := sudoInvokingUser(); ok {
+ return u, nil
+ }
+ // Fail closed instead of falling through to root: every caller feeds this
+ // username into profile-path resolution, so a lookup failure would resolve
+ // (and create) a root-owned profile namespace and switch the daemon onto it
+ // behind the invoking user's back.
+ if sudoActive() {
+ return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
+ }
+ return user.Current()
+}
+
+// IsPlainRoot reports that the process runs as root with no usable sudo
+// context: there is no invoking user to act for, so per-user resolution falls
+// back to root's own (empty) state. Callers use it to refuse ambiguous
+// operations instead of silently acting on the wrong profile.
+func IsPlainRoot() bool {
+ if geteuid() != 0 {
+ return false
+ }
+ _, ok := sudoInvokingUser()
+ return !ok
+}
+
+// MirrorIsAuthoritative reports whether the invoking user's local
+// active-profile mirror can be trusted as the profile selector. It cannot under
+// sudo (writes to it are skipped, so it goes stale) or as plain root (there is
+// no invoking user, so it falls back to root's own default). Callers use it to
+// decide whether to read the profile from the mirror or from the daemon.
+func MirrorIsAuthoritative() bool {
+ return !sudoActive() && !IsPlainRoot()
+}
+
+// sudoInvokingUser resolves SUDO_USER when the process runs as root under
+// sudo. Returns false whenever the sudo context is absent or unusable, in
+// which case callers fall back to the process user.
+func sudoInvokingUser() (*user.User, bool) {
+ if !sudoActive() {
+ return nil, false
+ }
+ name := os.Getenv(envSudoUser)
+ u, err := lookupUser(name)
+ if err != nil {
+ log.Warnf("sudo invoking user %q lookup: %v", name, err)
+ return nil, false
+ }
+ return u, true
+}
+
+// sudoActive reports a sudo context from the environment alone: write-skip
+// decisions key off it so a transient user lookup failure can never flip a
+// run from read-only to writing root-owned files into the user's directory.
+func sudoActive() bool {
+ if geteuid() != 0 {
+ return false
+ }
+ name := os.Getenv(envSudoUser)
+ return name != "" && name != "root"
+}
+
+// userBaseConfigDir mirrors os.UserConfigDir for a user other than the process
+// owner. Environment overrides (XDG_CONFIG_HOME) cannot be honoured here: under
+// sudo the environment is root's, not the invoking user's.
+func userBaseConfigDir(u *user.User) (string, error) {
+ if u.HomeDir == "" {
+ return "", fmt.Errorf("user %s has no home directory", u.Username)
+ }
+ if runtime.GOOS == "darwin" {
+ return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
+ }
+ return filepath.Join(u.HomeDir, ".config"), nil
+}
diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go
new file mode 100644
index 000000000..54c8ad8fd
--- /dev/null
+++ b/client/internal/profilemanager/invoking_user_test.go
@@ -0,0 +1,230 @@
+package profilemanager
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
+ t.Setenv(envSudoUser, "")
+
+ got, err := InvokingUser()
+ require.NoError(t, err)
+
+ current, err := user.Current()
+ require.NoError(t, err)
+ assert.Equal(t, current.Username, got.Username)
+}
+
+func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
+ t.Setenv(envSudoUser, "")
+ _, ok := sudoInvokingUser()
+ assert.False(t, ok)
+}
+
+func TestSudoInvokingUserIgnoresRoot(t *testing.T) {
+ t.Setenv(envSudoUser, "root")
+ origEuid := geteuid
+ geteuid = func() int { return 0 }
+ t.Cleanup(func() { geteuid = origEuid })
+
+ _, ok := sudoInvokingUser()
+ assert.False(t, ok, "sudo from a root shell must not redirect anything")
+ assert.False(t, sudoActive())
+ assert.True(t, IsPlainRoot())
+}
+
+func TestSudoInvokingUserResolvesInvokingUser(t *testing.T) {
+ fakeSudo(t, filepath.Join("/home", "misha"))
+
+ u, ok := sudoInvokingUser()
+ require.True(t, ok)
+ assert.Equal(t, "misha", u.Username)
+
+ got, err := InvokingUser()
+ require.NoError(t, err)
+ assert.Equal(t, "misha", got.Username)
+
+ assert.False(t, IsPlainRoot())
+}
+
+func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
+ fakeSudo(t, filepath.Join("/home", "misha"))
+ lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
+
+ got, err := InvokingUser()
+ require.Error(t, err)
+ assert.Nil(t, got, "must not resolve to the root process user")
+}
+
+func TestProfileFilePathFailsClosedWhenSudoLookupFails(t *testing.T) {
+ profilesRoot := t.TempDir()
+ fakeSudo(t, filepath.Join("/home", "misha"))
+ lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
+
+ origDir := DefaultConfigPathDir
+ DefaultConfigPathDir = profilesRoot
+ t.Cleanup(func() { DefaultConfigPathDir = origDir })
+
+ p := &Profile{ID: "0123456789abcdef0123456789abcdef"}
+ _, err := p.FilePath()
+ require.Error(t, err)
+ assertNoEntries(t, profilesRoot)
+}
+
+func TestSudoActiveSurvivesLookupFailure(t *testing.T) {
+ fakeSudo(t, filepath.Join("/home", "misha"))
+ lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
+
+ _, ok := sudoInvokingUser()
+ assert.False(t, ok)
+ assert.True(t, sudoActive())
+ assert.True(t, IsPlainRoot())
+}
+
+func TestGetConfigDirUnderSudoIsReadOnly(t *testing.T) {
+ home := t.TempDir()
+ fakeSudo(t, home)
+
+ base, err := baseConfigDir()
+ require.NoError(t, err)
+ if runtime.GOOS == "darwin" {
+ assert.Equal(t, filepath.Join(home, "Library", "Application Support"), base)
+ } else {
+ assert.Equal(t, filepath.Join(home, ".config"), base)
+ }
+
+ dir, err := getConfigDir()
+ require.NoError(t, err)
+ assert.Equal(t, filepath.Join(base, "netbird"), dir)
+ assert.NoDirExists(t, dir)
+}
+
+func TestBaseConfigDirFailsClosedWhenSudoLookupFails(t *testing.T) {
+ fakeSudo(t, filepath.Join("/home", "misha"))
+ lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
+
+ _, err := baseConfigDir()
+ require.Error(t, err)
+
+ _, err = getConfigDir()
+ require.Error(t, err)
+}
+
+func TestSwitchProfileSkipsStateWriteUnderSudo(t *testing.T) {
+ home := t.TempDir()
+ fakeSudo(t, home)
+
+ pm := NewProfileManager()
+ require.NoError(t, pm.SwitchProfile(defaultProfileName))
+ assertNoEntries(t, home)
+}
+
+func TestSetProfileStateSkipsWriteUnderSudo(t *testing.T) {
+ home := t.TempDir()
+ fakeSudo(t, home)
+
+ pm := NewProfileManager()
+ require.NoError(t, pm.SetProfileState(defaultProfileName, &ProfileState{Email: "misha@example.com"}))
+ assertNoEntries(t, home)
+}
+
+func TestRemoveProfileStateSkipsRemoveUnderSudo(t *testing.T) {
+ home := t.TempDir()
+ stateDir := filepath.Join(home, ".config", "netbird")
+ if runtime.GOOS == "darwin" {
+ stateDir = filepath.Join(home, "Library", "Application Support", "netbird")
+ }
+ require.NoError(t, os.MkdirAll(stateDir, 0o700))
+ stateFile := filepath.Join(stateDir, "default.state.json")
+ require.NoError(t, os.WriteFile(stateFile, []byte(`{"email":"misha@example.com"}`), 0o600))
+
+ fakeSudo(t, home)
+ pm := NewProfileManager()
+ require.NoError(t, pm.RemoveProfileState("default"))
+ assert.FileExists(t, stateFile)
+}
+
+func TestUserBaseConfigDir(t *testing.T) {
+ u := &user.User{Username: "misha", HomeDir: filepath.Join("/home", "misha")}
+ dir, err := userBaseConfigDir(u)
+ require.NoError(t, err)
+ if runtime.GOOS == "darwin" {
+ assert.Equal(t, filepath.Join(u.HomeDir, "Library", "Application Support"), dir)
+ } else {
+ assert.Equal(t, filepath.Join(u.HomeDir, ".config"), dir)
+ }
+
+ _, err = userBaseConfigDir(&user.User{Username: "nohome"})
+ require.Error(t, err)
+}
+
+func TestIsPlainRoot(t *testing.T) {
+ t.Setenv(envSudoUser, "")
+ origEuid := geteuid
+ t.Cleanup(func() { geteuid = origEuid })
+
+ geteuid = func() int { return 1000 }
+ assert.False(t, IsPlainRoot())
+
+ geteuid = func() int { return 0 }
+ assert.True(t, IsPlainRoot())
+}
+
+func TestMirrorIsAuthoritative(t *testing.T) {
+ t.Setenv(envSudoUser, "")
+ origEuid := geteuid
+ t.Cleanup(func() { geteuid = origEuid })
+
+ geteuid = func() int { return 1000 }
+ assert.True(t, MirrorIsAuthoritative(), "a normal user's own mirror is authoritative")
+
+ geteuid = func() int { return 0 }
+ assert.False(t, MirrorIsAuthoritative(), "plain root has no authoritative mirror")
+}
+
+func TestMirrorIsAuthoritativeFalseUnderSudo(t *testing.T) {
+ fakeSudo(t, filepath.Join("/home", "misha"))
+ assert.False(t, MirrorIsAuthoritative(), "the sudo mirror is frozen, so it is not authoritative")
+}
+
+func fakeSudo(t *testing.T, home string) {
+ t.Helper()
+ t.Setenv(envSudoUser, "misha")
+
+ origEuid := geteuid
+ origLookup := lookupUser
+ origOverride := ConfigDirOverride
+ geteuid = func() int { return 0 }
+ lookupUser = func(name string) (*user.User, error) {
+ return &user.User{Username: name, Uid: "1234", Gid: "1234", HomeDir: home}, nil
+ }
+ ConfigDirOverride = ""
+ t.Cleanup(func() {
+ geteuid = origEuid
+ lookupUser = origLookup
+ ConfigDirOverride = origOverride
+ })
+}
+
+func assertNoEntries(t *testing.T, root string) {
+ t.Helper()
+ err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if path != root {
+ t.Errorf("unexpected entry created under %s: %s", root, path)
+ }
+ return nil
+ })
+ require.NoError(t, err)
+}
diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go
index e25d493d5..d2ed92bc5 100644
--- a/client/internal/profilemanager/profilemanager.go
+++ b/client/internal/profilemanager/profilemanager.go
@@ -3,7 +3,6 @@ package profilemanager
import (
"fmt"
"os"
- "os/user"
"path/filepath"
"strings"
"sync"
@@ -54,7 +53,7 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("invalid profile ID: %q", id)
}
- username, err := user.Current()
+ username, err := InvokingUser()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
@@ -130,7 +129,7 @@ func (pm *ProfileManager) getActiveProfileState() ID {
if err != nil {
if !os.IsNotExist(err) {
log.Warnf("failed to read active profile state: %v", err)
- } else {
+ } else if !sudoActive() {
if err := pm.setActiveProfileState(defaultProfileName); err != nil {
log.Warnf("failed to set default profile state: %v", err)
}
@@ -148,6 +147,13 @@ func (pm *ProfileManager) getActiveProfileState() ID {
}
func (pm *ProfileManager) setActiveProfileState(id ID) error {
+ // The invoking user's state is read-only under sudo — a root-owned file in
+ // the user's directory would break their own runs. The daemon still records
+ // the switch on its side; only the user-local bookkeeping is skipped.
+ if sudoActive() {
+ log.Infof("running under sudo: not persisting active profile %q for user %s", id, os.Getenv(envSudoUser))
+ return nil
+ }
configDir, err := getConfigDir()
if err != nil {
diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go
index ddb5dd056..81e6c085f 100644
--- a/client/internal/profilemanager/state.go
+++ b/client/internal/profilemanager/state.go
@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
+ log "github.com/sirupsen/logrus"
+
"github.com/netbirdio/netbird/util"
)
@@ -63,6 +65,15 @@ func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
return fmt.Errorf("invalid profile ID: %q", id)
}
+ // The invoking user's state is read-only under sudo. The file only carries
+ // the account email for the login hint and display, so skipping the write
+ // costs at most one extra account prompt later — a root-owned file in the
+ // user's directory would cost every later update instead.
+ if sudoActive() {
+ log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser))
+ return nil
+ }
+
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)
@@ -92,6 +103,11 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
// equivalent to clearing it; the next SSO login recreates it. A missing file
// is not an error.
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
+ if sudoActive() {
+ log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser))
+ return nil
+ }
+
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go
index 6d5feec79..b81d51b67 100644
--- a/client/internal/routemanager/selection.go
+++ b/client/internal/routemanager/selection.go
@@ -17,23 +17,30 @@ import (
// are mutually exclusive: if the selection activates an exit node, every other
// available exit node is deselected so two can't be active at once. With
// appendRoute=false the previous selection is replaced instead of extended.
+// A partial failure (e.g. an unknown ID mixed with valid ones) still applies
+// the valid IDs to the routing table; the unknown ones are reported in the
+// returned error.
func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
- if err := m.selectRoutes(ids, appendRoute); err != nil {
- return err
- }
+ err := m.selectRoutes(ids, appendRoute)
+ // Apply regardless of err: selectRoutes already selects the valid part of a
+ // partial request, and skipping this on error would leave those routes
+ // selected in the selector but never installed in the routing table.
m.TriggerSelection(m.GetClientRoutes())
- return nil
+ return err
}
// DeselectRoutes removes the routes with the given network IDs from the
// selection and applies the change. V4/v6 exit-node pairs are expanded
-// automatically.
+// automatically. A partial failure (e.g. an unknown ID mixed with valid ones)
+// still applies the valid IDs to the routing table; the unknown ones are
+// reported in the returned error.
func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error {
- if err := m.deselectRoutes(ids); err != nil {
- return err
- }
+ err := m.deselectRoutes(ids)
+ // Apply regardless of err: deselectRoutes already deselects the valid part
+ // of a partial request, and skipping this on error would leave those routes
+ // installed in the routing table despite being marked deselected.
m.TriggerSelection(m.GetClientRoutes())
- return nil
+ return err
}
func (m *DefaultManager) deselectRoutes(ids []route.NetID) error {
diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go
index 6066b5661..4ef9ddb88 100644
--- a/client/internal/routemanager/selection_test.go
+++ b/client/internal/routemanager/selection_test.go
@@ -1,12 +1,17 @@
package routemanager
import (
+ "context"
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/exp/maps"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/routemanager/client"
+ "github.com/netbirdio/netbird/client/internal/routemanager/notifier"
"github.com/netbirdio/netbird/client/internal/routeselector"
"github.com/netbirdio/netbird/route"
)
@@ -112,6 +117,75 @@ func TestSelectRoutes_UnknownRoute(t *testing.T) {
assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail")
}
+// newPartialFailureTestManager exercises the real install/remove path without
+// touching the system: the noop refcounter absorbs the route changes, and every
+// route already has a watcher, so none is started.
+func newPartialFailureTestManager() *DefaultManager {
+ ctx := context.Background()
+
+ m := &DefaultManager{
+ ctx: ctx,
+ clientRoutes: route.HAMap{
+ "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p1"}},
+ "other|10.1.2.0/24": {{NetID: "other", Network: netip.MustParsePrefix("10.1.2.0/24"), Peer: "p2"}},
+ },
+ routeSelector: routeselector.NewRouteSelector(),
+ notifier: notifier.NewNotifier(),
+ statusRecorder: peer.NewRecorder("https://mgm"),
+ activeRoutes: make(map[route.HAUniqueID]client.RouteHandler),
+ clientNetworks: map[route.HAUniqueID]*client.Watcher{
+ "lan|192.168.1.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}),
+ "other|10.1.2.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}),
+ },
+ }
+ m.setupRefCounters(true)
+ return m
+}
+
+// Regression for the reported symptom: a partial failure returned before
+// TriggerSelection ran, so the valid route was marked selected while never
+// reaching the routing table (activeRoutes/ip route).
+func TestSelectRoutes_PartialFailureStillInstallsValidRoute(t *testing.T) {
+ m := newPartialFailureTestManager()
+
+ err := m.SelectRoutes([]route.NetID{"missing", "lan"}, false)
+
+ assert.Error(t, err, "the unknown id must still be reported")
+ assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the valid route must be installed despite the error")
+ assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must not be installed")
+}
+
+// Mirror of the case above: a partial failure must remove the valid route from
+// the routing table, not just mark it deselected in the selector.
+func TestDeselectRoutes_PartialFailureStillRemovesValidRoute(t *testing.T) {
+ m := newPartialFailureTestManager()
+
+ require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false))
+ require.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"))
+ require.Contains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"))
+
+ err := m.DeselectRoutes([]route.NetID{"missing", "other"})
+
+ assert.Error(t, err, "the unknown id must still be reported")
+ assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must be removed")
+ assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the untouched route stays installed")
+}
+
+// The selection now runs on every request, including one where no ID is known
+// and the selector stays untouched. Nothing may be torn down or reinstalled on
+// that path.
+func TestSelectRoutes_TotalFailureLeavesInstalledRoutesAlone(t *testing.T) {
+ m := newPartialFailureTestManager()
+
+ require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false))
+ installed := maps.Keys(m.activeRoutes)
+
+ err := m.SelectRoutes([]route.NetID{"missing"}, false)
+
+ assert.Error(t, err, "the unknown id must still be reported")
+ assert.ElementsMatch(t, installed, maps.Keys(m.activeRoutes), "a fully invalid request must not disturb the routing table")
+}
+
func TestExitNodeSelectionHelpers(t *testing.T) {
routesMap := map[route.NetID][]*route.Route{
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 1254b384d..8a64ad316 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -32,6 +32,22 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al
rs.mu.Lock()
defer rs.mu.Unlock()
+ // Validate before mutating: a non-append selection wipes the current selection
+ // first, so a request of only unavailable routes would deselect everything and
+ // put nothing back. An empty request means deselect all, so it still goes through.
+ var err *multierror.Error
+ available := make([]route.NetID, 0, len(routes))
+ for _, r := range routes {
+ if !slices.Contains(allRoutes, r) {
+ err = multierror.Append(err, fmt.Errorf("route '%s' is not available", r))
+ continue
+ }
+ available = append(available, r)
+ }
+ if len(available) == 0 && err != nil {
+ return errors.FormatErrorOrNil(err)
+ }
+
if !appendRoute || rs.deselectAll {
if rs.deselectedRoutes == nil {
rs.deselectedRoutes = map[route.NetID]struct{}{}
@@ -46,14 +62,9 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al
}
}
- var err *multierror.Error
- for _, route := range routes {
- if !slices.Contains(allRoutes, route) {
- err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route))
- continue
- }
- delete(rs.deselectedRoutes, route)
- rs.selectedRoutes[route] = struct{}{}
+ for _, r := range available {
+ delete(rs.deselectedRoutes, r)
+ rs.selectedRoutes[r] = struct{}{}
}
rs.deselectAll = false
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index 2b1ba3fb9..f26d022e9 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -887,3 +887,70 @@ func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) {
assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected")
assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected")
}
+
+// A non-append selection clears the current selection before applying the requested
+// one, so an all-unavailable request used to leave nothing selected while returning
+// an error. Requests with at least one available route are unaffected.
+func TestRouteSelector_SelectRoutes_AllUnavailableKeepsSelection(t *testing.T) {
+ allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+ err := rs.SelectRoutes([]route.NetID{"Route1", "route4"}, false, allRoutes)
+
+ assert.Error(t, err, "an unavailable route ID must still be reported")
+ assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request")
+ for _, id := range []route.NetID{"route2", "route3"} {
+ assert.False(t, rs.IsSelected(id), "no other route may become selected")
+ }
+}
+
+// Boundary of the check above: an empty request is the caller deselecting everything,
+// not a failed lookup, so it must keep working.
+func TestRouteSelector_SelectRoutes_EmptyRequestStillDeselectsAll(t *testing.T) {
+ allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+ require.NoError(t, rs.SelectRoutes(nil, false, allRoutes))
+
+ for _, id := range allRoutes {
+ assert.False(t, rs.IsSelected(id), "an empty selection request must deselect everything")
+ }
+}
+
+// Mobile clients always call SelectRoutes with append=true. On that path an
+// all-unavailable request was never destructive to begin with (append skips the
+// wipe regardless of the guard above), but the behavior has no coverage yet.
+func TestRouteSelector_SelectRoutes_AppendAllUnavailableKeepsSelection(t *testing.T) {
+ allRoutes := []route.NetID{"route1", "route2", "route3"}
+
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes))
+
+ err := rs.SelectRoutes([]route.NetID{"missing"}, true, allRoutes)
+
+ assert.Error(t, err, "an unavailable route ID must still be reported")
+ assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request")
+ for _, id := range []route.NetID{"route2", "route3"} {
+ assert.False(t, rs.IsSelected(id), "no other route may become selected")
+ }
+}
+
+// The early return for an all-unavailable request must not clear deselectAll,
+// or a typo'd network ID would silently drop the "nothing selected, including
+// future networks" policy.
+func TestRouteSelector_SelectRoutes_AllUnavailableAfterDeselectAllKeepsPolicy(t *testing.T) {
+ allRoutes := []route.NetID{"route1", "route2"}
+
+ rs := routeselector.NewRouteSelector()
+ rs.DeselectAllRoutes()
+
+ err := rs.SelectRoutes([]route.NetID{"missing"}, false, allRoutes)
+
+ assert.Error(t, err, "an unavailable route ID must still be reported")
+ assert.True(t, rs.IsDeselectAll(), "deselect-all policy must survive a fully invalid request")
+ assert.False(t, rs.IsSelected("route3"), "deselect-all must still cover networks not present in allRoutes yet")
+}
diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index e6d73588b..382213a96 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -4,12 +4,14 @@ package NetBirdSDK
import (
"context"
+ "errors"
"fmt"
"net/netip"
"os"
"sort"
"strings"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
@@ -37,6 +39,8 @@ const (
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
+var errClientAlreadyRunning = errors.New("client is already running")
+
// RouteListener export internal RouteListener for mobile
type NetworkChangeListener interface {
listener.NetworkChangeListener
@@ -74,15 +78,13 @@ type Client struct {
cacheDir string
logFilePath string
recorder *peer.Status
- ctxCancel context.CancelFunc
- ctxCancelLock *sync.Mutex
deviceName string
osName string
osVersion string
networkChangeListener listener.NetworkChangeListener
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
- loginComplete bool
+ loginComplete atomic.Bool
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run injects its state and sweeper into each new
// ConnectClient.
@@ -90,9 +92,16 @@ type Client struct {
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
+ // stateMu guards the run lifecycle as one unit: the cancel installed by
+ // the current run, the channel it closes on exit, and the state it
+ // published. One run at a time: startRun refuses a second Run while the
+ // previous one has not exited, and the platform serializes Stop before
+ // Start, so no generation tracking is needed.
stateMu sync.RWMutex
connectClient *internal.ConnectClient
config *profilemanager.Config
+ runDone chan struct{}
+ ctxCancel context.CancelFunc
fileDropMu sync.Mutex
fileDrop *FileDrop
@@ -110,7 +119,6 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
osName: osName,
osVersion: osVersion,
recorder: recorder,
- ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
netMgr: netevents.NewManager(recorder),
@@ -159,17 +167,21 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
- var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
- c.ctxCancelLock.Lock()
- ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
- defer c.ctxCancel()
- c.ctxCancelLock.Unlock()
+ runCtx, runCancel := context.WithCancel(ctxWithValues)
+ defer runCancel()
+
+ done, err := c.startRun(runCancel)
+ if err != nil {
+ return err
+ }
+ defer c.finishRun(done)
+ ctx := runCtx
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
@@ -219,16 +231,40 @@ func (c *Client) NotifyNetworkChange() {
c.netMgr.NotifyNetworkChange()
}
-// Stop the internal client and free the resources
+// Stop cancels the running client and waits for the run loop to exit, so a
+// caller that restarts immediately cannot race the outgoing teardown.
func (c *Client) Stop() {
- c.ctxCancelLock.Lock()
- defer c.ctxCancelLock.Unlock()
- if c.ctxCancel == nil {
+ done := c.cancelRun()
+ if done == nil {
return
}
- c.ctxCancel()
- c.setState(nil, nil)
+ select {
+ case <-done:
+ case <-time.After(stopRunWaitTimeout):
+ log.Warnf("Stop: timed out waiting for the run loop to exit")
+ }
+}
+
+// StopWithoutWait cancels the running client without waiting for the run loop.
+// Use it where the caller is on a deadline the wait could overrun, such as
+// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds
+// before it kills the extension.
+func (c *Client) StopWithoutWait() {
+ c.cancelRun()
+}
+
+func (c *Client) cancelRun() chan struct{} {
+ c.stateMu.RLock()
+ done := c.runDone
+ cancel := c.ctxCancel
+ c.stateMu.RUnlock()
+
+ if cancel != nil {
+ cancel()
+ }
+
+ return done
}
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
@@ -380,16 +416,14 @@ func (c *Client) IsLoginRequiredCached() bool {
}
func (c *Client) IsLoginRequired() bool {
- var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
- c.ctxCancelLock.Lock()
- defer c.ctxCancelLock.Unlock()
- ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
+ ctx, cancel := context.WithCancel(ctxWithValues)
+ defer cancel()
var cfg *profilemanager.Config
var err error
@@ -437,17 +471,22 @@ func (c *Client) IsLoginRequired() bool {
// loginForMobileAuthTimeout is the timeout for requesting auth info from the server
const loginForMobileAuthTimeout = 30 * time.Second
+const stopRunWaitTimeout = 20 * time.Second
+
func (c *Client) LoginForMobile() string {
- var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
- c.ctxCancelLock.Lock()
- defer c.ctxCancelLock.Unlock()
- ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
+ ctx, cancel := context.WithCancel(ctxWithValues)
+ loginDone := false
+ defer func() {
+ if !loginDone {
+ cancel()
+ }
+ }()
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
// which are blocked by the tvOS sandbox in App Group containers
@@ -474,7 +513,9 @@ func (c *Client) LoginForMobile() string {
}
// This could cause a potential race condition with loading the extension which need to be handled on swift side
+ loginDone = true
go func() {
+ defer cancel()
tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo)
if err != nil {
log.Errorf("LoginForMobile: WaitToken failed: %v", err)
@@ -491,18 +532,18 @@ func (c *Client) LoginForMobile() string {
log.Errorf("LoginForMobile: Login failed: %v", err)
return
}
- c.loginComplete = true
+ c.loginComplete.Store(true)
}()
return flowInfo.VerificationURIComplete
}
func (c *Client) IsLoginComplete() bool {
- return c.loginComplete
+ return c.loginComplete.Load()
}
func (c *Client) ClearLoginComplete() {
- c.loginComplete = false
+ c.loginComplete.Store(false)
}
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
@@ -722,13 +763,36 @@ func (c *Client) DeselectRoute(id string) error {
return nil
}
-// setState stores the running engine state so DebugBundle can reuse the live
-// config and ConnectClient. It is cleared on Stop.
-func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
+
+ if c.runDone != nil {
+ return nil, errClientAlreadyRunning
+ }
+
+ done := make(chan struct{})
+ c.runDone = done
+ c.ctxCancel = cancel
+ return done, nil
+}
+
+func (c *Client) finishRun(done chan struct{}) {
+ c.stateMu.Lock()
+ c.connectClient = nil
+ c.config = nil
+ c.runDone = nil
+ c.ctxCancel = nil
+ c.stateMu.Unlock()
+
+ close(done)
+}
+
+func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+ c.stateMu.Lock()
c.config = cfg
c.connectClient = cc
+ c.stateMu.Unlock()
}
// stateSnapshot returns the current config and ConnectClient under the lock.
diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go
index 42a575359..cf7aa6730 100644
--- a/client/ios/NetBirdSDK/login.go
+++ b/client/ios/NetBirdSDK/login.go
@@ -11,6 +11,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mobile"
"github.com/netbirdio/netbird/client/system"
)
@@ -284,12 +285,14 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
}
jwtToken := ""
+ email := ""
if needsLogin {
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
jwtToken = tokenInfo.GetTokenToUse()
+ email = tokenInfo.Email
}
err, isAuthError := authClient.Login(ctx, "", jwtToken)
@@ -301,6 +304,14 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
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 := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
+ log.Warnf("failed to store profile account email: %v", err)
+ }
+ }
+
// Save the config before notifying success to ensure persistence completes
// before the callback potentially triggers teardown on the Swift side.
// Note: This differs from Android which doesn't save config after login.
@@ -320,10 +331,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin
return nil
}
+// profileLoginHint returns the stored account email for the profile at cfgPath,
+// so a re-login targets the account the profile already belongs to instead of
+// whatever session the shared browser cookie jar happens to hold.
+//
+// An empty hint is deliberate, not a fallback: a fresh profile leaves the
+// choice to the IdP. Switching accounts is done by switching or removing
+// profiles, not by logging out — logout keeps the email.
+func profileLoginHint(cfgPath string) string {
+ if cfgPath == "" {
+ return ""
+ }
+ return mobile.ReadProfileEmail(cfgPath)
+}
+
const authInfoRequestTimeout = 30 * time.Second
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
- oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "")
+ oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, profileLoginHint(a.cfgPath))
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go
index 9401acacc..7c730f757 100644
--- a/client/jobexec/executor.go
+++ b/client/jobexec/executor.go
@@ -28,7 +28,11 @@ func NewExecutor() *Executor {
return &Executor{}
}
-func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) {
+func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL, uploadURL string) (string, error) {
+ if uploadURL == "" {
+ uploadURL = types.DefaultBundleURL
+ }
+
if waitForDuration > MaxBundleWaitTime {
log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime)
waitForDuration = MaxBundleWaitTime
@@ -54,7 +58,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
}
}()
- key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
+ key, err := debug.UploadDebugBundle(ctx, uploadURL, mgmURL, path, false)
if err != nil {
log.Errorf("failed to upload debug bundle: %v", err)
return "", fmt.Errorf("upload debug bundle: %w", err)
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
index 29288b511..64a8093c3 100644
--- a/client/mdm/canonical_loaders.go
+++ b/client/mdm/canonical_loaders.go
@@ -27,9 +27,13 @@ var allKeys = []string{
KeyRosenpassEnabled,
KeyRosenpassPermissive,
KeyWireguardPort,
+ KeyEnableLocalMetrics,
+ KeyLocalMetricsAddress,
KeySplitTunnelMode,
KeySplitTunnelApps,
KeyLazyConnection,
+ KeyRemoteJobsAllowed,
+ KeyBundleUploadURL,
}
// canonicalKey maps the lowercase form of a managed-config value name to
diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go
new file mode 100644
index 000000000..330a15c47
--- /dev/null
+++ b/client/mdm/canonical_loaders_test.go
@@ -0,0 +1,52 @@
+//go:build windows || darwin
+
+package mdm
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "slices"
+ "strconv"
+ "testing"
+)
+
+// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key*
+// constant without listing it in allKeys causes: the desktop loaders resolve
+// value names through canonicalKey, so an unlisted key is silently discarded as
+// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go
+// stale in the same way.
+func TestAllKeysCoversEveryPolicyKey(t *testing.T) {
+ file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0)
+ if err != nil {
+ t.Fatalf("parse policy.go: %v", err)
+ }
+
+ for _, decl := range file.Decls {
+ gen, ok := decl.(*ast.GenDecl)
+ if !ok || gen.Tok != token.CONST {
+ continue
+ }
+ for _, spec := range gen.Specs {
+ value, ok := spec.(*ast.ValueSpec)
+ if !ok || len(value.Names) != 1 || len(value.Values) != 1 {
+ continue
+ }
+ name := value.Names[0].Name
+ if len(name) < 4 || name[:3] != "Key" {
+ continue
+ }
+ lit, ok := value.Values[0].(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ continue
+ }
+ key, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ t.Fatalf("unquote %s: %v", name, err)
+ }
+ if !slices.Contains(allKeys, key) {
+ t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key)
+ }
+ }
+ }
+}
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
index 1feff28f8..dac135ea6 100644
--- a/client/mdm/policy.go
+++ b/client/mdm/policy.go
@@ -47,6 +47,8 @@ const (
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
+ KeyEnableLocalMetrics = "enableLocalMetrics"
+ KeyLocalMetricsAddress = "localMetricsAddress"
// Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator
@@ -60,6 +62,17 @@ const (
// the management feature flag. Read as a bool (native bool, or on/off,
// true/false, 1/0, yes/no); absent = defer to management.
KeyLazyConnection = "lazyConnection"
+
+ // KeyRemoteJobsAllowed opts the peer into management-requested remote jobs
+ // (e.g. debug bundles). Read as a bool; absent = defer to the local config
+ // (which defaults to disabled). Stored on Config as RemoteJobsAllowed.
+ KeyRemoteJobsAllowed = "allowRemoteJobs"
+
+ // KeyBundleUploadURL overrides the debug-bundle upload service URL for
+ // remote jobs, taking precedence over the management-supplied value. Read
+ // as a string; must be an https URL with a host. Absent = defer to the
+ // management-supplied URL (or the default upload server).
+ KeyBundleUploadURL = "debugBundleUploadURL"
)
// Split-tunnel mode literals (KeySplitTunnelMode values).
@@ -71,6 +84,8 @@ const (
// SecretKeys lists keys whose values must be redacted in logs.
var SecretKeys = map[string]struct{}{
KeyPreSharedKey: {},
+ // The upload URL can embed credentials or signed query tokens.
+ KeyBundleUploadURL: {},
}
// boolStringLiterals enumerates the textual boolean encodings the
diff --git a/client/mobile/profile_state.go b/client/mobile/profile_state.go
index 890001d69..bae0e0dfb 100644
--- a/client/mobile/profile_state.go
+++ b/client/mobile/profile_state.go
@@ -105,8 +105,14 @@ func WriteProfileEmail(configPath string, email string) error {
return fmt.Errorf("resolve profile account path: %w", err)
}
+ // DirectWriteJson, not the atomic writers: those create a temp file and
+ // rename it over the target, which the tvOS App Group sandbox blocks. It is
+ // the same reason the config next to this file goes through
+ // DirectWriteOutConfig. The file is rewritten whole from one key, so losing
+ // atomicity costs nothing beyond a torn write on a crash mid-write, which
+ // reads back as "no email" and is recovered by the next login.
state := profilemanager.ProfileState{Email: email}
- if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
+ if err := util.DirectWriteJson(context.Background(), accountPath, state); err != nil {
return fmt.Errorf("write profile account: %w", err)
}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index a0f8d61d7..5f99e1ec4 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -552,8 +552,13 @@ type LoginRequest struct {
DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
+ LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
+ // remoteJobsAllowed opts the peer into management-requested remote jobs
+ // (e.g. debug bundles). Absent leaves the stored value unchanged.
+ RemoteJobsAllowed *bool `protobuf:"varint,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *LoginRequest) Reset() {
@@ -867,6 +872,27 @@ func (x *LoginRequest) GetDisableIpv6() bool {
return false
}
+func (x *LoginRequest) GetEnableLocalMetrics() bool {
+ if x != nil && x.EnableLocalMetrics != nil {
+ return *x.EnableLocalMetrics
+ }
+ return false
+}
+
+func (x *LoginRequest) GetLocalMetricsAddress() string {
+ if x != nil && x.LocalMetricsAddress != nil {
+ return *x.LocalMetricsAddress
+ }
+ return ""
+}
+
+func (x *LoginRequest) GetRemoteJobsAllowed() bool {
+ if x != nil && x.RemoteJobsAllowed != nil {
+ return *x.RemoteJobsAllowed
+ }
+ return false
+}
+
type LoginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"`
@@ -1424,6 +1450,7 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
+ RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"`
// mDMManagedFields lists the names of configuration keys whose value is
// currently enforced by an MDM policy. Names match mdm.Key* constants
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
@@ -1653,6 +1680,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool {
return false
}
+func (x *GetConfigResponse) GetRemoteJobsAllowed() bool {
+ if x != nil {
+ return x.RemoteJobsAllowed
+ }
+ return false
+}
+
func (x *GetConfigResponse) GetMDMManagedFields() []string {
if x != nil {
return x.MDMManagedFields
@@ -4442,8 +4476,13 @@ type SetConfigRequest struct {
DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
+ LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
+ // remoteJobsAllowed opts the peer into management-requested remote jobs
+ // (e.g. debug bundles). Absent leaves the stored value unchanged.
+ RemoteJobsAllowed *bool `protobuf:"varint,38,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *SetConfigRequest) Reset() {
@@ -4721,6 +4760,27 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
return false
}
+func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
+ if x != nil && x.EnableLocalMetrics != nil {
+ return *x.EnableLocalMetrics
+ }
+ return false
+}
+
+func (x *SetConfigRequest) GetLocalMetricsAddress() string {
+ if x != nil && x.LocalMetricsAddress != nil {
+ return *x.LocalMetricsAddress
+ }
+ return ""
+}
+
+func (x *SetConfigRequest) GetRemoteJobsAllowed() bool {
+ if x != nil && x.RemoteJobsAllowed != nil {
+ return *x.RemoteJobsAllowed
+ }
+ return false
+}
+
type SetConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -8165,7 +8225,7 @@ var File_daemon_proto protoreflect.FileDescriptor
const file_daemon_proto_rawDesc = "" +
"\n" +
"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
- "\fEmptyRequest\"\xef\x12\n" +
+ "\fEmptyRequest\"\xdb\x14\n" +
"\fLoginRequest\x12\x1a\n" +
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
@@ -8210,7 +8270,10 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
- "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" +
+ "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
+ "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
+ "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" +
+ "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -8238,7 +8301,10 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
- "\r_disable_ipv6\"\xb5\x01\n" +
+ "\r_disable_ipv6B\x17\n" +
+ "\x15_enable_local_metricsB\x18\n" +
+ "\x16_local_metrics_addressB\x14\n" +
+ "\x12_remoteJobsAllowed\"\xb5\x01\n" +
"\rLoginResponse\x12$\n" +
"\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" +
"\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" +
@@ -8273,7 +8339,7 @@ const file_daemon_proto_rawDesc = "" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" +
+ "\busername\x18\x02 \x01(\tR\busername\"\xd8\t\n" +
"\x11GetConfigResponse\x12$\n" +
"\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" +
"\n" +
@@ -8305,7 +8371,8 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
- "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
+ "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" +
+ "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" +
"\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
@@ -8533,7 +8600,7 @@ const file_daemon_proto_rawDesc = "" +
"\f_profileNameB\v\n" +
"\t_username\"'\n" +
"\x15SwitchProfileResponse\x12\x0e\n" +
- "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"\x84\x13\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -8573,7 +8640,10 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
- "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" +
+ "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
+ "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
+ "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" +
+ "\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" +
@@ -8598,7 +8668,10 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" +
- "\r_disable_ipv6\"\x13\n" +
+ "\r_disable_ipv6B\x17\n" +
+ "\x15_enable_local_metricsB\x18\n" +
+ "\x16_local_metrics_addressB\x14\n" +
+ "\x12_remoteJobsAllowed\"\x13\n" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 59284d858..d44801f31 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -268,6 +268,12 @@ message LoginRequest {
optional bool disableSSHAuth = 38;
optional int32 sshJWTCacheTTL = 39;
optional bool disable_ipv6 = 40;
+
+ optional bool enable_local_metrics = 41;
+ optional string local_metrics_address = 42;
+ // remoteJobsAllowed opts the peer into management-requested remote jobs
+ // (e.g. debug bundles). Absent leaves the stored value unchanged.
+ optional bool remoteJobsAllowed = 43;
}
message LoginResponse {
@@ -388,6 +394,8 @@ message GetConfigResponse {
bool disable_ipv6 = 27;
+ bool remoteJobsAllowed = 29;
+
// mDMManagedFields lists the names of configuration keys whose value is
// currently enforced by an MDM policy. Names match mdm.Key* constants
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
@@ -792,6 +800,12 @@ message SetConfigRequest {
optional bool disableSSHAuth = 33;
optional int32 sshJWTCacheTTL = 34;
optional bool disable_ipv6 = 35;
+
+ optional bool enable_local_metrics = 36;
+ optional string local_metrics_address = 37;
+ // remoteJobsAllowed opts the peer into management-requested remote jobs
+ // (e.g. debug bundles). Absent leaves the stored value unchanged.
+ optional bool remoteJobsAllowed = 38;
}
message SetConfigResponse{}
diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go
new file mode 100644
index 000000000..2d84d1b6a
--- /dev/null
+++ b/client/server/logout_gate_test.go
@@ -0,0 +1,200 @@
+package server
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// unreachableManagementURL keeps a test that is expected to stop at a gate from
+// reaching the network if the gate ever regresses: the profiles a logout must
+// not touch point here, so a leak fails fast instead of contacting a real
+// management server.
+const unreachableManagementURL = "https://127.0.0.1:9"
+
+// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server
+// enabled. Deregistering an SSH-enabled profile is a privileged change, so an
+// unprivileged caller is refused by requirePrivilegeForDeregistration before any
+// management connection is attempted, which is what keeps these tests offline.
+func enableSSHOnProfile(t *testing.T, cfgPath string) {
+ t.Helper()
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
+ ManagementURL: "https://api.netbird.io:443",
+ ServerSSHAllowed: boolPtr(true),
+ })
+ require.NoError(t, err)
+}
+
+// Logging out of the profile the daemon is already running is a deregistration,
+// not profile management, so the profiles-disabled kill switch must not block
+// it. The desktop UI always addresses logout by profile (both the profile menu
+// and the session-expiration dialog), so gating it left users with
+// disableProfiles enforced unable to log out at all.
+func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) {
+ s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+ enableSSHOnProfile(t, cfgPath)
+
+ s.profilesDisabled = true
+
+ _, err := s.Logout(userCtx(), &proto.LogoutRequest{
+ ProfileName: &activeProfile,
+ Username: &username,
+ })
+
+ require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err),
+ "logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err)
+ require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
+}
+
+// A profile-addressed logout that targets some *other* profile does manage
+// profiles, so it stays gated: with profiles disabled the daemon must not
+// deregister a peer the user is not currently running.
+func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ other := "other-profile"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
+ ManagementURL: unreachableManagementURL,
+ })
+ require.NoError(t, err)
+
+ s.profilesDisabled = true
+
+ _, err = s.Logout(userCtx(), &proto.LogoutRequest{
+ ProfileName: &other,
+ Username: &username,
+ })
+
+ require.Error(t, err)
+ require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err)
+ require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
+}
+
+// A legacy profile ID is a display name, so two users can hold the same ID in
+// their own profile directories. Matching on the ID alone would let one user's
+// logout pass the gate against the other user's active profile, so the username
+// is part of the comparison.
+func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
+ s, _, _, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // A legacy-style profile whose ID is its filename stem, and an active state
+ // claiming that same ID for a different user.
+ shared := "shared-legacy-name"
+ _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
+ ManagementURL: unreachableManagementURL,
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: profilemanager.ID(shared),
+ Username: "someone-else",
+ }))
+
+ s.profilesDisabled = true
+
+ _, err = s.Logout(userCtx(), &proto.LogoutRequest{
+ ProfileName: &shared,
+ Username: &username,
+ })
+
+ require.Error(t, err)
+ require.Equal(t, codes.Unavailable, gstatus.Code(err),
+ "another user's profile must not pass the gate on an ID match alone: %v", err)
+}
+
+// Deregistering a namesake profile must not go out with the running config.
+// logoutFromProfile reuses the connected client's config when the target is the
+// active profile, and on an ID-only match a shared legacy ID made it reuse it
+// for another user's profile, deregistering the active peer instead.
+func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
+ s, _, _, username, cfgPath := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ // The running config has the SSH server enabled, so reusing it would be
+ // refused with PermissionDenied. The namesake profile does not, so the
+ // correct path gets as far as dialing its own unreachable management URL.
+ enableSSHOnProfile(t, cfgPath)
+ running, err := profilemanager.GetConfig(cfgPath)
+ require.NoError(t, err)
+ s.config = running
+ s.connectClient = newDummyConnectClient(context.Background())
+
+ shared := "shared-legacy-name"
+ _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
+ ManagementURL: unreachableManagementURL,
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: profilemanager.ID(shared),
+ Username: "someone-else",
+ }))
+
+ // Bounded so the deregistration the fixed path attempts fails on the dial
+ // rather than sitting in gRPC backoff for the whole test timeout.
+ ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second)
+ t.Cleanup(cancel)
+
+ _, err = s.Logout(ctx, &proto.LogoutRequest{
+ ProfileName: &shared,
+ Username: &username,
+ })
+
+ require.Error(t, err)
+ require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err),
+ "the namesake profile was deregistered with the running config: %v", err)
+}
+
+// The connection teardown follows the profile that is active when the logout
+// completes, not the one seen before it started: Login switches profiles under
+// guardedConfigMu, which the logout path does not hold, so a login that landed
+// meanwhile must keep its connection.
+func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) {
+ s, _, activeProfile, username, _ := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+
+ state := internal.CtxGetState(s.rootCtx)
+
+ s.cleanupAfterProfileLogout("some-other-profile", username)
+ status, err := state.Status()
+ require.NoError(t, err)
+ require.NotEqual(t, internal.StatusNeedsLogin, status,
+ "logging out of a profile that is not active must not ask for a new login")
+
+ s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username)
+ status, err = state.Status()
+ require.NoError(t, err)
+ require.Equal(t, internal.StatusNeedsLogin, status,
+ "logging out of the active profile must ask for a new login")
+}
+
+// With profiles enabled the gate is out of the way on both surfaces; the active
+// profile still reaches the deregistration path.
+func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) {
+ s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
+ s.rootCtx = internal.CtxInitState(context.Background())
+ enableSSHOnProfile(t, cfgPath)
+
+ _, err := s.Logout(userCtx(), &proto.LogoutRequest{
+ ProfileName: &activeProfile,
+ Username: &username,
+ })
+
+ require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
+ require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err)
+}
diff --git a/client/server/mdm.go b/client/server/mdm.go
index 9836c6bea..b41e2b590 100644
--- a/client/server/mdm.go
+++ b/client/server/mdm.go
@@ -233,6 +233,24 @@ func conflictString(key, got string) conflictCheck {
}
}
+// conflictStringPtr is conflictString for optional proto fields, where an
+// explicit empty value is still a request to change the setting. If p is
+// nil the field is treated as matching (no override requested); otherwise
+// the check returns true only when the policy contains the key and its
+// value equals *p.
+func conflictStringPtr(key string, p *string) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true
+ }
+ want, ok := pol.GetString(key)
+ return ok && want == *p
+ },
+ }
+}
+
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
// nil the field is treated as matching; otherwise the check returns
// true only when the policy contains the key and its int value equals *p.
@@ -297,10 +315,13 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
+ conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
@@ -332,6 +353,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.Mtu != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
+ msg.RemoteJobsAllowed != nil ||
msg.NetworkMonitor != nil ||
msg.DisableClientRoutes != nil ||
msg.DisableServerRoutes != nil ||
@@ -346,7 +368,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.EnableSSHLocalPortForwarding != nil ||
msg.EnableSSHRemotePortForwarding != nil ||
msg.DisableSSHAuth != nil ||
- msg.SshJWTCacheTTL != nil
+ msg.SshJWTCacheTTL != nil ||
+ msg.EnableLocalMetrics != nil ||
+ msg.LocalMetricsAddress != nil
}
// loginRequestHasConfigOverrides reports whether the LoginRequest
@@ -370,6 +394,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.WireguardPort != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
+ msg.RemoteJobsAllowed != nil ||
msg.RosenpassPermissive != nil ||
len(msg.ExtraIFaceBlacklist) > 0 ||
msg.NetworkMonitor != nil ||
@@ -381,7 +406,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
- msg.BlockInbound != nil
+ msg.BlockInbound != nil ||
+ msg.EnableLocalMetrics != nil ||
+ msg.LocalMetricsAddress != nil
}
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
@@ -418,10 +445,13 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
+ conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
diff --git a/client/server/network.go b/client/server/network.go
index c390b8180..69eaabf8a 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}
-
diff --git a/client/server/server.go b/client/server/server.go
index 7b7260d78..0c264a1c8 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -23,6 +23,9 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -36,6 +39,7 @@ import (
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/proto"
+ "github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
"github.com/netbirdio/netbird/version"
)
@@ -109,6 +113,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
+ localMetrics *localmetrics.Manager
fileDrop *filedrop.Manager
@@ -174,9 +179,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
s.sleepHandler = sleephandler.New(agent)
s.startSleepDetector()
+ s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
+
return s
}
+// clientMetricsGatherer returns the Prometheus gatherer of the running
+// engine's client metrics, or nil when no engine is running.
+func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
+ s.mutex.Lock()
+ connectClient := s.connectClient
+ s.mutex.Unlock()
+
+ if connectClient == nil {
+ return nil
+ }
+ engine := connectClient.Engine()
+ if engine == nil {
+ return nil
+ }
+ return engine.GetClientMetrics().PrometheusGatherer()
+}
+
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -257,6 +281,7 @@ func (s *Server) Start() error {
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
+ s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if s.sessionWatcher == nil {
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
@@ -480,11 +505,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
- if _, err := profilemanager.UpdateConfig(config); err != nil {
+ updatedConf, err := profilemanager.UpdateConfig(config)
+ if err != nil {
log.Errorf("failed to update profile config: %v", err)
return nil, fmt.Errorf("failed to update profile config: %w", err)
}
+ if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
+ if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
+ s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
+ }
+ }
+
return &proto.SetConfigResponse{}, nil
}
@@ -554,8 +586,11 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
config.RosenpassEnabled = msg.RosenpassEnabled
config.RosenpassPermissive = msg.RosenpassPermissive
+ config.LocalMetricsEnabled = msg.EnableLocalMetrics
+ config.LocalMetricsAddress = msg.LocalMetricsAddress
config.DisableAutoConnect = msg.DisableAutoConnect
config.ServerSSHAllowed = msg.ServerSSHAllowed
+ config.RemoteJobsAllowed = msg.RemoteJobsAllowed
config.NetworkMonitor = msg.NetworkMonitor
config.DisableClientRoutes = msg.DisableClientRoutes
config.DisableServerRoutes = msg.DisableServerRoutes
@@ -660,6 +695,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
+ s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
+
// 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
@@ -678,54 +715,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
if msg.SetupKey == "" {
- hint := ""
- if msg.Hint != nil {
- hint = *msg.Hint
- }
- oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
- if err != nil {
- state.Set(internal.StatusLoginFailed)
- return nil, err
- }
-
- if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
- if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
- log.Debugf("using previous oauth flow info")
- state.Set(internal.StatusNeedsLogin)
- return &proto.LoginResponse{
- NeedsSSOLogin: true,
- VerificationURI: s.oauthAuthFlow.info.VerificationURI,
- VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
- UserCode: s.oauthAuthFlow.info.UserCode,
- }, nil
- } else {
- log.Warnf("canceling previous waiting execution")
- if s.oauthAuthFlow.waitCancel != nil {
- s.oauthAuthFlow.waitCancel()
- }
- }
- }
-
- authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
- if err != nil {
- log.Errorf("getting a request OAuth flow failed: %v", err)
- return nil, err
- }
-
- s.mutex.Lock()
- s.oauthAuthFlow.flow = oAuthFlow
- s.oauthAuthFlow.info = authInfo
- s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
- s.mutex.Unlock()
-
- state.Set(internal.StatusNeedsLogin)
-
- return &proto.LoginResponse{
- NeedsSSOLogin: true,
- VerificationURI: authInfo.VerificationURI,
- VerificationURIComplete: authInfo.VerificationURIComplete,
- UserCode: authInfo.UserCode,
- }, nil
+ return s.beginSSOLogin(ctx, config, msg)
}
// Setup-key path: we are about to dial Management with the key, so the
@@ -741,6 +731,76 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return &proto.LoginResponse{}, nil
}
+// beginSSOLogin starts the browser leg of a login that carries no setup key and
+// returns the response that parks the caller on it.
+func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
+ state := internal.CtxGetState(s.rootCtx)
+
+ hint := ""
+ if msg.Hint != nil {
+ hint = *msg.Hint
+ }
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
+ if err != nil {
+ state.Set(internal.StatusLoginFailed)
+ return nil, err
+ }
+
+ if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil {
+ state.Set(internal.StatusNeedsLogin)
+ return resp, nil
+ }
+
+ authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
+ if err != nil {
+ log.Errorf("getting a request OAuth flow failed: %v", err)
+ return nil, err
+ }
+
+ s.mutex.Lock()
+ s.oauthAuthFlow.flow = oAuthFlow
+ s.oauthAuthFlow.info = authInfo
+ s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
+ s.mutex.Unlock()
+
+ state.Set(internal.StatusNeedsLogin)
+
+ return &proto.LoginResponse{
+ NeedsSSOLogin: true,
+ VerificationURI: authInfo.VerificationURI,
+ VerificationURIComplete: authInfo.VerificationURIComplete,
+ UserCode: authInfo.UserCode,
+ }, nil
+}
+
+// pendingOAuthFlowResponse returns the in-flight flow's response when it
+// targets the same IdP client and has enough time left for the user to finish
+// the browser leg, so a second login joins the pending flow instead of opening
+// a competing one. A flow too close to expiry has its waiter cancelled and nil
+// returned, leaving the caller to start a fresh flow.
+func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse {
+ if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
+ return nil
+ }
+
+ if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
+ log.Debugf("using previous oauth flow info")
+ return &proto.LoginResponse{
+ NeedsSSOLogin: true,
+ VerificationURI: s.oauthAuthFlow.info.VerificationURI,
+ VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
+ UserCode: s.oauthAuthFlow.info.UserCode,
+ }
+ }
+
+ log.Warnf("canceling previous waiting execution")
+ if s.oauthAuthFlow.waitCancel != nil {
+ s.oauthAuthFlow.waitCancel()
+ }
+
+ return nil
+}
+
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
// device/PKCE flow and blocks until the user finishes the browser leg.
//
@@ -1010,6 +1070,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
+ s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
s.clientRunning = true
s.clientRunningChan = make(chan struct{})
@@ -1187,6 +1248,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
s.config = config
+ s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if msg != nil && msg.ProfileName != nil {
s.publishProfileListChanged(*msg.ProfileName)
@@ -1313,11 +1375,16 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
return nil, err
}
- if err := s.validateProfileOperation(resolved.ID, true); err != nil {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
+ }
+
+ if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil {
return nil, err
}
- if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ if err := s.logoutFromProfile(ctx, resolved, username); err != nil {
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
// A refused deregistration is already a status error carrying the reason
// and the command to run; rewrapping it as Internal would flatten both
@@ -1328,18 +1395,35 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
}
- activeProf, _ := s.profileManager.GetActiveProfileState()
- if activeProf != nil && activeProf.ID == resolved.ID {
- if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
- log.Errorf("failed to cleanup connection: %v", err)
- }
- state := internal.CtxGetState(s.rootCtx)
- state.Set(internal.StatusNeedsLogin)
- }
+ s.cleanupAfterProfileLogout(resolved.ID, username)
return &proto.LogoutResponse{}, nil
}
+// cleanupAfterProfileLogout tears the connection down and asks for a new login
+// when the profile that was just deregistered is the one the daemon is running.
+// The active profile is read again here rather than reused from the pre-flight
+// check: Login switches profiles under guardedConfigMu, which this path does not
+// hold, so a login that landed meanwhile must not have its fresh connection
+// dropped by a logout that targeted the profile it replaced.
+func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err)
+ return
+ }
+
+ if !isActiveProfile(activeProf, id, username) {
+ return
+ }
+
+ if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
+ log.Errorf("failed to cleanup connection: %v", err)
+ }
+ state := internal.CtxGetState(s.rootCtx)
+ state.Set(internal.StatusNeedsLogin)
+}
+
func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) {
if s.config == nil {
activeProf, err := s.profileManager.GetActiveProfileState()
@@ -1391,40 +1475,47 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return config, configExisted, nil
}
-func (s *Server) canRemoveProfile(id profilemanager.ID) error {
- if id == profilemanager.DefaultProfileName {
- return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
- }
-
- activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.ID == id {
- return fmt.Errorf("remove active profile: %s", id)
- }
-
- return nil
-}
-
-func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
- if s.checkProfilesDisabled() {
- return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
- }
-
+// validateProfileLogout gates a profile-addressed logout. Deregistering the
+// profile the daemon already runs is what a plain `netbird logout` does, so the
+// profiles-disabled kill switch must not block it. Logging out of any other
+// profile is profile management and stays gated.
+func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error {
if id == "" {
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
- if !allowActiveProfile {
- if err := s.canRemoveProfile(id); err != nil {
- return gstatus.Errorf(codes.InvalidArgument, "%v", err)
- }
+ if isActive {
+ return nil
+ }
+
+ if s.checkProfilesDisabled() {
+ return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
return nil
}
-func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
+// isActiveProfile reports whether id is the profile the daemon runs for
+// username. The username is part of the comparison because legacy profile IDs
+// are display names, which two users can both hold; the default profile is
+// shared by every user and carries no username.
+func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool {
+ if activeProf == nil || activeProf.ID != id {
+ return false
+ }
+
+ return id == profilemanager.DefaultProfileName || activeProf.Username == username
+}
+
+// logoutFromProfile deregisters profile, reusing the running config when
+// profile is the one the daemon is connected with. The username takes part in
+// that decision for the same reason it does in the logout gate: a legacy
+// profile ID is a display name two users can share, and sending the running
+// config for a namesake would deregister the active peer instead of the
+// requested one.
+func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error {
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
+ if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil {
return s.sendLogoutRequest(ctx)
}
@@ -2103,6 +2194,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
Mtu: int64(cfg.MTU),
DisableAutoConnect: cfg.DisableAutoConnect,
ServerSSHAllowed: *cfg.ServerSSHAllowed,
+ RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed),
RosenpassEnabled: cfg.RosenpassEnabled,
RosenpassPermissive: cfg.RosenpassPermissive,
BlockInbound: cfg.BlockInbound,
@@ -2193,7 +2285,7 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, err
}
- if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil {
// Deregistration is best-effort here: the local profile is removed
// either way, so an unprivileged caller leaves the peer registered on
// the management server rather than being blocked from removing it.
diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go
index 0366ccb31..aa6e99026 100644
--- a/client/server/server_privileged_test.go
+++ b/client/server/server_privileged_test.go
@@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
- networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
return nil, "", err
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
index ae323ea8c..ad3b7ade7 100644
--- a/client/server/setconfig_mdm_test.go
+++ b/client/server/setconfig_mdm_test.go
@@ -136,6 +136,51 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
}, v.GetFields())
}
+func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyEnableLocalMetrics: true,
+ mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ enabled := false
+ addr := "0.0.0.0:9999"
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ EnableLocalMetrics: &enabled,
+ LocalMetricsAddress: &addr,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{
+ mdm.KeyEnableLocalMetrics,
+ mdm.KeyLocalMetricsAddress,
+ }, v.GetFields())
+}
+
+// An explicitly empty address still changes the effective listen address
+// (the manager falls back to the default), so presence must be honored
+// rather than collapsed to "field not set".
+func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ addr := ""
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ LocalMetricsAddress: &addr,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
+}
+
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled).
diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go
index db7a26f03..7442b718e 100644
--- a/client/server/setconfig_test.go
+++ b/client/server/setconfig_test.go
@@ -61,6 +61,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
rosenpassEnabled := true
rosenpassPermissive := true
serverSSHAllowed := true
+ remoteJobsAllowed := true
interfaceName := "utun100"
wireguardPort := int64(51820)
preSharedKey := "test-psk"
@@ -76,6 +77,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
+ enableLocalMetrics := true
+ localMetricsAddress := "127.0.0.1:9292"
req := &proto.SetConfigRequest{
ProfileName: profName,
@@ -85,6 +88,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
RosenpassEnabled: &rosenpassEnabled,
RosenpassPermissive: &rosenpassPermissive,
ServerSSHAllowed: &serverSSHAllowed,
+ RemoteJobsAllowed: &remoteJobsAllowed,
InterfaceName: &interfaceName,
WireguardPort: &wireguardPort,
OptionalPreSharedKey: &preSharedKey,
@@ -107,6 +111,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
+ EnableLocalMetrics: &enableLocalMetrics,
+ LocalMetricsAddress: &localMetricsAddress,
}
_, err = s.SetConfig(ctx, req)
@@ -128,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
require.NotNil(t, cfg.ServerSSHAllowed)
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
+ require.NotNil(t, cfg.RemoteJobsAllowed)
+ require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed)
require.Equal(t, interfaceName, cfg.WgIface)
require.Equal(t, int(wireguardPort), cfg.WgPort)
require.Equal(t, preSharedKey, cfg.PreSharedKey)
@@ -153,6 +161,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, uint16(mtu), cfg.MTU)
require.NotNil(t, cfg.SSHJWTCacheTTL)
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
+ require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
+ require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
verifyAllFieldsCovered(t, req)
}
@@ -180,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"RosenpassEnabled": true,
"RosenpassPermissive": true,
"ServerSSHAllowed": true,
+ "RemoteJobsAllowed": true,
"InterfaceName": true,
"WireguardPort": true,
"OptionalPreSharedKey": true,
@@ -205,6 +216,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
+ "EnableLocalMetrics": true,
+ "LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -240,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-rosenpass": "RosenpassEnabled",
"rosenpass-permissive": "RosenpassPermissive",
"allow-server-ssh": "ServerSSHAllowed",
+ "allow-remote-jobs": "RemoteJobsAllowed",
"interface-name": "InterfaceName",
"wireguard-port": "WireguardPort",
"preshared-key": "OptionalPreSharedKey",
@@ -264,6 +278,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
"disable-ssh-auth": "DisableSSHAuth",
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
+ "enable-local-metrics": "EnableLocalMetrics",
+ "local-metrics-address": "LocalMetricsAddress",
}
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).
diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go
index ca1b4c4ee..01d24687e 100644
--- a/client/server/ssh_gate.go
+++ b/client/server/ssh_gate.go
@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
+ "github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
@@ -30,6 +31,8 @@ import (
// management identity hands SSH authorization decisions, including which
// keys and users are accepted, to whoever controls that identity. Changing
// the management URL and deregistering the peer are both ways to do that.
+// - Binding the local metrics endpoint to a non-loopback address publishes
+// peer names and connectivity state to the network without authentication.
//
// Everything else stays unauthenticated, so this is not an authorization model:
// it only refuses the changes that would let a local user become root. A caller
@@ -39,27 +42,36 @@ import (
// user-to-root boundary. Fields are nil or empty when the request leaves them
// untouched.
type privilegedConfigChange struct {
- managementURL string
- serverSSHAllowed *bool
- enableSSHRoot *bool
- disableSSHAuth *bool
+ managementURL string
+ serverSSHAllowed *bool
+ remoteJobsAllowed *bool
+ enableSSHRoot *bool
+ disableSSHAuth *bool
+ enableLocalMetrics *bool
+ localMetricsAddress *string
}
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
return privilegedConfigChange{
- managementURL: msg.GetManagementUrl(),
- serverSSHAllowed: msg.ServerSSHAllowed,
- enableSSHRoot: msg.EnableSSHRoot,
- disableSSHAuth: msg.DisableSSHAuth,
+ managementURL: msg.GetManagementUrl(),
+ serverSSHAllowed: msg.ServerSSHAllowed,
+ remoteJobsAllowed: msg.RemoteJobsAllowed,
+ enableSSHRoot: msg.EnableSSHRoot,
+ disableSSHAuth: msg.DisableSSHAuth,
+ enableLocalMetrics: msg.EnableLocalMetrics,
+ localMetricsAddress: msg.LocalMetricsAddress,
}
}
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
return privilegedConfigChange{
- managementURL: msg.GetManagementUrl(),
- serverSSHAllowed: msg.ServerSSHAllowed,
- enableSSHRoot: msg.EnableSSHRoot,
- disableSSHAuth: msg.DisableSSHAuth,
+ managementURL: msg.GetManagementUrl(),
+ serverSSHAllowed: msg.ServerSSHAllowed,
+ remoteJobsAllowed: msg.RemoteJobsAllowed,
+ enableSSHRoot: msg.EnableSSHRoot,
+ disableSSHAuth: msg.DisableSSHAuth,
+ enableLocalMetrics: msg.EnableLocalMetrics,
+ localMetricsAddress: msg.LocalMetricsAddress,
}
}
@@ -83,6 +95,21 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
}
+ // Enabling remote jobs lets the management server run jobs (e.g. debug
+ // bundles) on this host, so turning it on crosses the user-to-root
+ // boundary the same way enabling the SSH server does. The stored value
+ // defaults to off (nil = off), so a legacy config is correctly seen as
+ // off and turning it on requires privilege.
+ if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) {
+ return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs"))
+ }
+
+ if addr, exposes := exposesLocalMetrics(stored, change); exposes {
+ return denyPrivileged(ctx,
+ "exposing the local metrics endpoint on a non-loopback address",
+ ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr))
+ }
+
// Only guard the management binding while the SSH server is enabled: that is
// when the management identity decides who may open a shell here.
if !sshServerEnabled(stored) {
@@ -245,6 +272,48 @@ func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
return &enabled
}
+// exposesLocalMetrics reports whether the change would leave the metrics
+// endpoint enabled on an address that is not confirmed loopback, and returns
+// that address. A request that restates the stored state is not a change, so a
+// settings form resubmitted after an administrator opened the endpoint is not
+// refused.
+func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) {
+ storedEnabled, storedAddr := storedLocalMetrics(stored)
+
+ enabled := storedEnabled
+ if change.enableLocalMetrics != nil {
+ enabled = *change.enableLocalMetrics
+ }
+ addr := storedAddr
+ if change.localMetricsAddress != nil {
+ addr = metricsAddrOrDefault(*change.localMetricsAddress)
+ }
+
+ if !enabled || localmetrics.IsLoopback(addr) {
+ return "", false
+ }
+ if storedEnabled && storedAddr == addr {
+ return "", false
+ }
+ return addr, true
+}
+
+// storedLocalMetrics reads the metrics settings from the stored config,
+// tolerating a config that does not exist yet.
+func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) {
+ if cfg == nil {
+ return false, localmetrics.DefaultListenAddress
+ }
+ return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress)
+}
+
+func metricsAddrOrDefault(addr string) string {
+ if addr == "" {
+ return localmetrics.DefaultListenAddress
+ }
+ return addr
+}
+
// sameManagementURL reports whether requested addresses the same management
// server as stored, comparing scheme, host and effective port so that an
// equivalent spelling ("https://api.netbird.io" for a stored
diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go
index cbd345f16..b4712c64a 100644
--- a/client/server/ssh_gate_test.go
+++ b/client/server/ssh_gate_test.go
@@ -61,6 +61,8 @@ func noIdentityCtx() context.Context { return context.Background() }
func boolPtr(v bool) *bool { return &v }
+func strPtr(v string) *string { return &v }
+
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
@@ -171,6 +173,34 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
},
+ {
+ name: "enabling remote jobs unprivileged is refused",
+ stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
+ change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "enabling remote jobs as root is allowed",
+ stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
+ change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
+ privileged: true,
+ },
+ {
+ name: "a profile with no config yet counts as off, so enabling remote jobs is refused",
+ stored: nil,
+ change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ name: "restating already-enabled remote jobs is not a change",
+ stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
+ change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
+ },
+ {
+ name: "turning remote jobs off is not guarded",
+ stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
+ change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)},
+ },
{
name: "a request that touches none of the guarded fields is allowed",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
@@ -194,6 +224,102 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
}
}
+func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) {
+ exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"}
+
+ tests := []struct {
+ name string
+ stored *profilemanager.Config
+ change privilegedConfigChange
+ privileged bool
+ wantDeny bool
+ }{
+ {
+ name: "binding a non-loopback address unprivileged is refused",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
+ wantDeny: true,
+ },
+ {
+ name: "binding a non-loopback address as root is allowed",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
+ privileged: true,
+ },
+ {
+ name: "enabling on the default loopback address is not guarded",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
+ },
+ {
+ name: "enabling on an explicit loopback address is not guarded",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")},
+ },
+ {
+ name: "enabling on the IPv6 loopback address is not guarded",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")},
+ },
+ {
+ // The address alone does nothing while the endpoint stays off.
+ name: "a non-loopback address without enabling is not guarded",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
+ },
+ {
+ name: "widening an already enabled loopback endpoint is refused",
+ stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"},
+ change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
+ wantDeny: true,
+ },
+ {
+ name: "restating an already exposed endpoint is not a change",
+ stored: exposed,
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
+ },
+ {
+ name: "turning an exposed endpoint off is not guarded",
+ stored: exposed,
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)},
+ },
+ {
+ name: "re-enabling an exposed endpoint that was turned off is refused",
+ stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
+ wantDeny: true,
+ },
+ {
+ // Fail closed: an address that cannot be parsed is not confirmed loopback.
+ name: "an unparseable address is refused",
+ stored: &profilemanager.Config{},
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")},
+ wantDeny: true,
+ },
+ {
+ name: "a profile with no config yet counts as off, so exposing is refused",
+ stored: nil,
+ change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
+ wantDeny: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := userCtx()
+ if tt.privileged {
+ ctx = rootCtx()
+ }
+ err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
+ if tt.wantDeny {
+ assertDenied(t, err)
+ return
+ }
+ assertAllowed(t, err)
+ })
+ }
+}
+
func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
sshOn := func(raw string) *profilemanager.Config {
return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}
diff --git a/client/system/info.go b/client/system/info.go
index daeabca13..273c7a533 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -65,6 +65,7 @@ type Info struct {
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed bool
+ RemoteJobsAllowed bool
DisableClientRoutes bool
DisableServerRoutes bool
@@ -90,12 +91,16 @@ func (i *Info) SetFlags(
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
+ remoteJobsAllowed *bool,
) {
i.RosenpassEnabled = rosenpassEnabled
i.RosenpassPermissive = rosenpassPermissive
if serverSSHAllowed != nil {
i.ServerSSHAllowed = *serverSSHAllowed
}
+ if remoteJobsAllowed != nil {
+ i.RemoteJobsAllowed = *remoteJobsAllowed
+ }
i.DisableClientRoutes = disableClientRoutes
i.DisableServerRoutes = disableServerRoutes
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
index d1945894d..157005d3e 100644
--- a/client/testutil/privileged/runner_test.go
+++ b/client/testutil/privileged/runner_test.go
@@ -25,7 +25,7 @@ import (
// (.github/workflows/golang-test-linux.yml, test_client_on_docker).
const (
containerImage = "golang"
- containerTag = "1.25-alpine"
+ containerTag = "1.26.7-alpine"
)
const (
diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross
index a487b8db0..55c0d69e1 100644
--- a/client/ui/build/docker/Dockerfile.cross
+++ b/client/ui/build/docker/Dockerfile.cross
@@ -13,7 +13,7 @@
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
-FROM golang:1.25-bookworm
+FROM golang:1.26.7-bookworm
ARG TARGETARCH
diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server
index 58fb64f76..57183f1d2 100644
--- a/client/ui/build/docker/Dockerfile.server
+++ b/client/ui/build/docker/Dockerfile.server
@@ -2,7 +2,7 @@
# Multi-stage build for minimal image size
# Build stage
-FROM golang:alpine AS builder
+FROM golang:1.26.7-alpine AS builder
WORKDIR /app
diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
index ef8d6862f..e57040a7a 100644
--- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
+++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
@@ -18,6 +18,11 @@ import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
const SOON_THRESHOLD_SECONDS = 60 * 60;
+const DEADLINE_TOLERANCE_MS = 5 * 1000;
+// The final-warning deadline reaches the Go side as RFC3339 truncated to whole
+// seconds, while the status snapshot carries millisecond precision, so an
+// unchanged deadline can look up to 999 ms newer than the exact URL value.
+const EXACT_DEADLINE_TOLERANCE_MS = 999;
export default function SessionExpirationDialog() {
const { t } = useTranslation();
@@ -29,11 +34,19 @@ export default function SessionExpirationDialog() {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS;
}, [params]);
+ const initialDeadline = useMemo(() => {
+ const raw = params.get("deadline");
+ if (!raw) return null;
+ const n = Number.parseInt(raw, 10);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ }, [params]);
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const busyRef = useRef(busy);
busyRef.current = busy;
+ const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000);
+ const exactDeadlineRef = useRef(initialDeadline !== null);
const expired = remaining <= 0;
const expiredRef = useRef(expired);
expiredRef.current = expired;
@@ -45,23 +58,45 @@ export default function SessionExpirationDialog() {
useEffect(() => {
setRemaining(initialSeconds);
- }, [initialSeconds]);
+ openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000;
+ exactDeadlineRef.current = initialDeadline !== null;
+ }, [initialSeconds, initialDeadline]);
+ // Recompute from the absolute deadline instead of decrementing per tick: webview
+ // timers get suspended for tens of seconds (App Nap / hidden-window throttling),
+ // so a tick counter drifts behind the wall clock by the suspended time.
useEffect(() => {
const id = globalThis.setInterval(() => {
- setRemaining((s) => (s <= 1 ? 0 : s - 1));
+ setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000)));
}, 1000);
return () => globalThis.clearInterval(id);
}, [initialSeconds]);
+ // Auto-close only when the session was actually renewed elsewhere (tray action, CLI,
+ // main window): the daemon keeps emitting Connected snapshots regardless of session
+ // state, so the signal is the deadline jumping past the one this dialog was opened for.
+ // With the exact deadline from the URL any jump past its sub-second precision loss
+ // counts; the seconds-derived fallback needs a wider tolerance for the Go-side
+ // truncation and mount latency.
// Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state).
useEffect(() => {
- const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
- if (busyRef.current || expiredRef.current) return;
- if (ev?.data?.status === "Connected") {
- WindowManager.CloseSessionExpiration().catch(console.error);
- }
- });
+ const off = Events.On(
+ "netbird:status",
+ (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => {
+ if (busyRef.current || expiredRef.current) return;
+ if (ev?.data?.status !== "Connected") return;
+ const raw = ev?.data?.sessionExpiresAt;
+ if (!raw) return;
+ const renewed = Date.parse(raw);
+ if (!Number.isFinite(renewed)) return;
+ const tolerance = exactDeadlineRef.current
+ ? EXACT_DEADLINE_TOLERANCE_MS
+ : DEADLINE_TOLERANCE_MS;
+ if (renewed - openedDeadlineRef.current > tolerance) {
+ WindowManager.CloseSessionExpiration().catch(console.error);
+ }
+ },
+ );
return () => {
off();
};
diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json
index 419358d36..17fb1d8ea 100644
--- a/client/ui/i18n/locales/_index.json
+++ b/client/ui/i18n/locales/_index.json
@@ -1,6 +1,7 @@
{
"languages": [
{"code": "en", "displayName": "English (US)", "englishName": "English (US)"},
+ {"code": "uk", "displayName": "Українська", "englishName": "Ukrainian"},
{"code": "de", "displayName": "Deutsch", "englishName": "German"},
{"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"},
{"code": "ru", "displayName": "Русский", "englishName": "Russian"},
diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json
new file mode 100644
index 000000000..4e3f24102
--- /dev/null
+++ b/client/ui/i18n/locales/uk/common.json
@@ -0,0 +1,1376 @@
+{
+ "tray.tooltip": {
+ "message": "NetBird"
+ },
+ "tray.status.disconnected": {
+ "message": "Відключено"
+ },
+ "tray.status.daemonUnavailable": {
+ "message": "Не запущено"
+ },
+ "tray.status.error": {
+ "message": "Помилка"
+ },
+ "tray.status.connected": {
+ "message": "Підключено"
+ },
+ "tray.status.connecting": {
+ "message": "Підключення"
+ },
+ "tray.status.needsLogin": {
+ "message": "Потрібно ввійти"
+ },
+ "tray.status.loginFailed": {
+ "message": "Помилка входу"
+ },
+ "tray.status.sessionExpired": {
+ "message": "Сеанс закінчився"
+ },
+ "tray.session.expiresIn": {
+ "message": "До завершення сеансу: {remaining}"
+ },
+ "tray.session.unit.lessThanMinute": {
+ "message": "менше хвилини"
+ },
+ "tray.session.unit.minute": {
+ "message": "1 хв."
+ },
+ "tray.session.unit.minutes": {
+ "message": "{count} хв."
+ },
+ "tray.session.unit.hour": {
+ "message": "1 год."
+ },
+ "tray.session.unit.hours": {
+ "message": "{count} год."
+ },
+ "tray.session.unit.day": {
+ "message": "1 дн."
+ },
+ "tray.session.unit.days": {
+ "message": "{count} дн."
+ },
+ "tray.menu.open": {
+ "message": "Відкрити NetBird"
+ },
+ "tray.menu.connect": {
+ "message": "Підключитися"
+ },
+ "tray.menu.disconnect": {
+ "message": "Відключитися"
+ },
+ "tray.menu.exitNode": {
+ "message": "Вихідний вузол"
+ },
+ "tray.menu.networks": {
+ "message": "Ресурси"
+ },
+ "tray.menu.profiles": {
+ "message": "Профілі"
+ },
+ "tray.menu.manageProfiles": {
+ "message": "Керування профілями"
+ },
+ "tray.menu.settings": {
+ "message": "Налаштування…"
+ },
+ "tray.menu.debugBundle": {
+ "message": "Створити архів діагностики"
+ },
+ "tray.menu.about": {
+ "message": "Допомога та підтримка"
+ },
+ "tray.menu.github": {
+ "message": "GitHub"
+ },
+ "tray.menu.documentation": {
+ "message": "Документація"
+ },
+ "tray.menu.troubleshoot": {
+ "message": "Діагностика"
+ },
+ "tray.menu.downloadLatest": {
+ "message": "Завантажити останню версію"
+ },
+ "tray.menu.installVersion": {
+ "message": "Встановити версію {version}"
+ },
+ "tray.menu.guiVersion": {
+ "message": "Графічний інтерфейс: {version}"
+ },
+ "tray.menu.daemonVersion": {
+ "message": "Служба: {version}"
+ },
+ "tray.menu.versionUnknown": {
+ "message": "—"
+ },
+ "tray.menu.quit": {
+ "message": "Вийти з NetBird"
+ },
+ "notify.daemonOutdated.title": {
+ "message": "Служба NetBird застаріла"
+ },
+ "notify.daemonOutdated.body": {
+ "message": "Оновіть службу NetBird, щоб користуватися застосунком."
+ },
+ "notify.update.title": {
+ "message": "Доступне оновлення NetBird"
+ },
+ "notify.update.body": {
+ "message": "Доступна версія NetBird {version}."
+ },
+ "notify.update.enforcedSuffix": {
+ "message": " Ваш адміністратор вимагає встановити це оновлення."
+ },
+ "notify.error.title": {
+ "message": "Помилка"
+ },
+ "notify.error.connect": {
+ "message": "Не вдалося підключитися"
+ },
+ "notify.error.disconnect": {
+ "message": "Не вдалося відключитися"
+ },
+ "notify.error.switchProfile": {
+ "message": "Не вдалося перемкнутися на {profile}"
+ },
+ "notify.error.exitNode": {
+ "message": "Не вдалося оновити вихідний вузол {name}"
+ },
+ "notify.sessionExpired.title": {
+ "message": "Сеанс NetBird закінчився"
+ },
+ "notify.sessionExpired.body": {
+ "message": "Ваш сеанс NetBird закінчився. Будь ласка, увійдіть знову."
+ },
+ "notify.sessionWarning.title": {
+ "message": "Сеанс невдовзі закінчиться"
+ },
+ "notify.sessionWarning.body": {
+ "message": "Ваш сеанс NetBird закінчиться через {remaining}. Натисніть «Продовжити зараз», щоб оновити його."
+ },
+ "notify.sessionWarning.bodyGeneric": {
+ "message": "Ваш сеанс NetBird невдовзі закінчиться. Натисніть «Продовжити зараз», щоб оновити його."
+ },
+ "notify.sessionWarning.extend": {
+ "message": "Продовжити зараз"
+ },
+ "notify.sessionWarning.dismiss": {
+ "message": "Закрити"
+ },
+ "notify.sessionWarning.failed": {
+ "message": "Не вдалося продовжити сеанс NetBird"
+ },
+ "notify.sessionWarning.successTitle": {
+ "message": "Сеанс NetBird продовжено"
+ },
+ "notify.sessionWarning.successBody": {
+ "message": "Ваш сеанс успішно продовжено."
+ },
+ "notify.sessionDeadlineRejected.title": {
+ "message": "Недійсний термін дії сеансу"
+ },
+ "notify.sessionDeadlineRejected.body": {
+ "message": "Сервер надіслав недійсний термін дії сеансу. Будь ласка, увійдіть знову."
+ },
+ "notify.mdm.policyApplied.title": {
+ "message": "Налаштування NetBird оновлено"
+ },
+ "notify.mdm.policyApplied.body": {
+ "message": "Конфігурацію NetBird оновлено відповідно до політики вашої організації."
+ },
+ "common.cancel": {
+ "message": "Скасувати"
+ },
+ "common.save": {
+ "message": "Зберегти"
+ },
+ "common.saveChanges": {
+ "message": "Зберегти зміни"
+ },
+ "common.saving": {
+ "message": "Збереження…"
+ },
+ "common.close": {
+ "message": "Закрити"
+ },
+ "common.copy": {
+ "message": "Копіювати"
+ },
+ "common.togglePasswordVisibility": {
+ "message": "Показати/сховати пароль"
+ },
+ "common.increase": {
+ "message": "Збільшити"
+ },
+ "common.decrease": {
+ "message": "Зменшити"
+ },
+ "common.delete": {
+ "message": "Видалити"
+ },
+ "common.create": {
+ "message": "Створити"
+ },
+ "common.add": {
+ "message": "Додати"
+ },
+ "common.remove": {
+ "message": "Вилучити"
+ },
+ "common.refresh": {
+ "message": "Оновити"
+ },
+ "common.loading": {
+ "message": "Завантаження…"
+ },
+ "common.netbird": {
+ "message": "NetBird"
+ },
+ "common.noResults.title": {
+ "message": "Результатів не знайдено"
+ },
+ "common.noResults.description": {
+ "message": "Ми не змогли нічого знайти. Спробуйте змінити пошуковий запит або налаштування фільтрів."
+ },
+ "notConnected.title": {
+ "message": "Відключено"
+ },
+ "notConnected.description": {
+ "message": "Спочатку підключіться до NetBird, щоб переглянути детальну інформацію про піри, мережеві ресурси та вихідні вузли."
+ },
+ "connect.status.disconnected": {
+ "message": "Відключено"
+ },
+ "connect.status.connecting": {
+ "message": "Підключення…"
+ },
+ "connect.status.connected": {
+ "message": "Підключено"
+ },
+ "connect.status.disconnecting": {
+ "message": "Відключення…"
+ },
+ "connect.status.daemonUnavailable": {
+ "message": "Служба недоступна"
+ },
+ "connect.status.loginRequired": {
+ "message": "Потрібно ввійти"
+ },
+ "connect.error.loginTitle": {
+ "message": "Помилка входу"
+ },
+ "connect.error.connectTitle": {
+ "message": "Помилка підключення"
+ },
+ "connect.error.disconnectTitle": {
+ "message": "Помилка відключення"
+ },
+ "nav.peers.title": {
+ "message": "Піри"
+ },
+ "nav.peers.description": {
+ "message": "Підключено {connected} з {total}"
+ },
+ "nav.resources.title": {
+ "message": "Ресурси"
+ },
+ "nav.resources.description": {
+ "message": "Активно {active} з {total}"
+ },
+ "nav.exitNode.title": {
+ "message": "Вихідні вузли"
+ },
+ "nav.exitNode.none": {
+ "message": "Неактивний"
+ },
+ "nav.exitNode.using": {
+ "message": "Через {name}"
+ },
+ "header.openSettings": {
+ "message": "Відкрити налаштування"
+ },
+ "header.togglePanel": {
+ "message": "Показати/сховати бічну панель"
+ },
+ "profile.selector.loading": {
+ "message": "Завантаження…"
+ },
+ "profile.selector.noProfile": {
+ "message": "Немає профілю"
+ },
+ "profile.selector.searchPlaceholder": {
+ "message": "Пошук профілю за назвою…"
+ },
+ "profile.selector.emptyTitle": {
+ "message": "Профілів не знайдено"
+ },
+ "profile.selector.emptyDescription": {
+ "message": "Спробуйте змінити пошуковий запит або створіть новий профіль."
+ },
+ "profile.selector.newProfile": {
+ "message": "Новий профіль"
+ },
+ "profile.selector.moreOptions": {
+ "message": "Додаткові параметри"
+ },
+ "profile.selector.deregister": {
+ "message": "Вийти з профілю"
+ },
+ "profile.selector.delete": {
+ "message": "Видалити"
+ },
+ "profile.selector.switchTo": {
+ "message": "Перемкнутися на цей профіль"
+ },
+ "profile.selector.edit": {
+ "message": "Редагувати"
+ },
+ "profile.edit.title": {
+ "message": "Редагувати профіль"
+ },
+ "profile.edit.submit": {
+ "message": "Зберегти зміни"
+ },
+ "profile.dialog.title": {
+ "message": "Введіть назву профілю"
+ },
+ "profile.dialog.nameLabel": {
+ "message": "Назва профілю"
+ },
+ "profile.dialog.description": {
+ "message": "Вкажіть зрозумілу назву для вашого профілю."
+ },
+ "profile.dialog.placeholder": {
+ "message": "наприклад, Робота"
+ },
+ "profile.dialog.submit": {
+ "message": "Додати профіль"
+ },
+ "profile.dialog.required": {
+ "message": "Будь ласка, введіть назву профілю, наприклад, «Робота» або «Дім»."
+ },
+ "profile.dialog.managementHelp": {
+ "message": "Використовуйте NetBird Cloud або власний сервер."
+ },
+ "profile.dialog.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або додайте профіль, якщо ви впевнені, що вона правильна."
+ },
+ "header.menu.settings": {
+ "message": "Налаштування…"
+ },
+ "header.menu.defaultView": {
+ "message": "Стандартний вигляд"
+ },
+ "header.menu.advancedView": {
+ "message": "Розширений вигляд"
+ },
+ "header.menu.updateAvailable": {
+ "message": "Доступне оновлення"
+ },
+ "header.menu.open": {
+ "message": "Відкрити меню"
+ },
+ "header.profile.switch": {
+ "message": "Змінити профіль"
+ },
+ "connect.toggle.label": {
+ "message": "Перемкнути підключення NetBird"
+ },
+ "connect.localIp.label": {
+ "message": "Локальні IP-адреси"
+ },
+ "common.search": {
+ "message": "Пошук"
+ },
+ "common.filter": {
+ "message": "Фільтр"
+ },
+ "exitNodes.dropdown.trigger": {
+ "message": "Вибрати вихідний вузол"
+ },
+ "peers.row.label": {
+ "message": "Відкрити деталі для {name}, {status}"
+ },
+ "peers.dialog.title": {
+ "message": "Деталі піра"
+ },
+ "networks.row.toggle": {
+ "message": "Перемкнути {name}"
+ },
+ "networks.bulk.label": {
+ "message": "Перемкнути всі видимі ресурси"
+ },
+ "profile.switch.title": {
+ "message": "Перемкнутися на профіль «{name}»?"
+ },
+ "profile.switch.message": {
+ "message": "Ви впевнені, що хочете змінити профіль?\nВаш поточний профіль буде відключено."
+ },
+ "profile.switch.confirm": {
+ "message": "Підтвердити"
+ },
+ "profile.deregister.title": {
+ "message": "Вийти з профілю «{name}»?"
+ },
+ "profile.deregister.message": {
+ "message": "Ви впевнені, що хочете вийти з цього профілю?\nВам доведеться увійти знову, щоб використовувати його."
+ },
+ "profile.deregister.confirm": {
+ "message": "Вийти"
+ },
+ "profile.delete.title": {
+ "message": "Видалити профіль «{name}»?"
+ },
+ "profile.delete.message": {
+ "message": "Ви впевнені, що хочете видалити цей профіль?\nЦю дію неможливо скасувати."
+ },
+ "profile.delete.disabledActive": {
+ "message": "Активні профілі не можна видаляти. Перемкніться на інший профіль перед видаленням цього."
+ },
+ "profile.delete.disabledDefault": {
+ "message": "Профіль за замовчуванням не можна видалити."
+ },
+ "profile.error.switchTitle": {
+ "message": "Помилка зміни профілю"
+ },
+ "profile.error.deregisterTitle": {
+ "message": "Помилка виходу з профілю"
+ },
+ "profile.error.deleteTitle": {
+ "message": "Помилка видалення профілю"
+ },
+ "profile.error.createTitle": {
+ "message": "Помилка створення профілю"
+ },
+ "profile.error.editTitle": {
+ "message": "Помилка редагування профілю"
+ },
+ "profile.error.loadTitle": {
+ "message": "Помилка завантаження профілів"
+ },
+ "profile.dropdown.activeProfile": {
+ "message": "Активний профіль"
+ },
+ "profile.dropdown.switchProfile": {
+ "message": "Змінити профіль"
+ },
+ "profile.dropdown.noEmail": {
+ "message": "Інше"
+ },
+ "profile.dropdown.addProfile": {
+ "message": "Додати профіль"
+ },
+ "profile.dropdown.manageProfiles": {
+ "message": "Керування профілями"
+ },
+ "profile.dropdown.settings": {
+ "message": "Налаштування"
+ },
+ "settings.profiles.section.profiles": {
+ "message": "Профілі"
+ },
+ "settings.profiles.intro": {
+ "message": "Використовуйте кілька профілів NetBird одночасно, наприклад, робочий та особистий облікові записи або різні сервери керування. Додавайте профілі, виходьте з них або видаляйте їх нижче."
+ },
+ "settings.profiles.addProfile": {
+ "message": "Додати профіль"
+ },
+ "settings.profiles.active": {
+ "message": "Активний"
+ },
+ "settings.profiles.emptyTitle": {
+ "message": "Немає профілів"
+ },
+ "settings.profiles.emptyDescription": {
+ "message": "Створіть профіль, щоб підключитися до сервера керування NetBird."
+ },
+ "settings.error.loadTitle": {
+ "message": "Помилка завантаження налаштувань"
+ },
+ "settings.error.saveTitle": {
+ "message": "Помилка збереження налаштувань"
+ },
+ "settings.error.debugBundleTitle": {
+ "message": "Помилка створення архіву діагностики"
+ },
+ "settings.nav.label": {
+ "message": "Розділи налаштувань"
+ },
+ "settings.tabs.general": {
+ "message": "Загальні"
+ },
+ "settings.tabs.network": {
+ "message": "Мережа"
+ },
+ "settings.tabs.security": {
+ "message": "Безпека"
+ },
+ "settings.tabs.profiles": {
+ "message": "Профілі"
+ },
+ "settings.tabs.ssh": {
+ "message": "SSH"
+ },
+ "settings.tabs.advanced": {
+ "message": "Розширені"
+ },
+ "settings.tabs.troubleshooting": {
+ "message": "Діагностика"
+ },
+ "settings.tabs.about": {
+ "message": "Про програму"
+ },
+ "settings.tabs.updateAvailable": {
+ "message": "Доступне оновлення"
+ },
+ "settings.general.section.general": {
+ "message": "Загальні"
+ },
+ "settings.general.section.connection": {
+ "message": "Підключення"
+ },
+ "settings.general.connectOnStartup.label": {
+ "message": "Підключитися під час запуску"
+ },
+ "settings.general.connectOnStartup.help": {
+ "message": "Автоматично встановлювати підключення під час запуску служби."
+ },
+ "settings.general.notifications.label": {
+ "message": "Сповіщення на робочому столі"
+ },
+ "settings.general.notifications.help": {
+ "message": "Показувати сповіщення на робочому столі про нові оновлення та події підключення."
+ },
+ "settings.general.autostart.label": {
+ "message": "Запускати інтерфейс NetBird під час входу"
+ },
+ "settings.general.autostart.help": {
+ "message": "Автоматично запускати інтерфейс NetBird під час входу в систему. Це стосується лише графічного інтерфейсу, а не фонової служби."
+ },
+ "settings.general.autostart.errorTitle": {
+ "message": "Помилка зміни автозапуску"
+ },
+ "settings.general.keepConnectedOnQuit.label": {
+ "message": "Залишатися підключеним після виходу"
+ },
+ "settings.general.keepConnectedOnQuit.help": {
+ "message": "Підключення залишатиметься активним у фоновому режимі після закриття NetBird. Воно буде розірвано лише тоді, коли ви відключите його самостійно."
+ },
+ "settings.general.language.label": {
+ "message": "Мова інтерфейсу"
+ },
+ "settings.general.language.help": {
+ "message": "Виберіть мову для інтерфейсу NetBird."
+ },
+ "settings.general.language.search": {
+ "message": "Пошук мови…"
+ },
+ "settings.general.language.empty": {
+ "message": "Не знайдено жодної мови."
+ },
+ "settings.general.management.label": {
+ "message": "Сервер керування"
+ },
+ "settings.general.management.help": {
+ "message": "Підключайтеся до NetBird Cloud або власного сервера керування. Зміни призведуть до перепідключення клієнта."
+ },
+ "settings.general.management.cloud": {
+ "message": "Cloud"
+ },
+ "settings.general.management.selfHosted": {
+ "message": "Власний сервер"
+ },
+ "settings.general.management.urlPlaceholder": {
+ "message": "https://netbird.selfhosted.com:443"
+ },
+ "settings.general.management.urlError": {
+ "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+ },
+ "settings.general.management.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або все одно збережіть зміни, якщо ви впевнені, що вона правильна."
+ },
+ "settings.general.management.switchCloudTitle": {
+ "message": "Перемкнутися на NetBird Cloud?"
+ },
+ "settings.general.management.switchCloudMessage": {
+ "message": "Це відключить вас від власного сервера.\nВам може знадобитися увійти знову."
+ },
+ "settings.general.management.switchCloudConfirm": {
+ "message": "Перемкнутися на Cloud"
+ },
+ "settings.network.section.connectivity": {
+ "message": "Підключення"
+ },
+ "settings.network.section.routingDns": {
+ "message": "Маршрутизація та DNS"
+ },
+ "settings.network.monitor.label": {
+ "message": "Перепідключатися при зміні мережі"
+ },
+ "settings.network.monitor.help": {
+ "message": "Відстежувати мережу й автоматично перепідключатися у разі таких змін, як перемикання Wi-Fi, зміна Ethernet-підключення або вихід із режиму сну."
+ },
+ "settings.network.dns.label": {
+ "message": "Увімкнути DNS"
+ },
+ "settings.network.dns.help": {
+ "message": "Застосовувати налаштування DNS, якими керує NetBird, до локального DNS-розв’язувача хоста."
+ },
+ "settings.network.clientRoutes.label": {
+ "message": "Увімкнути клієнтські маршрути"
+ },
+ "settings.network.clientRoutes.help": {
+ "message": "Приймати маршрути від інших пірів для доступу до їхніх мереж."
+ },
+ "settings.network.serverRoutes.label": {
+ "message": "Увімкнути серверні маршрути"
+ },
+ "settings.network.serverRoutes.help": {
+ "message": "Анонсувати локальні маршрути цього хоста іншим пірам."
+ },
+ "settings.network.ipv6.label": {
+ "message": "Увімкнути IPv6"
+ },
+ "settings.network.ipv6.help": {
+ "message": "Використовувати адресацію IPv6 для оверлейної мережі NetBird."
+ },
+ "settings.security.section.firewall": {
+ "message": "Брандмауер"
+ },
+ "settings.security.section.encryption": {
+ "message": "Шифрування"
+ },
+ "settings.security.blockInbound.label": {
+ "message": "Блокувати вхідний трафік"
+ },
+ "settings.security.blockInbound.help": {
+ "message": "Відхиляти небажані підключення від пірів до цього пристрою та будь-яких мереж, які він маршрутизує. Вихідний трафік не обмежується."
+ },
+ "settings.security.blockLan.label": {
+ "message": "Блокувати доступ до LAN"
+ },
+ "settings.security.blockLan.help": {
+ "message": "Заборонити пірам отримувати доступ до вашої локальної мережі або її пристроїв, коли цей пристрій маршрутизує їхній трафік."
+ },
+ "settings.security.rosenpass.label": {
+ "message": "Увімкнути постквантову стійкість"
+ },
+ "settings.security.rosenpass.help": {
+ "message": "Додати постквантовий обмін ключами через Rosenpass поверх WireGuard®."
+ },
+ "settings.security.rosenpassPermissive.label": {
+ "message": "Увімкнути дозвільний режим"
+ },
+ "settings.security.rosenpassPermissive.help": {
+ "message": "Дозволити підключення до пірів без підтримки постквантової стійкості."
+ },
+ "settings.ssh.section.server": {
+ "message": "Сервер"
+ },
+ "settings.ssh.section.capabilities": {
+ "message": "Можливості"
+ },
+ "settings.ssh.section.authentication": {
+ "message": "Автентифікація"
+ },
+ "settings.ssh.server.label": {
+ "message": "Увімкнути SSH-сервер"
+ },
+ "settings.ssh.server.help": {
+ "message": "Запустити SSH-сервер NetBird на цьому хості, щоб інші піри могли підключатися до нього."
+ },
+ "settings.ssh.root.label": {
+ "message": "Дозволити вхід як root"
+ },
+ "settings.ssh.root.help": {
+ "message": "Дозволити пірам входити як користувач root. Вимкніть, щоб вимагати непривілейований обліковий запис."
+ },
+ "settings.ssh.sftp.label": {
+ "message": "Дозволити SFTP"
+ },
+ "settings.ssh.sftp.help": {
+ "message": "Безпечно передавати файли за допомогою нативних клієнтів SFTP або SCP."
+ },
+ "settings.ssh.localForward.label": {
+ "message": "Локальне переспрямування портів"
+ },
+ "settings.ssh.localForward.help": {
+ "message": "Дозволити пірам, що підключаються, переспрямовувати локальні порти до сервісів, доступних із цього хоста."
+ },
+ "settings.ssh.remoteForward.label": {
+ "message": "Віддалене переспрямування портів"
+ },
+ "settings.ssh.remoteForward.help": {
+ "message": "Дозволити підключеним пірам відкривати порти на цьому хості з переспрямуванням на свої машини."
+ },
+ "settings.ssh.jwt.label": {
+ "message": "Увімкнути JWT-автентифікацію"
+ },
+ "settings.ssh.jwt.help": {
+ "message": "Перевіряти кожен сеанс SSH через ваш IdP для ідентифікації користувачів та аудиту. Вимкніть, щоб покладатися лише на політики мережевих ACL, що корисно, коли IdP недоступний."
+ },
+ "settings.ssh.jwtTtl.label": {
+ "message": "Час кешування JWT (TTL)"
+ },
+ "settings.ssh.jwtTtl.help": {
+ "message": "Як довго цей клієнт кешує JWT перед повторним запитом для вихідних SSH-з’єднань. Встановіть 0, щоб вимкнути кешування та проходити автентифікацію при кожному підключенні."
+ },
+ "settings.ssh.jwtTtl.suffix": {
+ "message": "сек."
+ },
+ "settings.advanced.section.interface": {
+ "message": "Інтерфейс"
+ },
+ "settings.advanced.section.security": {
+ "message": "Безпека"
+ },
+ "settings.advanced.interfaceName.label": {
+ "message": "Назва"
+ },
+ "settings.advanced.interfaceName.error": {
+ "message": "Використовуйте 1-15 літер, цифр, крапок, дефісів або підкреслень."
+ },
+ "settings.advanced.interfaceName.errorMac": {
+ "message": "Повинно починатися з «utun», після якого має йти число (наприклад, utun100)."
+ },
+ "settings.advanced.port.label": {
+ "message": "Порт"
+ },
+ "settings.advanced.port.error": {
+ "message": "Введіть порт між {min} та {max}."
+ },
+ "settings.advanced.port.help": {
+ "message": "Якщо встановлено 0, буде використано випадковий вільний порт."
+ },
+ "settings.advanced.mtu.label": {
+ "message": "MTU"
+ },
+ "settings.advanced.mtu.error": {
+ "message": "Введіть значення MTU між {min} та {max}."
+ },
+ "settings.advanced.psk.label": {
+ "message": "Попередньо узгоджений ключ"
+ },
+ "settings.advanced.psk.help": {
+ "message": "Додатковий PSK WireGuard для симетричного шифрування. Це не те саме, що NetBird Setup Key. Ви зможете обмінюватися даними лише з тими пірами, які використовують такий самий попередньо узгоджений ключ."
+ },
+ "settings.troubleshooting.section.title": {
+ "message": "Архів діагностики"
+ },
+ "settings.troubleshooting.anonymize.label": {
+ "message": "Анонімізувати чутливу інформацію"
+ },
+ "settings.troubleshooting.anonymize.help": {
+ "message": "Приховує IP-адреси, домени та інші конфіденційні дані."
+ },
+ "settings.troubleshooting.anonymize.info": {
+ "message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
+ },
+ "settings.troubleshooting.anonymize.none": {
+ "message": "Вимкнено"
+ },
+ "settings.troubleshooting.anonymize.default": {
+ "message": "Стандартний"
+ },
+ "settings.troubleshooting.anonymize.strict": {
+ "message": "Суворий"
+ },
+ "settings.troubleshooting.systemInfo.label": {
+ "message": "Додати інформацію про систему"
+ },
+ "settings.troubleshooting.systemInfo.help": {
+ "message": "Додати дані про ОС, ядро, мережеві інтерфейси та таблиці маршрутизації."
+ },
+ "settings.troubleshooting.upload.label": {
+ "message": "Завантажити архів на сервери NetBird"
+ },
+ "settings.troubleshooting.upload.help": {
+ "message": "Створює ключ завантаження, який можна передати службі підтримки NetBird."
+ },
+ "settings.troubleshooting.trace.label": {
+ "message": "Увімкнути журнали рівня TRACE"
+ },
+ "settings.troubleshooting.trace.help": {
+ "message": "Підвищує рівень журналювання до TRACE на час створення архіву та відновлює його після завершення."
+ },
+ "settings.troubleshooting.capture.label": {
+ "message": "Запис сеансу"
+ },
+ "settings.troubleshooting.capture.help": {
+ "message": "Перепідключає NetBird і чекає, щоб ви могли відтворити проблему."
+ },
+ "settings.troubleshooting.packets.label": {
+ "message": "Захоплювати мережеві пакети"
+ },
+ "settings.troubleshooting.packets.help": {
+ "message": "Зберігає файл .pcap із мережевим трафіком протягом сеансу захоплення."
+ },
+ "settings.troubleshooting.duration.label": {
+ "message": "Тривалість захоплення"
+ },
+ "settings.troubleshooting.duration.help": {
+ "message": "Скільки часу триває сеанс захоплення."
+ },
+ "settings.troubleshooting.duration.suffix": {
+ "message": "хв."
+ },
+ "settings.troubleshooting.create": {
+ "message": "Створити архів"
+ },
+ "settings.troubleshooting.progress.description": {
+ "message": "Збір журналів, даних про систему та інформації про стан підключення. Зазвичай це займає хвилину. Ви можете продовжувати використовувати NetBird або закрити вікно налаштувань, поки процес триває."
+ },
+ "settings.troubleshooting.cancelling": {
+ "message": "Скасування…"
+ },
+ "settings.troubleshooting.done.uploadedTitle": {
+ "message": "Архів діагностики успішно завантажено!"
+ },
+ "settings.troubleshooting.done.savedTitle": {
+ "message": "Архів збережено"
+ },
+ "settings.troubleshooting.done.uploadedDescription": {
+ "message": "Поділіться ключем завантаження нижче зі службою підтримки NetBird. Локальну копію також збережено на вашому пристрої."
+ },
+ "settings.troubleshooting.done.savedDescription": {
+ "message": "Ваш архів діагностики збережено локально."
+ },
+ "settings.troubleshooting.done.copyKey": {
+ "message": "Копіювати ключ"
+ },
+ "settings.troubleshooting.done.openFolder": {
+ "message": "Відкрити папку"
+ },
+ "settings.troubleshooting.done.openFileLocation": {
+ "message": "Відкрити розташування файлу"
+ },
+ "settings.troubleshooting.uploadFailedWithReason": {
+ "message": "Помилка завантаження: {reason} Архів все одно збережено локально"
+ },
+ "settings.troubleshooting.uploadFailed": {
+ "message": "Помилка завантаження. Архів все одно збережено локально."
+ },
+ "settings.troubleshooting.stage.reconnecting": {
+ "message": "Перепідключення NetBird…"
+ },
+ "settings.troubleshooting.stage.capturing": {
+ "message": "Запис журналів діагностики"
+ },
+ "settings.troubleshooting.stage.bundling": {
+ "message": "Створення архіву діагностики…"
+ },
+ "settings.troubleshooting.stage.uploading": {
+ "message": "Завантаження на сервери NetBird…"
+ },
+ "settings.troubleshooting.stage.cancelling": {
+ "message": "Скасування…"
+ },
+ "settings.about.client": {
+ "message": "NetBird Client v{version}"
+ },
+ "settings.about.clientName": {
+ "message": "NetBird Client"
+ },
+ "settings.about.development": {
+ "message": "[Розробка]"
+ },
+ "settings.about.gui": {
+ "message": "Графічний інтерфейс v{version}"
+ },
+ "settings.about.guiName": {
+ "message": "Графічний інтерфейс"
+ },
+ "settings.about.copyright": {
+ "message": "© {year} NetBird. Усі права захищено."
+ },
+ "settings.about.links.imprint": {
+ "message": "Реквізити"
+ },
+ "settings.about.links.privacy": {
+ "message": "Конфіденційність"
+ },
+ "settings.about.links.cla": {
+ "message": "CLA"
+ },
+ "settings.about.links.terms": {
+ "message": "Умови використання"
+ },
+ "settings.about.community.github": {
+ "message": "GitHub"
+ },
+ "settings.about.community.slack": {
+ "message": "Slack"
+ },
+ "settings.about.community.forum": {
+ "message": "Форум"
+ },
+ "settings.about.community.documentation": {
+ "message": "Документація"
+ },
+ "settings.about.community.feedback": {
+ "message": "Зворотний зв’язок"
+ },
+ "update.banner.message": {
+ "message": "NetBird {version} готовий до встановлення."
+ },
+ "update.banner.later": {
+ "message": "Пізніше"
+ },
+ "update.banner.installNow": {
+ "message": "Встановити зараз"
+ },
+ "update.card.versionAvailableDownload": {
+ "message": "Версія {version} доступна для завантаження."
+ },
+ "update.card.versionAvailableInstall": {
+ "message": "Версія {version} доступна для встановлення."
+ },
+ "update.card.whatsNew": {
+ "message": "Що нового?"
+ },
+ "update.card.installNow": {
+ "message": "Встановити зараз"
+ },
+ "update.card.getInstaller": {
+ "message": "Завантажити"
+ },
+ "update.card.autoCheckInterval": {
+ "message": "NetBird перевіряє наявність оновлень у фоновому режимі."
+ },
+ "update.card.changelog": {
+ "message": "Список змін"
+ },
+ "update.card.onLatestVersion": {
+ "message": "Ви використовуєте останню версію"
+ },
+ "update.header.tooltip": {
+ "message": "Доступне оновлення"
+ },
+ "update.overlay.updatingVersion": {
+ "message": "Оновлення NetBird до v{version}"
+ },
+ "update.overlay.updating": {
+ "message": "Оновлення NetBird"
+ },
+ "update.overlay.description": {
+ "message": "Доступна новіша версія, яка зараз встановлюється. NetBird автоматично перезапуститься після завершення оновлення."
+ },
+ "update.overlay.error.timeoutTitle": {
+ "message": "Оновлення триває занадто довго"
+ },
+ "update.overlay.error.timeoutDescription": {
+ "message": "Встановлення {target} тривало занадто довго і не завершилося."
+ },
+ "update.overlay.error.canceledTitle": {
+ "message": "Оновлення зупинено"
+ },
+ "update.overlay.error.canceledDescription": {
+ "message": "Оновлення до {target} було скасовано до його завершення."
+ },
+ "update.overlay.error.failTitle": {
+ "message": "Не вдалося встановити оновлення"
+ },
+ "update.overlay.error.failDescription": {
+ "message": "Не вдалося встановити оновлення до {target}."
+ },
+ "update.overlay.error.unknownMessage": {
+ "message": "Невідома помилка"
+ },
+ "update.overlay.error.targetVersion": {
+ "message": "v{version}"
+ },
+ "update.overlay.error.targetFallback": {
+ "message": "нової версії"
+ },
+ "update.error.loadStateTitle": {
+ "message": "Помилка завантаження стану оновлення"
+ },
+ "update.error.triggerTitle": {
+ "message": "Помилка запуску оновлення"
+ },
+ "update.page.versionLine": {
+ "message": "Оновлення клієнта до версії {version}."
+ },
+ "update.page.versionLineGeneric": {
+ "message": "Оновлення клієнта."
+ },
+ "update.page.outdated": {
+ "message": "Ваша версія клієнта старіша за версію для автооновлення, задану в Management."
+ },
+ "update.page.status.running": {
+ "message": "Оновлення"
+ },
+ "update.page.status.timeout": {
+ "message": "Час очікування оновлення минув. Будь ласка, спробуйте ще раз."
+ },
+ "update.page.status.canceled": {
+ "message": "Оновлення скасовано."
+ },
+ "update.page.status.failed": {
+ "message": "Помилка оновлення: {message}"
+ },
+ "update.page.status.unknownError": {
+ "message": "невідома помилка оновлення"
+ },
+ "update.page.failedTitle": {
+ "message": "Помилка оновлення"
+ },
+ "update.page.timeoutMessage": {
+ "message": "Час очікування оновлення минув."
+ },
+ "update.page.dontClose": {
+ "message": "Будь ласка, не закривайте це вікно."
+ },
+ "update.page.updating": {
+ "message": "Оновлення…"
+ },
+ "update.page.complete": {
+ "message": "Оновлення завершено"
+ },
+ "update.page.failed": {
+ "message": "Помилка оновлення"
+ },
+ "window.title.settings": {
+ "message": "Налаштування"
+ },
+ "window.title.signIn": {
+ "message": "Вхід"
+ },
+ "window.title.sessionExpiration": {
+ "message": "Термін дії сеансу закінчується"
+ },
+ "window.title.updating": {
+ "message": "Оновлення"
+ },
+ "window.title.welcome": {
+ "message": "Ласкаво просимо до NetBird"
+ },
+ "window.title.error": {
+ "message": "Помилка"
+ },
+ "welcome.title": {
+ "message": "Знайдіть NetBird в області сповіщень"
+ },
+ "welcome.titleMac": {
+ "message": "Знайдіть NetBird у рядку меню"
+ },
+ "welcome.description": {
+ "message": "NetBird працює в області сповіщень. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+ },
+ "welcome.descriptionMac": {
+ "message": "NetBird працює в рядку меню. Натисніть на іконку, щоб підключитися, змінити профіль або відкрити налаштування."
+ },
+ "welcome.continue": {
+ "message": "Продовжити"
+ },
+ "welcome.back": {
+ "message": "Назад"
+ },
+ "welcome.management.title": {
+ "message": "Налаштування NetBird"
+ },
+ "welcome.management.description": {
+ "message": "Натисніть «Продовжити», щоб розпочати, або виберіть Власний сервер, якщо у вас є власний сервер NetBird."
+ },
+ "welcome.management.cloud.title": {
+ "message": "NetBird Cloud"
+ },
+ "welcome.management.cloud.description": {
+ "message": "Використовуйте наш хмарний сервіс. Налаштування не потрібне."
+ },
+ "welcome.management.selfHosted.title": {
+ "message": "Власний сервер"
+ },
+ "welcome.management.selfHosted.description": {
+ "message": "Підключіться до власного сервера керування."
+ },
+ "welcome.management.urlLabel": {
+ "message": "URL-адреса сервера керування"
+ },
+ "welcome.management.urlPlaceholder": {
+ "message": "https://netbird.selfhosted.com:443"
+ },
+ "welcome.management.urlInvalid": {
+ "message": "Будь ласка, введіть дійсну URL-адресу, наприклад: https://netbird.selfhosted.com:443"
+ },
+ "welcome.management.urlUnreachable": {
+ "message": "Не вдалося підключитися до цього сервера. Перевірте URL-адресу або вашу мережу, а потім продовжуйте, якщо ви впевнені, що вона правильна."
+ },
+ "welcome.management.checking": {
+ "message": "Перевірка…"
+ },
+ "browserLogin.title": {
+ "message": "Завершіть вхід у браузері"
+ },
+ "browserLogin.notSeeing": {
+ "message": "Ми відкрили вкладку браузера, щоб ви могли завершити вхід. Не бачите її?"
+ },
+ "browserLogin.tryAgain": {
+ "message": "Спробувати ще раз"
+ },
+ "browserLogin.openFailedTitle": {
+ "message": "Помилка відкриття браузера"
+ },
+ "sessionExpiration.title": {
+ "message": "Термін дії сеансу невдовзі закінчиться"
+ },
+ "sessionExpiration.titleLater": {
+ "message": "Термін дії вашого сеансу закінчиться"
+ },
+ "sessionExpiration.description": {
+ "message": "Цей пристрій невдовзі буде відключено. Поновіть сеанс, увійшовши через браузер."
+ },
+ "sessionExpiration.descriptionLater": {
+ "message": "Вхід через браузер підтримує підключення цього пристрою до вашої мережі."
+ },
+ "sessionExpiration.stay": {
+ "message": "Продовжити сеанс"
+ },
+ "sessionExpiration.authenticate": {
+ "message": "Увійти"
+ },
+ "sessionExpiration.logout": {
+ "message": "Вийти"
+ },
+ "sessionExpiration.expired": {
+ "message": "Термін дії сеансу закінчився"
+ },
+ "sessionExpiration.expiredDescription": {
+ "message": "Пристрій відключено. Пройдіть автентифікацію у браузері, щоб перепідключитися."
+ },
+ "sessionExpiration.close": {
+ "message": "Закрити"
+ },
+ "sessionExpiration.extendFailedTitle": {
+ "message": "Помилка продовження сеансу"
+ },
+ "sessionExpiration.logoutFailedTitle": {
+ "message": "Помилка виходу"
+ },
+ "peers.search.placeholder": {
+ "message": "Пошук за ім’ям або IP"
+ },
+ "peers.filter.all": {
+ "message": "Усі"
+ },
+ "peers.filter.online": {
+ "message": "Онлайн"
+ },
+ "peers.filter.offline": {
+ "message": "Офлайн"
+ },
+ "peers.empty.title": {
+ "message": "Немає доступних пірів"
+ },
+ "peers.empty.description": {
+ "message": "У вас немає доступних пірів або доступу до жодного з них."
+ },
+ "peers.details.domain": {
+ "message": "Домен"
+ },
+ "peers.details.netbirdIp": {
+ "message": "NetBird IP"
+ },
+ "peers.details.netbirdIpv6": {
+ "message": "NetBird IPv6"
+ },
+ "peers.details.publicKey": {
+ "message": "Публічний ключ"
+ },
+ "peers.details.connection": {
+ "message": "Підключення"
+ },
+ "peers.details.latency": {
+ "message": "Затримка"
+ },
+ "peers.details.lastHandshake": {
+ "message": "Останнє рукостискання"
+ },
+ "peers.details.statusSince": {
+ "message": "Останнє оновлення підключення"
+ },
+ "peers.details.bytes": {
+ "message": "Байти"
+ },
+ "peers.details.bytesSent": {
+ "message": "Надіслано"
+ },
+ "peers.details.bytesReceived": {
+ "message": "Отримано"
+ },
+ "peers.details.localIce": {
+ "message": "Локальний ICE"
+ },
+ "peers.details.remoteIce": {
+ "message": "Віддалений ICE"
+ },
+ "peers.details.never": {
+ "message": "Ніколи"
+ },
+ "peers.details.justNow": {
+ "message": "Щойно"
+ },
+ "peers.details.refresh": {
+ "message": "Оновити"
+ },
+ "peers.status.connected": {
+ "message": "Підключено"
+ },
+ "peers.status.connecting": {
+ "message": "Підключення"
+ },
+ "peers.status.disconnected": {
+ "message": "Відключено"
+ },
+ "peers.details.relayAddress": {
+ "message": "Ретранслятор"
+ },
+ "peers.details.networks": {
+ "message": "Ресурси"
+ },
+ "peers.details.relayed": {
+ "message": "Через ретранслятор"
+ },
+ "peers.details.p2p": {
+ "message": "P2P"
+ },
+ "peers.details.rosenpass": {
+ "message": "Rosenpass увімкнено"
+ },
+ "networks.search.placeholder": {
+ "message": "Пошук за мережею або доменом"
+ },
+ "networks.filter.all": {
+ "message": "Усі"
+ },
+ "networks.filter.active": {
+ "message": "Активні"
+ },
+ "networks.filter.overlapping": {
+ "message": "Перетинаються"
+ },
+ "networks.empty.title": {
+ "message": "Немає доступних ресурсів"
+ },
+ "networks.empty.description": {
+ "message": "У вас немає доступних мережевих ресурсів або доступу до жодного з них."
+ },
+ "networks.selected": {
+ "message": "Вибрано"
+ },
+ "networks.unselected": {
+ "message": "Не вибрано"
+ },
+ "networks.ips.heading": {
+ "message": "Визначені IP-адреси"
+ },
+ "networks.bulk.selectionCount": {
+ "message": "Активні: {selected} з {total}"
+ },
+ "networks.bulk.enableAll": {
+ "message": "Увімкнути всі"
+ },
+ "networks.bulk.disableAll": {
+ "message": "Вимкнути всі"
+ },
+ "exitNodes.search.placeholder": {
+ "message": "Пошук вихідних вузлів"
+ },
+ "exitNodes.none": {
+ "message": "Немає"
+ },
+ "exitNodes.empty.title": {
+ "message": "Немає доступних вихідних вузлів"
+ },
+ "exitNodes.empty.description": {
+ "message": "Цьому піру не надано жодного вихідного вузла."
+ },
+ "exitNodes.card.title": {
+ "message": "Вихідний вузол"
+ },
+ "exitNodes.card.statusActive": {
+ "message": "Активний"
+ },
+ "exitNodes.card.statusInactive": {
+ "message": "Неактивний"
+ },
+ "exitNodes.dropdown.noneTitle": {
+ "message": "Немає"
+ },
+ "exitNodes.dropdown.noneDescription": {
+ "message": "Пряме підключення без вихідного вузла"
+ },
+ "quickActions.connect": {
+ "message": "Підключитися"
+ },
+ "quickActions.disconnect": {
+ "message": "Відключитися"
+ },
+ "daemon.unavailable.title": {
+ "message": "Служба NetBird не запущена"
+ },
+ "daemon.unavailable.description": {
+ "message": "Програма перепідключиться автоматично, щойно служба запрацює."
+ },
+ "daemon.unavailable.docsLink": {
+ "message": "Документація"
+ },
+ "daemon.outdated.title": {
+ "message": "Клієнт NetBird застарів"
+ },
+ "daemon.outdated.description": {
+ "message": "Новий графічний інтерфейс несумісний зі старою версією клієнта NetBird. Оновіть клієнт, щоб використовувати нову програму."
+ },
+ "daemon.outdated.download": {
+ "message": "Завантажити останню версію"
+ },
+ "error.jwt_clock_skew": {
+ "message": "Помилка входу: годинник цього пристрою не синхронізовано із сервером. Будь ласка, синхронізуйте системний годинник і спробуйте знову."
+ },
+ "error.jwt_expired": {
+ "message": "Термін дії вашого токена входу закінчився. Будь ласка, увійдіть знову."
+ },
+ "error.jwt_signature_invalid": {
+ "message": "Помилка входу: недійсний підпис токена. Будь ласка, зверніться до адміністратора."
+ },
+ "error.session_expired": {
+ "message": "Термін дії вашого сеансу закінчився. Будь ласка, увійдіть знову."
+ },
+ "error.invalid_setup_key": {
+ "message": "Setup Key відсутній або недійсний."
+ },
+ "error.permission_denied": {
+ "message": "Вхід відхилено сервером."
+ },
+ "error.daemon_unreachable": {
+ "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба."
+ },
+ "error.unknown": {
+ "message": "Помилка операції."
+ },
+ "error.elevation_unavailable": {
+ "message": "NetBird не зміг запросити в системи привілеї, необхідні для внесення змін. Замість цього виконайте:"
+ },
+ "error.elevation_failed": {
+ "message": "Не вдалося застосувати зміни з підвищеними привілеями. Замість цього виконайте:"
+ },
+ "settings.ssh.privilege.actorRoot": {
+ "message": "прав root"
+ },
+ "settings.ssh.privilege.actorAdministrator": {
+ "message": "прав адміністратора"
+ },
+ "settings.ssh.privilege.hint": {
+ "message": "Потребує {actor}. Замість цього виконайте:"
+ },
+ "settings.ssh.privilege.oneWay": {
+ "message": "Ви можете вимкнути це, але щоб увімкнути знову, знадобиться {actor}:"
+ },
+ "settings.ssh.privilege.oneWayInverted": {
+ "message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
+ },
+ "settings.ssh.privilege.authorizePending": {
+ "message": "Очікування авторизації…"
+ }
+}
diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go
index 4930ce22b..94dba6038 100644
--- a/client/ui/services/windowmanager.go
+++ b/client/ui/services/windowmanager.go
@@ -292,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() {
}
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
-// the countdown. Singleton, destroyed on close.
-func (s *WindowManager) OpenSessionExpiration(seconds int) {
+// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog
+// compares renewal snapshots against. Singleton, destroyed on close.
+func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
+ if deadlineUnixMilli > 0 {
+ startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
+ }
if s.sessionExpiration == nil {
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
opts.Screen = s.getScreenBasedOnCursorPosition()
diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go
index e948b3273..1bd0cdcbd 100644
--- a/client/ui/tray_events.go
+++ b/client/ui/tray_events.go
@@ -81,7 +81,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
- t.openSessionExpiration()
+ deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt])
+ t.openSessionExpiration(deadline)
return
}
t.notifySessionWarning(
diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go
index e21d56044..407b72baf 100644
--- a/client/ui/tray_session.go
+++ b/client/ui/tray_session.go
@@ -278,12 +278,23 @@ func (t *Tray) dismissSessionWarning() {
}
// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
-// Idempotent on the WindowManager side.
-func (t *Tray) openSessionExpiration() {
+// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon,
+// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the
+// WindowManager side.
+func (t *Tray) openSessionExpiration(deadline time.Time) {
if t.svc.WindowManager == nil {
return
}
- t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
+ if deadline.IsZero() {
+ t.sessionMu.Lock()
+ deadline = t.sessionExpiresAt
+ t.sessionMu.Unlock()
+ }
+ var deadlineMs int64
+ if !deadline.IsZero() {
+ deadlineMs = deadline.UnixMilli()
+ }
+ t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs)
}
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
@@ -304,5 +315,5 @@ func (t *Tray) openSessionExtendFlow() {
if t.svc.WindowManager == nil {
return
}
- t.svc.WindowManager.OpenSessionExpiration(seconds)
+ t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli())
}
diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage
index 79746819d..011379c2f 100644
--- a/combined/Dockerfile.multistage
+++ b/combined/Dockerfile.multistage
@@ -1,4 +1,4 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26.7-bookworm AS builder
WORKDIR /app
# Install build dependencies
diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist
index fe10b5b63..eec96d35b 100644
--- a/docs/io.netbird.client.plist
+++ b/docs/io.netbird.client.plist
@@ -85,6 +85,21 @@
-->
+
+
+
+
+
+