Merge branch 'main' into embedded-vnc

This commit is contained in:
Viktor Liu
2026-09-02 19:10:03 +02:00
153 changed files with 9089 additions and 1934 deletions
+125
View File
@@ -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),
}
}
+34
View File
@@ -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, &section); 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))
}
+151
View File
@@ -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")
}
}
+1 -2
View File
@@ -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)
}
+13
View File
@@ -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")
}
+18 -20
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+5 -6
View File
@@ -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)
}
+4 -2
View File
@@ -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
}
+89 -16
View File
@@ -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 <name>` 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
}
+88
View File
@@ -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)
}
+41 -11
View File
@@ -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 {
+261
View File
@@ -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)
}
}
}
+1
View File
@@ -369,6 +369,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.EnableSSHLocalPortForwarding,
a.config.EnableSSHRemotePortForwarding,
a.config.DisableSSHAuth,
a.config.RemoteJobsAllowed,
)
}
+10
View File
@@ -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
}
+3 -1
View File
@@ -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)
}
+3
View File
@@ -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))
}
+16 -6
View File
@@ -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
+39 -2
View File
@@ -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() {
+36
View File
@@ -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")
})
}
}
+13
View File
@@ -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() {
@@ -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")
})
}
}
+14 -3
View File
@@ -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
+24 -5
View File
@@ -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")
@@ -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 {
+75 -21
View File
@@ -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)
@@ -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.
}
+98 -1
View File
@@ -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
@@ -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) {
@@ -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
}
@@ -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)
}
@@ -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 {
+16
View File
@@ -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)
+16 -9
View File
@@ -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 {
@@ -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")}},
+19 -8
View File
@@ -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
@@ -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")
}
+26 -1
View File
@@ -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)
}
+6 -2
View File
@@ -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)
+2
View File
@@ -34,6 +34,8 @@ var allKeys = []string{
KeySplitTunnelMode,
KeySplitTunnelApps,
KeyLazyConnection,
KeyRemoteJobsAllowed,
KeyBundleUploadURL,
}
// canonicalKey maps the lowercase form of a managed-config value name to
+13
View File
@@ -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
+7 -1
View File
@@ -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)
}
+57 -24
View File
@@ -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" +
+14 -6
View File
@@ -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{}
+49 -4
View File
@@ -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{}
}
+176
View File
@@ -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)
}
+200
View File
@@ -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)
}
+4
View File
@@ -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),
+195 -92
View File
@@ -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.
+4
View File
@@ -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(),
}
}
+188
View File
@@ -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")
}
+6
View File
@@ -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",
+12
View File
@@ -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
}
+28
View File
@@ -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)},
+5
View File
@@ -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
}
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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();
};
+1
View File
@@ -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"},
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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()
+2 -1
View File
@@ -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(
+15 -4
View File
@@ -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())
}