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 c93e36e4e..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; \ 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..60e1ceeb8 --- /dev/null +++ b/client/android/split_tunnel.go @@ -0,0 +1,125 @@ +package android + +// SplitTunnelMode is which of the two selections, if either, the tunnel applies. +// Its values land in the profile's stored preferences, so the constants below +// are append-only and must never be reordered. +type SplitTunnelMode int + +const ( + modeOff SplitTunnelMode = iota + modeExclude + modeInclude +) + +// The same modes as basic ints. gomobile drops a constant whose type is not a +// basic one, so these are what reaches the generated Java bindings, and they +// keep the Android side tied to the values above instead of repeating 0, 1, 2. +const ( + SplitTunnelModeOff = int(modeOff) + SplitTunnelModeExclude = int(modeExclude) + SplitTunnelModeInclude = int(modeInclude) +) + +type splitTunnelSection struct { + Mode SplitTunnelMode `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. +// +// Mode is an int rather than a SplitTunnelMode because gomobile carries only +// basic types across the binding. It holds one of the SplitTunnelMode* +// constants. +type SplitTunnelSettings struct { + Mode int + 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 +} + +// normalizeSplitTunnelMode maps anything outside the known set to off, so a mode +// written by a newer build degrades to carrying every application rather than to +// some other mode's behaviour. +func normalizeSplitTunnelMode(mode SplitTunnelMode) SplitTunnelMode { + switch mode { + case modeExclude, modeInclude: + return mode + default: + return modeOff + } +} + +func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings { + out := NewSplitTunnelSettings() + out.Mode = int(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(SplitTunnelMode(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..bf6e0267e --- /dev/null +++ b/client/android/split_tunnel_test.go @@ -0,0 +1,151 @@ +package android + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestNormalizeSplitTunnelMode(t *testing.T) { + tests := []struct { + name string + mode SplitTunnelMode + want SplitTunnelMode + }{ + {name: "exclude is kept", mode: modeExclude, want: modeExclude}, + {name: "include is kept", mode: modeInclude, want: modeInclude}, + {name: "off is kept", mode: modeOff, want: modeOff}, + {name: "a mode from a newer build falls back to off", mode: SplitTunnelMode(7), want: modeOff}, + {name: "a negative mode falls back to off", mode: SplitTunnelMode(-1), want: modeOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSplitTunnelMode(tt.mode); got != tt.want { + t.Errorf("normalizeSplitTunnelMode(%d) = %d, want %d", tt.mode, got, tt.want) + } + }) + } +} + +// The constants the Android side reads must stay the values the store writes: +// gomobile carries the ints below, not the typed constants they mirror. +func TestSplitTunnelModeConstantsMirrorTheTypedOnes(t *testing.T) { + if SplitTunnelModeOff != int(modeOff) { + t.Errorf("off = %d, want %d", SplitTunnelModeOff, modeOff) + } + if SplitTunnelModeExclude != int(modeExclude) { + t.Errorf("exclude = %d, want %d", SplitTunnelModeExclude, modeExclude) + } + if SplitTunnelModeInclude != int(modeInclude) { + t.Errorf("include = %d, want %d", SplitTunnelModeInclude, modeInclude) + } +} + +func TestSettingsFromSection(t *testing.T) { + got := settingsFromSection(splitTunnelSection{ + Mode: modeExclude, + Excluded: []string{"com.example.a", "com.example.b"}, + Included: []string{"com.example.c"}, + }) + + if got.Mode != SplitTunnelModeExclude { + t.Errorf("mode = %d, want %d", 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 = %d, want %d", 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)) + } +} + +// The section is what the profile's preference file holds, so the mode has to +// survive a JSON round trip as the number the constants name. +func TestSectionEncodesTheModeAsItsNumber(t *testing.T) { + raw, err := json.Marshal(sectionFromSettings(&SplitTunnelSettings{Mode: SplitTunnelModeInclude})) + if err != nil { + t.Fatalf("marshal section: %v", err) + } + + var back splitTunnelSection + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal section: %v", err) + } + if back.Mode != modeInclude { + t.Errorf("mode = %d, want %d, from %s", back.Mode, modeInclude, raw) + } +} + +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 = %d, want %d", 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)) + } +} + +// A mode the Java side never sets, such as one left by a newer build, must not +// reach the stored section either. +func TestSectionFromSettingsNormalizesAnUnknownMode(t *testing.T) { + section := sectionFromSettings(&SplitTunnelSettings{Mode: 7}) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } +} + +func TestSectionFromNilSettings(t *testing.T) { + section := sectionFromSettings(nil) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } + 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..4e08334eb 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 @@ -92,7 +91,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("daemon login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") 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 @@ -345,7 +343,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("foreground login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") return nil } 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/service_controller.go b/client/cmd/service_controller.go index b187a7b87..e9a0e055f 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -41,13 +41,15 @@ func daemonServerOptions(network string) []grpc.ServerOption { if network == "tcp" { log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+ - "deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr) + "deregistration) will be denied, and the SSH JWT cache is neither filled nor served. "+ + "Use a unix socket, or npipe:// on Windows", daemonAddr) return nil } creds := ipcauth.NewTransportCredentials() //nolint:staticcheck if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive - log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS) + log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied "+ + "and the SSH JWT cache is neither filled nor served", runtime.GOOS) return nil } diff --git a/client/cmd/up.go b/client/cmd/up.go index 125676410..8f6eb5532 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,6 +428,17 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return nil } +// 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. @@ -457,6 +498,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.RosenpassPermissive = &rosenpassPermissive } setSSHSetConfigFields(&req, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed) setVNCSetConfigFields(&req, cmd) if cmd.Flag(interfaceNameFlag).Changed { @@ -553,6 +595,8 @@ 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(serverVNCAllowedFlag).Changed { ic.ServerVNCAllowed = &serverVNCAllowed } @@ -727,6 +771,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte } setSSHLoginFields(&loginRequest, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed) setVNCLoginFields(&loginRequest, cmd) if cmd.Flag(disableAutoConnectFlag).Changed { @@ -912,3 +957,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/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index 2d35b9c6f..cd1ed12f1 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -57,10 +57,13 @@ type ICEBind struct { endpoints map[netip.Addr]net.Conn endpointsMu sync.Mutex recvChan chan recvMessage - // every time when Close() is called (i.e. BindUpdate()) we need to close exit from the receiveRelayed and create a - // new closed channel. With the closedChanMu we can safely close the channel and create a new one + // Close() (i.e. BindUpdate()) closes closedChan to release receiveRelayed, + // and the following Open() installs a fresh one. closedChanMu guards both + // closedChan and closed: readers only ever hold it long enough to copy the + // channel, never across a blocking receive, so Open cannot be starved by a + // parked receiver. closedChan chan struct{} - closedChanMu sync.RWMutex // protect the closeChan recreation from reading from it. + closedChanMu sync.RWMutex closed bool activityRecorder *ActivityRecorder @@ -92,24 +95,41 @@ func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) } func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) { - s.closed = false s.closedChanMu.Lock() - s.closedChan = make(chan struct{}) - s.closedChanMu.Unlock() + defer s.closedChanMu.Unlock() + + // Open the underlying bind before touching any state, so a failure leaves + // the current generation exactly as it was. Publishing the new generation + // first would strand it: StdNetBind rejects an Open while it is already + // open, and a Close arriving in that window would mark the bind closed + // while this call went on to install live sockets, after which every later + // Close returns early and never shuts them down. fns, port, err := s.StdNetBind.Open(uport) if err != nil { return nil, 0, err } + + // Release whoever is parked on the outgoing generation before replacing it. + // An Open that follows an Open rather than a Close would otherwise leave + // them waiting on a channel no later Close can reach. + if !s.closed { + close(s.closedChan) + } + s.closed = false + s.closedChan = make(chan struct{}) + fns = append(fns, s.receiveRelayed) return fns, port, nil } func (s *ICEBind) Close() error { + s.closedChanMu.Lock() + defer s.closedChanMu.Unlock() + if s.closed { return nil } s.closed = true - close(s.closedChan) s.muUDPMux.Lock() @@ -121,6 +141,15 @@ func (s *ICEBind) Close() error { return s.StdNetBind.Close() } +// currentClosedChan copies the channel that signals the current Open +// generation is closing. Callers select on the copy so the lock is never held +// across a blocking receive, which would otherwise stall the next Open. +func (s *ICEBind) currentClosedChan() chan struct{} { + s.closedChanMu.RLock() + defer s.closedChanMu.RUnlock() + return s.closedChan +} + func (s *ICEBind) ActivityRecorder() *ActivityRecorder { return s.activityRecorder } @@ -150,8 +179,10 @@ func (b *ICEBind) RemoveEndpoint(fakeIP netip.Addr) { } func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []byte) { + closedChan := b.currentClosedChan() + select { - case <-b.closedChan: + case <-closedChan: return case <-ctx.Done(): return @@ -333,11 +364,10 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) { // receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the // WireGuard. Critical part is do not block if the Closed() has been called. func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) { - c.closedChanMu.RLock() - defer c.closedChanMu.RUnlock() + closedChan := c.currentClosedChan() select { - case <-c.closedChan: + case <-closedChan: return 0, net.ErrClosed case msg, ok := <-c.recvChan: if !ok { diff --git a/client/iface/bind/ice_bind_close_test.go b/client/iface/bind/ice_bind_close_test.go new file mode 100644 index 000000000..829d27663 --- /dev/null +++ b/client/iface/bind/ice_bind_close_test.go @@ -0,0 +1,261 @@ +package bind + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + wgConn "golang.zx2c4.com/wireguard/conn" +) + +// startReceivers runs every receive function the way wireguard-go's device +// does: one goroutine per function, all tracked by a single WaitGroup. After +// calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while +// holding device.net, so a receive function that never returns wedges the +// device and every goroutine that needs the same lock. +func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup { + wg, _ := startReceiversEntered(fns) + return wg +} + +// startReceiversEntered also returns a channel closed once every receive +// function has been called at least once. A receive function that has been +// entered is either inside its blocking receive or about to be, which is a +// stronger signal to synchronise on than a bare sleep. +func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) { + var wg sync.WaitGroup + var entered sync.WaitGroup + wg.Add(len(fns)) + entered.Add(len(fns)) + + for i := range fns { + go func(fn wgConn.ReceiveFunc) { + defer wg.Done() + buffs := [][]byte{make([]byte, 1500)} + sizes := make([]int, 1) + eps := make([]wgConn.Endpoint, 1) + first := true + for { + if first { + entered.Done() + first = false + } + if _, err := fn(buffs, sizes, eps); err != nil { + return + } + } + }(fns[i]) + } + + allEntered := make(chan struct{}) + go func() { + entered.Wait() + close(allEntered) + }() + return &wg, allEntered +} + +// closeBounded runs Close off the caller's goroutine so a regression that +// wedges it fails the test instead of hanging teardown, and reports whether it +// returned in time. +func closeBounded(iceBind *ICEBind, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + _ = iceBind.Close() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// isClosed reports whether the bind's current generation channel is closed. +func isClosed(iceBind *ICEBind) bool { + iceBind.closedChanMu.RLock() + ch := iceBind.closedChan + iceBind.closedChanMu.RUnlock() + select { + case <-ch: + return true + default: + return false + } +} + +// receiversStopped reports whether every receive function returned before the +// timeout, mirroring device.net.stopping.Wait() inside closeBindLocked. +func receiversStopped(wg *sync.WaitGroup, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// TestICEBindCloseReleasesReceivers covers the contract closeBindLocked relies +// on: once Close returns, every receive function handed out by Open must stop. +func TestICEBindCloseReleasesReceivers(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "opening the bind must succeed") + + wg := startReceivers(fns) + require.NoError(t, iceBind.Close()) + + require.True(t, receiversStopped(wg, 5*time.Second), + "every receive function must return once Close returns") +} + +// TestICEBindOpenDoesNotBlockOnParkedReceiver reproduces the deadlock that +// wedges interface creation. +// +// receiveRelayed used to hold closedChanMu for the whole of its blocking +// select, so a parked receiver kept the read lock indefinitely. Open takes the +// same mutex for writing to install a fresh closedChan, so it could never +// acquire it while a receiver was parked. wireguard-go reaches Open from +// Device.IpcSet and Device.Up with device.net held, so the stall takes the +// device's lock with it and every other device goroutine queues behind it. +// +// Failing here means Open never returned. +func TestICEBindOpenDoesNotBlockOnParkedReceiver(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "the first Open must succeed") + + wg, entered := startReceiversEntered(fns) + t.Cleanup(func() { + // Both bounded: a regression that wedges Close must surface as the + // assertion below, not as a hung teardown. + if !closeBounded(iceBind, 5*time.Second) { + t.Error("Close did not return during teardown; the bind lifecycle is wedged even though the assertion above passed") + } + if !receiversStopped(wg, 5*time.Second) { + t.Error("receive functions were still running after teardown Close, which is what closeBindLocked blocks on") + } + }) + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("receive functions never started") + } + // Entered is not yet parked, so still allow the blocking receive to be + // reached. Parking takes microseconds; this margin is six orders larger. + time.Sleep(500 * time.Millisecond) + + reopened := make(chan struct{}) + go func() { + // The error is irrelevant; StdNetBind rejects a second Open. What + // matters is that the call returns at all. + _, _, _ = iceBind.Open(0) + close(reopened) + }() + + select { + case <-reopened: + case <-time.After(10 * time.Second): + t.Fatal("Open blocked while a receive function was parked; wireguard-go makes this call with device.net held, which is what stalls interface creation") + } +} + +// TestICEBindConcurrentOpenClose exercises Open and Close from separate +// goroutines, the way Device.IpcSet and Device.Up reach the bind, and is meant +// to be run under -race. +// +// closed and closedChan must be updated together. When they were not, Close +// could observe a stale closed and either skip close(closedChan) and +// StdNetBind.Close entirely, leaving the receive functions running, or race a +// second Close and close the same channel twice. +// +// Receive functions are deliberately not started here: this test is about the +// shared state, and parking them would turn a race report into a hang. +func TestICEBindConcurrentOpenClose(t *testing.T) { + iceBind := setupICEBind(t) + + var wg sync.WaitGroup + wg.Add(2) + + // Release both loops together so the calls genuinely interleave rather + // than depending on goroutine start order. + start := make(chan struct{}) + + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _, _, _ = iceBind.Open(0) + } + }() + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _ = iceBind.Close() + } + }() + + close(start) + wg.Wait() + require.NoError(t, iceBind.Close()) + require.True(t, isClosed(iceBind), "the final Close must leave the current generation channel closed") +} + +// TestICEBindCloseReleasesReceiversUnderConcurrentClose runs full Open, receive, +// Close cycles with a second Close and an Open racing the first Close. Any +// iteration where the receive functions outlive Close, or where the surviving +// generation channel is left open, is the state closeBindLocked deadlocks on. +func TestICEBindCloseReleasesReceiversUnderConcurrentClose(t *testing.T) { + if testing.Short() { + t.Skip("stress test") + } + + for i := 0; i < 200; i++ { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "iteration %d: opening the bind must succeed", i) + wg := startReceivers(fns) + + start := make(chan struct{}) + var racers sync.WaitGroup + racers.Add(3) + for c := 0; c < 2; c++ { + go func() { + defer racers.Done() + <-start + _ = iceBind.Close() + }() + } + // An Open overlapping the Closes is what produces a generation whose + // channel outlives the flag saying the bind is closed. + go func() { + defer racers.Done() + <-start + _, _, _ = iceBind.Open(0) + }() + close(start) + racers.Wait() + + // Settle on a closed bind whatever order the racers landed in. + _ = iceBind.Close() + + if !receiversStopped(wg, 5*time.Second) { + t.Fatalf("iteration %d: receive functions still running after Close; closeBindLocked would block here on device.net.stopping.Wait", i) + } + if !isClosed(iceBind) { + t.Fatalf("iteration %d: Close returned with the current generation channel still open, so nothing will ever release its receivers", i) + } + } +} diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index a7483b389..240f7efe7 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -369,6 +369,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 e5dff06cc..fa630208a 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -242,7 +242,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) } @@ -652,6 +652,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), ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed, DisableVNCApproval: config.DisableVNCApproval, EnableSSHRoot: config.EnableSSHRoot, @@ -752,6 +753,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 7c5cbec33..e4df36740 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)) } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index b6b98d19e..52e23a2bf 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, ServerVNCAllowed: &bTrue, DisableVNCApproval: &bTrue, EnableSSHRoot: &bTrue, @@ -888,6 +890,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), @@ -905,6 +908,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/engine.go b/client/internal/engine.go index d7dfcced6..4180f6eab 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -138,6 +138,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool ServerVNCAllowed bool DisableVNCApproval *bool EnableSSHRoot *bool @@ -1270,6 +1271,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) { e.config.EnableSSHLocalPortForwarding, e.config.EnableSSHRemotePortForwarding, e.config.DisableSSHAuth, + &e.config.RemoteJobsAllowed, ) } @@ -1359,6 +1361,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) @@ -1388,7 +1397,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) @@ -1416,7 +1443,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 } @@ -1429,6 +1456,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/ipcauth/identity.go b/client/internal/ipcauth/identity.go index ff70c209a..d7d10f57d 100644 --- a/client/internal/ipcauth/identity.go +++ b/client/internal/ipcauth/identity.go @@ -91,6 +91,19 @@ func (i Identity) IsPrivileged() bool { return slices.Contains(i.Groups, sidAdministrators) } +// SameUser reports whether two identities are the same local principal. Only +// the account is compared: the group set and the elevation flag describe what a +// token may do, not who it belongs to. A SID on either side decides the +// comparison, so a Windows principal never matches a Unix one on the UID both +// happen to leave at zero. The zero Identity carries uid 0, so callers must +// establish that both identities are real before the answer means anything. +func (i Identity) SameUser(other Identity) bool { + if i.SID != "" || other.SID != "" { + return i.SID == other.SID + } + return i.UID == other.UID +} + // String renders the identity for audit logs and denial messages. func (i Identity) String() string { if i.IsWindows() { diff --git a/client/internal/ipcauth/identity_sameuser_test.go b/client/internal/ipcauth/identity_sameuser_test.go new file mode 100644 index 000000000..c98f583db --- /dev/null +++ b/client/internal/ipcauth/identity_sameuser_test.go @@ -0,0 +1,66 @@ +package ipcauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIdentitySameUser(t *testing.T) { + tests := []struct { + name string + a Identity + b Identity + want bool + }{ + { + name: "same uid", + a: Identity{UID: 1000, GID: 1000}, + b: Identity{UID: 1000, GID: 1000}, + want: true, + }, + { + name: "same uid, different gid and pid still the same user", + a: Identity{UID: 1000, GID: 1000, PID: 11}, + b: Identity{UID: 1000, GID: 27, PID: 22}, + want: true, + }, + { + name: "different uid", + a: Identity{UID: 1000}, + b: Identity{UID: 1001}, + want: false, + }, + { + name: "same sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "same sid, elevation and groups differ", + a: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true, Groups: []string{sidAdministrators}}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "different sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + name: "a windows principal is never a unix one", + a: Identity{SID: "S-1-5-18"}, + b: Identity{UID: 0}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.a.SameUser(tt.b)) + assert.Equal(t, tt.want, tt.b.SameUser(tt.a), "SameUser must be symmetric") + }) + } +} diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 0a69404df..7c23e42bd 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -32,13 +32,24 @@ Clients do not talk to InfluxDB directly. An ingest server sits between clients ```text Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) │ + ├─ Checks the X-Peer-ID header format ├─ Validates line protocol ├─ Allowlists measurements, fields, and tags ├─ Rejects out-of-bound values └─ Serves remote config at /config ``` -- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header. +- **Intentionally unauthenticated** — the endpoint receives obfuscated telemetry from + the peers of both cloud and self-hosted deployments. For a self-hosted peer there is + no shared trust anchor with this server, so there is nothing to authenticate against. +- **`X-Peer-ID` is a correlation tag, not a credential** — it carries the obfuscated + peer identifier so samples from one peer can be grouped. The server only checks that + the header is well-formed (16 hex chars); a malformed value is rejected with + `400 Bad Request`, not `401`. Any well-formed value is accepted by design, and the + header must not be relied on for access control. The header itself is not forwarded + to InfluxDB — the stored `peer_id` tag comes from the request body and is constrained + only by the tag allowlist and the maximum tag value length. +- **The InfluxDB token stays server-side** — clients never hold a write credential. - **InfluxDB is not exposed** — only accessible within the docker network - Source: `ingest/main.go` @@ -61,7 +72,7 @@ Tags: - `version`: NetBird version string - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) -- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key) +- `peer_id`: obfuscated peer identifier (truncated SHA-256 of the WireGuard public key) - `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. @@ -195,7 +206,7 @@ docker compose up -d ``` This starts: -- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **Ingest server** on http://localhost:8087 — accepts client metrics (unauthenticated by design; expects a well-formed `X-Peer-ID` correlation tag) - **InfluxDB** — internal only, not exposed to host - **Grafana** on http://localhost:3001 diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index 100c70011..e22d111f7 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -22,6 +22,11 @@ const ( maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this + readTimeout = 30 * time.Second // must fit reading a compressed body up to maxBodySize + writeTimeout = 60 * time.Second // must exceed the upstream client timeout below + idleTimeout = 120 * time.Second + readHeaderTimeout = 10 * time.Second + maxHeaderBytes = 1 << 20 // 1 MB ) type measurementSpec struct { @@ -144,8 +149,17 @@ func main() { fmt.Fprint(w, "ok") //nolint:errcheck }) + srv := &http.Server{ + Addr: listenAddr, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + } + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) - if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + if err := srv.ListenAndServe(); err != nil { log.Fatal(err) } } @@ -157,8 +171,8 @@ func handleIngest(client *http.Client, influxURL, influxToken string) http.Handl return } - if err := validateAuth(r); err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + if err := validatePeerIDFormat(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -207,8 +221,13 @@ func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client io.Copy(w, resp.Body) //nolint:errcheck } -// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID. -func validateAuth(r *http.Request) error { +// validatePeerIDFormat checks the shape of the X-Peer-ID header. The header is a +// correlation tag, not a credential: this endpoint is intentionally +// unauthenticated so that peers of self-hosted deployments, for which no shared +// trust anchor exists, can report obfuscated telemetry. The header is not forwarded +// to InfluxDB, so this check does not bound the stored peer_id tag; it only rejects +// a malformed header as a bad request rather than an auth failure. +func validatePeerIDFormat(r *http.Request) error { peerID := r.Header.Get("X-Peer-ID") if peerID == "" { return fmt.Errorf("missing X-Peer-ID header") diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index 96287813e..526f127cb 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -94,7 +94,7 @@ func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { require.Error(t, err) } -func TestValidateAuth(t *testing.T) { +func TestValidatePeerIDFormat(t *testing.T) { tests := []struct { name string peerID string @@ -113,7 +113,7 @@ func TestValidateAuth(t *testing.T) { if tt.peerID != "" { r.Header.Set("X-Peer-ID", tt.peerID) } - err := validateAuth(r) + err := validatePeerIDFormat(r) if tt.wantErr { require.Error(t, err) } else { 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 0dcdff06a..9eca0b51f 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 ServerVNCAllowed *bool DisableVNCApproval *bool EnableSSHRoot *bool @@ -129,6 +130,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + RemoteJobsAllowed *bool ServerVNCAllowed *bool DisableVNCApproval *bool EnableSSHRoot *bool @@ -196,6 +198,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 @@ -229,6 +237,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 } @@ -236,6 +250,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 @@ -277,7 +301,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 { @@ -507,6 +534,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } } + 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") @@ -716,6 +758,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 } @@ -763,6 +813,7 @@ 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.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv }) applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &bv }) applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) @@ -798,6 +849,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_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/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 fceb6a7fb..78ad47f23 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -34,6 +34,8 @@ var allKeys = []string{ KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, + KeyRemoteJobsAllowed, + KeyBundleUploadURL, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 0e116bfd6..27c04443f 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -64,6 +64,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). @@ -75,6 +86,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 bb983ec1d..ad05801f8 100644 --- a/client/mobile/profile_state.go +++ b/client/mobile/profile_state.go @@ -78,8 +78,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 57900b016..57c4fb640 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -348,10 +348,13 @@ type LoginRequest struct { DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` 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"` - ServerVNCAllowed *bool `protobuf:"varint,43,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval *bool `protobuf:"varint,44,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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"` + ServerVNCAllowed *bool `protobuf:"varint,44,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,45,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -679,6 +682,13 @@ func (x *LoginRequest) GetLocalMetricsAddress() string { return "" } +func (x *LoginRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + func (x *LoginRequest) GetServerVNCAllowed() bool { if x != nil && x.ServerVNCAllowed != nil { return *x.ServerVNCAllowed @@ -1250,8 +1260,9 @@ 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"` - ServerVNCAllowed bool `protobuf:"varint,29,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval bool `protobuf:"varint,30,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"` + RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"` + ServerVNCAllowed bool `protobuf:"varint,30,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval bool `protobuf:"varint,31,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,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 @@ -1481,6 +1492,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetRemoteJobsAllowed() bool { + if x != nil { + return x.RemoteJobsAllowed + } + return false +} + func (x *GetConfigResponse) GetServerVNCAllowed() bool { if x != nil { return x.ServerVNCAllowed @@ -4428,10 +4446,13 @@ type SetConfigRequest struct { DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` 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"` - ServerVNCAllowed *bool `protobuf:"varint,38,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` - DisableVNCApproval *bool `protobuf:"varint,39,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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"` + ServerVNCAllowed *bool `protobuf:"varint,39,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` + DisableVNCApproval *bool `protobuf:"varint,40,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { @@ -4723,6 +4744,13 @@ func (x *SetConfigRequest) GetLocalMetricsAddress() string { return "" } +func (x *SetConfigRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + func (x *SetConfigRequest) GetServerVNCAllowed() bool { if x != nil && x.ServerVNCAllowed != nil { return *x.ServerVNCAllowed @@ -7375,7 +7403,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\"\xa4\x15\n" + + "\fEmptyRequest\"\xed\x15\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7422,9 +7450,10 @@ const file_daemon_proto_rawDesc = "" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\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\x12/\n" + - "\x10serverVNCAllowed\x18+ \x01(\bH\x1eR\x10serverVNCAllowed\x88\x01\x01\x123\n" + - "\x12disableVNCApproval\x18, \x01(\bH\x1fR\x12disableVNCApproval\x88\x01\x01B\x13\n" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01\x12/\n" + + "\x10serverVNCAllowed\x18, \x01(\bH\x1fR\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18- \x01(\bH R\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7454,7 +7483,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x17\n" + "\x15_enable_local_metricsB\x18\n" + - "\x16_local_metrics_addressB\x13\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowedB\x13\n" + "\x11_serverVNCAllowedB\x15\n" + "\x13_disableVNCApproval\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + @@ -7491,7 +7521,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\"\x86\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xb4\n" + "\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + @@ -7524,9 +7554,10 @@ 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" + - "\x10serverVNCAllowed\x18\x1d \x01(\bR\x10serverVNCAllowed\x12.\n" + - "\x12disableVNCApproval\x18\x1e \x01(\bR\x12disableVNCApproval\x12*\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" + + "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" + + "\x10serverVNCAllowed\x18\x1e \x01(\bR\x10serverVNCAllowed\x12.\n" + + "\x12disableVNCApproval\x18\x1f \x01(\bR\x12disableVNCApproval\x12*\n" + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + @@ -7765,7 +7796,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\xcd\x13\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x96\x14\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7807,9 +7838,10 @@ const file_daemon_proto_rawDesc = "" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\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\x12/\n" + - "\x10serverVNCAllowed\x18& \x01(\bH\x1bR\x10serverVNCAllowed\x88\x01\x01\x123\n" + - "\x12disableVNCApproval\x18' \x01(\bH\x1cR\x12disableVNCApproval\x88\x01\x01B\x13\n" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01\x12/\n" + + "\x10serverVNCAllowed\x18' \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01\x123\n" + + "\x12disableVNCApproval\x18( \x01(\bH\x1dR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7836,7 +7868,8 @@ const file_daemon_proto_rawDesc = "" + "\x0f_sshJWTCacheTTLB\x0f\n" + "\r_disable_ipv6B\x17\n" + "\x15_enable_local_metricsB\x18\n" + - "\x16_local_metrics_addressB\x13\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowedB\x13\n" + "\x11_serverVNCAllowedB\x15\n" + "\x13_disableVNCApproval\"\x13\n" + "\x11SetConfigResponse\"Q\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index e477893b9..8c7123cda 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -253,10 +253,13 @@ message LoginRequest { 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; - optional bool serverVNCAllowed = 43; + optional bool serverVNCAllowed = 44; - optional bool disableVNCApproval = 44; + optional bool disableVNCApproval = 45; } message LoginResponse { @@ -377,9 +380,11 @@ message GetConfigResponse { bool disable_ipv6 = 27; - bool serverVNCAllowed = 29; + bool remoteJobsAllowed = 29; - bool disableVNCApproval = 30; + bool serverVNCAllowed = 30; + + bool disableVNCApproval = 31; // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants @@ -809,10 +814,13 @@ message SetConfigRequest { 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; - optional bool serverVNCAllowed = 38; + optional bool serverVNCAllowed = 39; - optional bool disableVNCApproval = 39; + optional bool disableVNCApproval = 40; } message SetConfigResponse{} diff --git a/client/server/jwt_cache.go b/client/server/jwt_cache.go index 21e170517..73cec046d 100644 --- a/client/server/jwt_cache.go +++ b/client/server/jwt_cache.go @@ -6,11 +6,21 @@ import ( "github.com/awnumar/memguard" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" ) type jwtCache struct { - mu sync.RWMutex - enclave *memguard.Enclave + mu sync.RWMutex + enclave *memguard.Enclave + owner *ipcauth.Identity + + // generation counts the invalidations. A caller that starts an + // authentication takes the generation first and hands it back to store, so + // a token obtained under a session that ended while the IdP was being + // polled cannot land in the cache the new session is using. + generation uint64 + expiresAt time.Time timer *time.Timer maxTokenSize int @@ -22,10 +32,23 @@ func newJWTCache() *jwtCache { } } -func (c *jwtCache) store(token string, maxAge time.Duration) { +func (c *jwtCache) currentGeneration() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.generation +} + +// store keeps the token only while generation is still the current one, and +// reports whether it did. See the generation field. +func (c *jwtCache) store(token string, owner ipcauth.Identity, maxAge time.Duration, generation uint64) bool { c.mu.Lock() defer c.mu.Unlock() + if c.generation != generation { + return false + } + c.cleanup() if c.timer != nil { @@ -35,6 +58,7 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { tokenBytes := []byte(token) c.enclave = memguard.NewEnclave(tokenBytes) + c.owner = &owner c.expiresAt = time.Now().Add(maxAge) var timer *time.Timer @@ -49,9 +73,12 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) }) c.timer = timer + + return true } -func (c *jwtCache) get() (string, bool) { +// get returns the cached token to the identity that stored it. +func (c *jwtCache) get(caller ipcauth.Identity) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() @@ -59,6 +86,11 @@ func (c *jwtCache) get() (string, bool) { return "", false } + if c.owner == nil || !c.owner.SameUser(caller) { + log.Warnf("refusing the cached SSH JWT: caller %s is not the identity that obtained it", caller) + return "", false + } + buffer, err := c.enclave.Open() if err != nil { log.Debugf("Failed to open JWT token enclave: %v", err) @@ -70,10 +102,23 @@ func (c *jwtCache) get() (string, bool) { return token, true } +func (c *jwtCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } + c.cleanup() + c.generation++ +} + // cleanup destroys the secure enclave, must be called with lock held func (c *jwtCache) cleanup() { if c.enclave != nil { c.enclave = nil } + c.owner = nil c.expiresAt = time.Time{} } diff --git a/client/server/jwt_cache_test.go b/client/server/jwt_cache_test.go new file mode 100644 index 000000000..11d208ade --- /dev/null +++ b/client/server/jwt_cache_test.go @@ -0,0 +1,176 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +const testTTL = time.Minute + +func unixCaller(uid uint32) ipcauth.Identity { + return ipcauth.Identity{UID: uid, GID: uid} +} + +func windowsCaller(sid string) ipcauth.Identity { + return ipcauth.Identity{SID: sid} +} + +func TestJWTCache_ServesTheOwner(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token-for-1000", owner, testTTL, c.currentGeneration()) + + got, found := c.get(owner) + + require.True(t, found, "the identity that stored the token must get it back") + assert.Equal(t, "token-for-1000", got) +} + +// The disclosure this cache guards against: one local account collecting the +// SSH JWT another account's authentication put in the daemon-wide cache. +func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) { + tests := []struct { + name string + owner ipcauth.Identity + caller ipcauth.Identity + }{ + {"different uid", unixCaller(1000), unixCaller(65534)}, + {"root is not the owner either", unixCaller(1000), unixCaller(0)}, + {"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")}, + {"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")}, + {"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newJWTCache() + c.store("victim-token", tt.owner, testTTL, c.currentGeneration()) + + got, found := c.get(tt.caller) + + assert.False(t, found, "a caller that is not the owner must get a miss") + assert.Empty(t, got) + }) + } +} + +func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) { + c := newJWTCache() + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// An entry with no recorded owner must match nobody, root included: an +// unidentified caller arrives as the zero Identity, which carries uid 0. This +// pins the nil-owner guard rather than the comparison, so it sets up an entry +// that exists and then drops its owner. +func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) { + c := newJWTCache() + c.store("token", unixCaller(1000), testTTL, c.currentGeneration()) + c.owner = nil + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// The same user calling once elevated and once not is still the same user, so +// hiding their own token from them would be wrong. +func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) { + c := newJWTCache() + sid := "S-1-5-21-1-2-3-1001" + owner := windowsCaller(sid) + owner.Elevated = true + c.store("token", owner, testTTL, c.currentGeneration()) + + got, found := c.get(windowsCaller(sid)) + + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_Expiry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + c.expiresAt = time.Now().Add(-time.Second) + + _, found := c.get(owner) + + assert.False(t, found) +} + +// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird +// session the token speaks for is over, so not even its owner may have it back. +func TestJWTCache_ClearDropsTheEntry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + + c.clear() + + _, found := c.get(owner) + assert.False(t, found) + assert.Nil(t, c.owner, "clear must forget the owner too") + assert.Nil(t, c.timer, "clear must stop the expiry timer") +} + +// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can +// clear the cache while a flow is still in the air. The token that flow returns +// belongs to the session that ended, so it must not land in the cache the new +// session is using. +func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + // The generation a caller takes when its authentication starts. + generation := c.currentGeneration() + + c.clear() // logout or profile switch, while the IdP is still being polled + + stored := c.store("stale-token", owner, testTTL, generation) + + assert.False(t, stored, "a token from an ended session must not be cached") + _, found := c.get(owner) + assert.False(t, found, "the cache must stay empty after the session ended") +} + +// The same caller must still be able to store once it re-reads the generation, so +// the guard does not wedge the cache after any invalidation. +func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + c.clear() + + require.True(t, c.store("token", owner, testTTL, c.currentGeneration())) + + got, found := c.get(owner) + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) { + c := newJWTCache() + first := unixCaller(1000) + second := unixCaller(1001) + + c.store("first-token", first, testTTL, c.currentGeneration()) + c.store("second-token", second, testTTL, c.currentGeneration()) + + _, found := c.get(first) + assert.False(t, found, "the previous owner must not reach the new token") + + got, found := c.get(second) + require.True(t, found) + assert.Equal(t, "second-token", got) +} 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 15da16e51..e40f4ee2e 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -315,6 +315,7 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed), conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), @@ -354,6 +355,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.Mtu != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.ServerVNCAllowed != nil || msg.DisableVNCApproval != nil || msg.NetworkMonitor != nil || @@ -396,6 +398,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.WireguardPort != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.ServerVNCAllowed != nil || msg.DisableVNCApproval != nil || msg.RosenpassPermissive != nil || @@ -448,6 +451,7 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed), conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), diff --git a/client/server/server.go b/client/server/server.go index 3e5e77dc4..3adbd8423 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -23,9 +23,9 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/prometheus/client_golang/prometheus" - "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" @@ -39,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" ) @@ -154,9 +155,17 @@ type Server struct { } type oauthAuthFlow struct { - expiresAt time.Time - flow auth.OAuthFlow - info auth.AuthFlowInfo + expiresAt time.Time + flow auth.OAuthFlow + info auth.AuthFlowInfo + + // cacheGeneration is the SSH JWT cache's generation as of the start of the + // request that created this flow. The flow outlives a profile switch, so + // reading the generation any later — when the IdP has answered, or when the + // token finally arrives — would read the new session's one and let the old + // session's token into the new session's cache. + cacheGeneration uint64 + waitCancel context.CancelFunc } @@ -591,6 +600,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.RemoteJobsAllowed = msg.RemoteJobsAllowed config.ServerVNCAllowed = msg.ServerVNCAllowed config.DisableVNCApproval = msg.DisableVNCApproval config.NetworkMonitor = msg.NetworkMonitor @@ -658,6 +668,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } state := internal.CtxGetState(s.rootCtx) + status := state.CurrentStatus() + if status == internal.StatusConnected { + return &proto.LoginResponse{}, nil + } + defer func() { status, err := state.Status() if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) { @@ -717,54 +732,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 @@ -780,6 +748,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. // @@ -1229,6 +1267,8 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + s.jwtCache.clear() + if msg != nil && msg.ProfileName != nil { s.publishProfileListChanged(*msg.ProfileName) } @@ -1354,11 +1394,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 @@ -1369,18 +1414,36 @@ 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) + } + s.jwtCache.clear() + 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() @@ -1405,6 +1468,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe log.Errorf("failed to cleanup connection: %v", err) return nil, err } + s.jwtCache.clear() state := internal.CtxGetState(s.rootCtx) state.Set(internal.StatusNeedsLogin) @@ -1432,40 +1496,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) } @@ -1762,6 +1833,20 @@ func (s *Server) getJWTCacheTTL() time.Duration { return ttl } +// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a +// miss on a control channel that carries no caller identity. +func (s *Server) cachedJWT(ctx context.Context) (string, bool) { + caller, ok := ipcauth.CallerIdentity(ctx) + if !ok { + // Expected and handled on a control channel with no peer identity: the + // caller re-authenticates. daemonServerOptions warns about it once at + // startup, so this stays out of the per-request log. + log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel") + return "", false + } + return s.jwtCache.get(caller) +} + // RequestJWTAuth initiates JWT authentication flow for SSH func (s *Server) RequestJWTAuth( ctx context.Context, @@ -1771,8 +1856,14 @@ func (s *Server) RequestJWTAuth( return nil, ctx.Err() } + // The generation is read here, with the config and under the same lock, not + // where the flow is stored below: RequestAuthInfo talks to the IdP in + // between, and a switch or a logout during that call would otherwise be + // read as the generation this flow belongs to. SwitchProfile holds + // s.mutex across its own clear(), so the pair cannot be torn. s.mutex.Lock() config := s.config + cacheGeneration := s.jwtCache.currentGeneration() s.mutex.Unlock() if config == nil { @@ -1781,7 +1872,7 @@ func (s *Server) RequestJWTAuth( jwtCacheTTL := s.getJWTCacheTTL() if jwtCacheTTL > 0 { - if cachedToken, found := s.jwtCache.get(); found { + if cachedToken, found := s.cachedJWT(ctx); found { log.Debugf("JWT token found in cache, returning cached token for SSH authentication") return &proto.RequestJWTAuthResponse{ @@ -1815,6 +1906,7 @@ func (s *Server) RequestJWTAuth( s.oauthAuthFlow.flow = oAuthFlow s.oauthAuthFlow.info = authInfo s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.oauthAuthFlow.cacheGeneration = cacheGeneration s.mutex.Unlock() return &proto.RequestJWTAuthResponse{ @@ -1839,6 +1931,10 @@ func (s *Server) WaitJWTToken( s.mutex.Lock() oAuthFlow := s.oauthAuthFlow.flow authInfo := s.oauthAuthFlow.info + // Recorded when the flow was created, not read here: the flow survives a + // profile switch, and everything from RequestJWTAuth to the IdP answering + // has to count as the same session for the cache. + generation := s.oauthAuthFlow.cacheGeneration s.mutex.Unlock() if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode { @@ -1853,11 +1949,17 @@ func (s *Server) WaitJWTToken( token := tokenInfo.GetTokenToUse() jwtCacheTTL := s.getJWTCacheTTL() - if jwtCacheTTL > 0 { - s.jwtCache.store(token, jwtCacheTTL) - log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) - } else { + switch caller, ok := ipcauth.CallerIdentity(ctx); { + case jwtCacheTTL <= 0: log.Debug("JWT caching disabled, not storing token") + case !ok: + log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel") + default: + if s.jwtCache.store(token, caller, jwtCacheTTL, generation) { + log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) + } else { + log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled") + } } s.mutex.Lock() @@ -2218,6 +2320,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), ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed, DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval, RosenpassEnabled: cfg.RosenpassEnabled, @@ -2310,7 +2413,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_connect_test.go b/client/server/server_connect_test.go index 0c6e03a4a..dc191a44f 100644 --- a/client/server/server_connect_test.go +++ b/client/server/server_connect_test.go @@ -18,6 +18,10 @@ func newTestServer() *Server { return &Server{ rootCtx: context.Background(), statusRecorder: peer.NewRecorder(""), + // New always populates the SSH JWT cache and the logout and + // profile-switch paths call into it unconditionally, so a Server + // assembled field by field has to populate it too. + jwtCache: newJWTCache(), } } diff --git a/client/server/server_jwt_test.go b/client/server/server_jwt_test.go new file mode 100644 index 000000000..3fec5598a --- /dev/null +++ b/client/server/server_jwt_test.go @@ -0,0 +1,188 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/localmetrics" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// These cover the RPC side of the cache: the cache itself is exercised in +// jwt_cache_test.go, but a correct cache buys nothing if the handlers around it +// consult the wrong identity or forget to clear it. + +func TestCachedJWT_ServesTheOwner(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(owner)) + + require.True(t, found, "the identity that obtained the token must get it back") + assert.Equal(t, "token", got) +} + +func TestCachedJWT_RefusesAnotherCaller(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(privilegedIdentity())) + + assert.False(t, found, "a caller that did not obtain the token must get a miss") + assert.Empty(t, got) +} + +// A control channel that carries no caller identity — a TCP daemon socket, or a +// platform with no peer-credential primitive — cannot tell one local user from +// another, so cachedJWT must fail closed there. +func TestCachedJWT_WithoutCallerIdentity(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(context.Background()) + + assert.False(t, found) + assert.Empty(t, got) +} + +// profileFixture points the profile globals at a temp dir holding a single +// default profile, which is the one ActiveProfileState.FilePath resolves +// without consulting the current OS user. +func profileFixture(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + defaultConfig := filepath.Join(dir, "default.json") + require.NoError(t, os.WriteFile(defaultConfig, []byte("{}"), 0o600)) + + origDir := profilemanager.DefaultConfigPathDir + origDefault := profilemanager.DefaultConfigPath + origState := profilemanager.ActiveProfileStatePath + origOverride := profilemanager.ConfigDirOverride + + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = defaultConfig + profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json") + profilemanager.ConfigDirOverride = dir + + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origDefault + profilemanager.ActiveProfileStatePath = origState + profilemanager.ConfigDirOverride = origOverride + }) + + return defaultConfig +} + +// A profile carries its own NetBird account, so a token obtained under the +// previous one must not survive the switch even for the local user who +// obtained it. +func TestSwitchProfile_ClearsJWTCache(t *testing.T) { + defaultConfig := profileFixture(t) + + // localmetrics.NewManager runs until its context is done, so the manager + // must not outlive the test. + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + s := newTestServer() + s.profileManager = profilemanager.NewServiceManager(defaultConfig) + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, nil) + + // A second profile to move to, so the request goes through + // switchProfileIfNeeded rather than the no-op path a nil request takes. + const target = "second" + username := "tester" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + name := target + _, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username}) + require.NoError(t, err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(target), active.ID, "the profile must actually have changed") + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "switching profile must drop the cached SSH JWT") +} + +// Down ends the connection, not the session: the peer stays enrolled and the +// token still belongs to the same NetBird identity, so `down` followed by `up` +// must not cost the owner a fresh device-code flow. +// +// The logout handlers do call cleanupConnection, and SwitchProfile does not; +// what they have in common is that each clears the cache itself, right after, +// so tearing the connection down is no longer what decides the token's fate. +func TestCleanupConnection_KeepsJWTCache(t *testing.T) { + s := newTestServer() + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + require.NoError(t, s.cleanupConnection()) + + got, found := s.jwtCache.get(owner) + require.True(t, found, "going down must not drop the cached SSH JWT") + assert.Equal(t, "token", got) +} + +// fakeOAuthFlow stands in for the IdP round trip so a test can drive +// WaitJWTToken without a real device-code flow. +type fakeOAuthFlow struct { + token string +} + +func (f *fakeOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) { + return auth.AuthFlowInfo{DeviceCode: "device-code"}, nil +} + +func (f *fakeOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) { + return auth.TokenInfo{AccessToken: f.token}, nil +} + +func (f *fakeOAuthFlow) GetClientID(context.Context) string { return "client-id" } + +// The flow outlives a profile switch, because SwitchProfile does not reset +// s.oauthAuthFlow. A switch between RequestJWTAuth and the IdP answering must +// still keep the token out of the cache the new profile uses, and the +// generation the flow carries is what decides it: reading the cache's own +// generation at store time would already be the new one. +func TestWaitJWTToken_DropsTokenFromASessionThatEndedBeforeTheWait(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + ttl := int(testTTL.Seconds()) + s.config = &profilemanager.Config{SSHJWTCacheTTL: &ttl} + + // RequestJWTAuth ran under the previous session and recorded its generation. + s.oauthAuthFlow.flow = &fakeOAuthFlow{token: "token-from-the-old-session"} + s.oauthAuthFlow.info = auth.AuthFlowInfo{DeviceCode: "device-code"} + s.oauthAuthFlow.cacheGeneration = s.jwtCache.currentGeneration() + + // A profile switch or a logout lands before the caller reaches WaitJWTToken. + s.jwtCache.clear() + + _, err := s.WaitJWTToken(ctxWithIdentity(owner), &proto.WaitJWTTokenRequest{DeviceCode: "device-code"}) + require.NoError(t, err) + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "a token whose flow started under the previous session must not be cached") +} diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index afe7486fa..57dfb97f6 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 serverVNCAllowed := true disableVNCApproval := true interfaceName := "utun100" @@ -89,6 +90,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassEnabled: &rosenpassEnabled, RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, + RemoteJobsAllowed: &remoteJobsAllowed, ServerVNCAllowed: &serverVNCAllowed, DisableVNCApproval: &disableVNCApproval, InterfaceName: &interfaceName, @@ -136,6 +138,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.NotNil(t, cfg.ServerVNCAllowed) require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed) require.NotNil(t, cfg.DisableVNCApproval) @@ -194,6 +198,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "RemoteJobsAllowed": true, "ServerVNCAllowed": true, "DisableVNCApproval": true, "InterfaceName": true, @@ -258,6 +263,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-remote-jobs": "RemoteJobsAllowed", "allow-server-vnc": "ServerVNCAllowed", "disable-vnc-approval": "DisableVNCApproval", "interface-name": "InterfaceName", diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 0a970b051..a69f07c0b 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -53,6 +53,7 @@ import ( type privilegedConfigChange struct { managementURL string serverSSHAllowed *bool + remoteJobsAllowed *bool enableSSHRoot *bool disableSSHAuth *bool serverVNCAllowed *bool @@ -66,6 +67,7 @@ func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfig return privilegedConfigChange{ managementURL: msg.GetManagementUrl(), serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, enableSSHRoot: msg.EnableSSHRoot, disableSSHAuth: msg.DisableSSHAuth, serverVNCAllowed: msg.ServerVNCAllowed, @@ -80,6 +82,7 @@ func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { return privilegedConfigChange{ managementURL: msg.GetManagementUrl(), serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, enableSSHRoot: msg.EnableSSHRoot, disableSSHAuth: msg.DisableSSHAuth, serverVNCAllowed: msg.ServerVNCAllowed, @@ -110,6 +113,15 @@ 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 err := requirePrivilegeForVNCChange(ctx, stored, change); err != nil { return err } diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go index 11818b06d..3c1971155 100644 --- a/client/server/ssh_gate_test.go +++ b/client/server/ssh_gate_test.go @@ -173,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)}, diff --git a/client/system/info.go b/client/system/info.go index a05b44fc8..fb7a428bf 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 ServerVNCAllowed bool DisableClientRoutes bool @@ -92,12 +93,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 + } if serverVNCAllowed != nil { i.ServerVNCAllowed = *serverVNCAllowed } 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 eb8f0af6b..1d2775386 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -293,11 +293,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 0855cd58c..932472854 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -84,7 +84,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 6e5d07740..91c38be08 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -284,12 +284,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, @@ -310,5 +321,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 e40443f73..d3a887758 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -91,6 +91,21 @@ --> + + + + + +