From cd98648a670f8830f2ef9c3aa367d71b4f12172e Mon Sep 17 00:00:00 2001 From: "Theodor S. Midtlien" Date: Fri, 24 Jul 2026 21:26:47 +0200 Subject: [PATCH] Add elevation for dangerous ssh flags --- client/cmd/elevate.go | 58 ++++++++++++++++++++ client/cmd/elevate_darwin.go | 16 ++++++ client/cmd/elevate_linux.go | 38 +++++++++++++ client/cmd/elevate_other.go | 17 ++++++ client/cmd/elevate_test.go | 99 ++++++++++++++++++++++++++++++++++ client/cmd/elevate_windows.go | 23 ++++++++ client/cmd/root.go | 2 + client/cmd/ssh_config_cmd.go | 73 +++++++++++++++++++++++++ client/cmd/up.go | 28 ++++++++-- client/ui/main.go | 12 +++++ client/ui/services/settings.go | 22 ++++++++ 11 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 client/cmd/elevate.go create mode 100644 client/cmd/elevate_darwin.go create mode 100644 client/cmd/elevate_linux.go create mode 100644 client/cmd/elevate_other.go create mode 100644 client/cmd/elevate_test.go create mode 100644 client/cmd/elevate_windows.go create mode 100644 client/cmd/ssh_config_cmd.go diff --git a/client/cmd/elevate.go b/client/cmd/elevate.go new file mode 100644 index 000000000..9ecb34249 --- /dev/null +++ b/client/cmd/elevate.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// SetSSHConfigCmdName is the hidden subcommand an elevated process runs to apply +// the privileged SSH settings. +const SetSSHConfigCmdName = "set-ssh-config" + +// sshConfigElevated is set by runInDaemonMode once the dangerous SSH settings +// have been applied by an elevated helper, so the (unprivileged) up flow does +// not re-send them and re-trip the daemon gate. +var sshConfigElevated bool + +// wantsDangerousSSH reports whether this invocation is trying to ENABLE SSH root +// login or DISABLE SSH authentication. +func wantsDangerousSSH(cmd *cobra.Command) bool { + return (cmd.Flags().Changed(enableSSHRootFlag) && enableSSHRoot) || + (cmd.Flags().Changed(disableSSHAuthFlag) && disableSSHAuth) +} + +// buildSetSSHConfigArgs builds the argument list for an elevated +// `netbird set-ssh-config` invocation. +func buildSetSSHConfigArgs(profileName, username string, enableSSHRoot, disableSSHAuth *bool, daemonAddr string) []string { + args := []string{SetSSHConfigCmdName} + if profileName != "" { + args = append(args, "--profile", profileName) + } + if username != "" { + args = append(args, "--username", username) + } + if enableSSHRoot != nil && *enableSSHRoot { + args = append(args, "--"+enableSSHRootFlag) + } + if disableSSHAuth != nil && *disableSSHAuth { + args = append(args, "--"+disableSSHAuthFlag) + } + if daemonAddr != "" { + args = append(args, "--daemon-addr", daemonAddr) + } + return args +} + +// ElevateSSHConfig re-runs the current executable's `set-ssh-config` command with +// root/administrator privileges via the platform's prompt (pkexec/UAC/osascript), +// so the elevated process connects to the daemon with a privileged identity and +// the daemon's requirePrivilegedForDangerousSSH gate passes. +func ElevateSSHConfig(profileName, username string, enableSSHRoot, disableSSHAuth *bool) error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locate executable for elevation: %w", err) + } + return runElevated(exe, buildSetSSHConfigArgs(profileName, username, enableSSHRoot, disableSSHAuth, daemonAddr)) +} diff --git a/client/cmd/elevate_darwin.go b/client/cmd/elevate_darwin.go new file mode 100644 index 000000000..3b7a72ef4 --- /dev/null +++ b/client/cmd/elevate_darwin.go @@ -0,0 +1,16 @@ +//go:build darwin + +package cmd + +import ( + "fmt" + "os" +) + +// isProcessPrivileged reports whether the current process runs as root. +func isProcessPrivileged() bool { return os.Geteuid() == 0 } + +// TODO(ssh-elevation): implement the osascript admin prompt. +func runElevated(_ string, _ []string) error { + return fmt.Errorf("automatic privilege elevation is not yet implemented on macOS, re-run with sudo") +} diff --git a/client/cmd/elevate_linux.go b/client/cmd/elevate_linux.go new file mode 100644 index 000000000..ca501597f --- /dev/null +++ b/client/cmd/elevate_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// isProcessPrivileged reports whether the current process runs as root. +func isProcessPrivileged() bool { return os.Geteuid() == 0 } + +// runElevated re-runs exe with args as root. On a graphical session it uses +// pkexec, which drives the desktop's polkit authentication agent (GUI prompt). +func runElevated(exe string, args []string) error { + if !hasGraphicalSession() { + return fmt.Errorf("cannot request privilege elevation without a graphical session. re-run as root: sudo %s %s", exe, strings.Join(args, " ")) + } + pkexec, err := exec.LookPath("pkexec") + if err != nil { + return fmt.Errorf("pkexec not found for privilege elevation, re-run as root: sudo %s %s", exe, strings.Join(args, " ")) + } + + c := exec.Command(pkexec, append([]string{exe}, args...)...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("elevated %s failed (elevation cancelled or denied?): %w", SetSSHConfigCmdName, err) + } + return nil +} + +// hasGraphicalSession heuristically reports whether a desktop session is present +// that polkit can prompt in. +func hasGraphicalSession() bool { + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" +} diff --git a/client/cmd/elevate_other.go b/client/cmd/elevate_other.go new file mode 100644 index 000000000..694b5a5ca --- /dev/null +++ b/client/cmd/elevate_other.go @@ -0,0 +1,17 @@ +//go:build !linux && !darwin && !windows + +package cmd + +import ( + "fmt" + "os" + "strings" +) + +// isProcessPrivileged reports whether the current process runs as root. +func isProcessPrivileged() bool { return os.Geteuid() == 0 } + +// runElevated has no automatic elevation mechanism on these platforms +func runElevated(exe string, args []string) error { + return fmt.Errorf("automatic privilege elevation is not supported on this platform, re-run as root: sudo %s %s", exe, strings.Join(args, " ")) +} diff --git a/client/cmd/elevate_test.go b/client/cmd/elevate_test.go new file mode 100644 index 000000000..c743153df --- /dev/null +++ b/client/cmd/elevate_test.go @@ -0,0 +1,99 @@ +//go:build !ios && !android + +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSetSSHConfigArgs(t *testing.T) { + tr, fa := true, false + + t.Run("enable root only, with daemon-addr", func(t *testing.T) { + got := buildSetSSHConfigArgs("prof", "alice", &tr, nil, "unix:///x.sock") + assert.Equal(t, []string{ + SetSSHConfigCmdName, "--profile", "prof", "--username", "alice", + "--" + enableSSHRootFlag, "--daemon-addr", "unix:///x.sock", + }, got) + }) + + t.Run("disable auth only, no daemon-addr", func(t *testing.T) { + got := buildSetSSHConfigArgs("prof", "alice", nil, &tr, "") + assert.Equal(t, []string{ + SetSSHConfigCmdName, "--profile", "prof", "--username", "alice", + "--" + disableSSHAuthFlag, + }, got) + }) + + t.Run("false pointers omit the flags", func(t *testing.T) { + got := buildSetSSHConfigArgs("", "", &fa, &fa, "") + assert.Equal(t, []string{SetSSHConfigCmdName}, got) + }) + + t.Run("both enabled", func(t *testing.T) { + got := buildSetSSHConfigArgs("p", "u", &tr, &tr, "") + assert.Equal(t, []string{ + SetSSHConfigCmdName, "--profile", "p", "--username", "u", + "--" + enableSSHRootFlag, "--" + disableSSHAuthFlag, + }, got) + }) +} + +func TestBuildSetSSHConfigRequest(t *testing.T) { + tr := true + + req := buildSetSSHConfigRequest("p", "u", &tr, nil) + assert.Equal(t, "p", req.ProfileName) + assert.Equal(t, "u", req.Username) + if assert.NotNil(t, req.EnableSSHRoot) { + assert.True(t, *req.EnableSSHRoot) + } + assert.Nil(t, req.DisableSSHAuth, "an unset flag must leave the daemon value untouched") +} + +func TestWantsDangerousSSH(t *testing.T) { + origRoot, origAuth := enableSSHRoot, disableSSHAuth + t.Cleanup(func() { enableSSHRoot, disableSSHAuth = origRoot, origAuth }) + + newCmd := func() *cobra.Command { + enableSSHRoot, disableSSHAuth = false, false + c := &cobra.Command{Use: "x"} + c.Flags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "") + c.Flags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "") + return c + } + + // wantsDangerousSSH fires only in the privileged direction. + t.Run("enable root true is dangerous", func(t *testing.T) { + c := newCmd() + require.NoError(t, c.Flags().Set(enableSSHRootFlag, "true")) + assert.True(t, wantsDangerousSSH(c)) + }) + + t.Run("enable root false is not dangerous", func(t *testing.T) { + c := newCmd() + require.NoError(t, c.Flags().Set(enableSSHRootFlag, "false")) + assert.False(t, wantsDangerousSSH(c)) + }) + + t.Run("disable auth true is dangerous", func(t *testing.T) { + c := newCmd() + require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "true")) + assert.True(t, wantsDangerousSSH(c)) + }) + + t.Run("disable auth false is not dangerous", func(t *testing.T) { + c := newCmd() + require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "false")) + assert.False(t, wantsDangerousSSH(c)) + }) + + t.Run("nothing changed is not dangerous", func(t *testing.T) { + c := newCmd() + assert.False(t, wantsDangerousSSH(c)) + }) +} diff --git a/client/cmd/elevate_windows.go b/client/cmd/elevate_windows.go new file mode 100644 index 000000000..f24329a72 --- /dev/null +++ b/client/cmd/elevate_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package cmd + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// isProcessPrivileged reports whether the current process token is elevated +// (running as administrator / LocalSystem). +func isProcessPrivileged() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// runElevated should re-run exe with args elevated via a UAC prompt +// (ShellExecuteEx with the "runas" verb). +// +// TODO(ssh-elevation): implement ShellExecuteEx("runas") + wait for the child. +func runElevated(_ string, _ []string) error { + return fmt.Errorf("automatic privilege elevation is not yet implemented on Windows, re-run netbird as administrator") +} diff --git a/client/cmd/root.go b/client/cmd/root.go index 94c768220..f1d64fead 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -176,6 +176,8 @@ func init() { rootCmd.AddCommand(ownerCmd) ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd) + rootCmd.AddCommand(setSSHConfigCmd) + networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) diff --git a/client/cmd/ssh_config_cmd.go b/client/cmd/ssh_config_cmd.go new file mode 100644 index 000000000..91fa29a41 --- /dev/null +++ b/client/cmd/ssh_config_cmd.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" +) + +var ( + setSSHConfigProfile string + setSSHConfigUsername string + setSSHConfigEnableRoot bool + setSSHConfigDisableAuth bool +) + +// setSSHConfigCmd applies the privileged SSH settings (enable root login / +// disable auth) to a profile over the daemon IPC. Hidden because users +// interact via `netbird up` / the UI, not directly. +var setSSHConfigCmd = &cobra.Command{ + Use: SetSSHConfigCmdName, + Short: "Apply privileged SSH settings to a profile (internal elevation target)", + Hidden: true, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := internal.CtxInitState(cmd.Context()) + + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return fmt.Errorf("connect to daemon: %w", err) + } + defer func() { + if cerr := conn.Close(); cerr != nil { + log.Warnf("failed closing daemon gRPC client connection: %v", cerr) + } + }() + + var enableRoot, disableAuth *bool + if cmd.Flags().Changed(enableSSHRootFlag) { + enableRoot = &setSSHConfigEnableRoot + } + if cmd.Flags().Changed(disableSSHAuthFlag) { + disableAuth = &setSSHConfigDisableAuth + } + + req := buildSetSSHConfigRequest(setSSHConfigProfile, setSSHConfigUsername, enableRoot, disableAuth) + if _, err := proto.NewDaemonServiceClient(conn).SetConfig(ctx, req); err != nil { + return fmt.Errorf("apply SSH config: %w", err) + } + return nil + }, +} + +// buildSetSSHConfigRequest builds a minimal SetConfigRequest that touches only +// the SSH fields that were provided (nil pointers leave the daemon's stored +// value untouched, matching setupSetConfigReq's pointer semantics). +func buildSetSSHConfigRequest(profileName, username string, enableSSHRoot, disableSSHAuth *bool) *proto.SetConfigRequest { + return &proto.SetConfigRequest{ + ProfileName: profileName, + Username: username, + EnableSSHRoot: enableSSHRoot, + DisableSSHAuth: disableSSHAuth, + } +} + +func init() { + setSSHConfigCmd.Flags().StringVar(&setSSHConfigProfile, "profile", "", "profile name to apply the SSH settings to") + setSSHConfigCmd.Flags().StringVar(&setSSHConfigUsername, "username", "", "owning username of the profile") + setSSHConfigCmd.Flags().BoolVar(&setSSHConfigEnableRoot, enableSSHRootFlag, false, "enable root login for the SSH server") + setSSHConfigCmd.Flags().BoolVar(&setSSHConfigDisableAuth, disableSSHAuthFlag, false, "disable SSH authentication") +} diff --git a/client/cmd/up.go b/client/cmd/up.go index 4d8ff9c27..f510a35c1 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,8 +21,8 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" @@ -321,6 +321,24 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager return fmt.Errorf("get current user: %v", err) } + // Enabling SSH root login / disabling SSH auth is a privileged change the + // daemon rejects from a non-privileged caller. Offer to elevate (polkit/UAC/ + // osascript) so an elevated helper applies just those settings. + if wantsDangerousSSH(cmd) && !isProcessPrivileged() { + cmd.Println("Enabling SSH root login / disabling SSH authentication requires administrator privileges, requesting elevation...") + var enableRoot, disableAuth *bool + if cmd.Flag(enableSSHRootFlag).Changed { + enableRoot = &enableSSHRoot + } + if cmd.Flag(disableSSHAuthFlag).Changed { + disableAuth = &disableSSHAuth + } + if err := ElevateSSHConfig(activeProf.ID.String(), username.Username, enableRoot, disableAuth); err != nil { + return fmt.Errorf("apply privileged SSH settings: %w", err) + } + sshConfigElevated = true + } + // set the new config req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { @@ -424,7 +442,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } - if cmd.Flag(enableSSHRootFlag).Changed { + if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated { req.EnableSSHRoot = &enableSSHRoot } if cmd.Flag(enableSSHSFTPFlag).Changed { @@ -436,7 +454,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward } - if cmd.Flag(disableSSHAuthFlag).Changed { + if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated { req.DisableSSHAuth = &disableSSHAuth } if cmd.Flag(sshJWTCacheTTLFlag).Changed { @@ -652,7 +670,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.ServerSSHAllowed = &serverSSHAllowed } - if cmd.Flag(enableSSHRootFlag).Changed { + if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated { loginRequest.EnableSSHRoot = &enableSSHRoot } @@ -668,7 +686,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward } - if cmd.Flag(disableSSHAuthFlag).Changed { + if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated { loginRequest.DisableSSHAuth = &disableSSHAuth } diff --git a/client/ui/main.go b/client/ui/main.go index 4ee0e7e01..b707cff42 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -8,6 +8,7 @@ import ( "flag" "io/fs" "log" + "os" "runtime" "strings" @@ -16,6 +17,7 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/services/notifications" + "github.com/netbirdio/netbird/client/cmd" "github.com/netbirdio/netbird/client/ui/authsession" "github.com/netbirdio/netbird/client/ui/i18n" "github.com/netbirdio/netbird/client/ui/preferences" @@ -80,6 +82,16 @@ func init() { } func main() { + // When re-launched under a privilege prompt (pkexec/UAC/osascript) to apply + // the dangerous SSH settings, this binary is invoked as os.Executable(). + // Delegate to the shared CLI command and exit before starting the GUI. + if len(os.Args) > 1 && os.Args[1] == cmd.SetSSHConfigCmdName { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } + os.Exit(0) + } + daemonAddr, userSetLogFile := parseFlagsAndInitLog() conn := NewConn(daemonAddr) diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 3b6f6f81b..e57c95627 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -7,6 +7,10 @@ import ( "fmt" "reflect" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/cmd" "github.com/netbirdio/netbird/client/proto" ) @@ -190,9 +194,27 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { SshJWTCacheTTL: p.SSHJWTCacheTTL, } _, err = cli.SetConfig(ctx, req) + if err != nil && wantsDangerousSSHConfig(p) && status.Code(err) == codes.PermissionDenied { + // The daemon restricts enabling SSH root login / disabling SSH auth to + // root/administrator, and the UI runs as the unprivileged desktop user. + // Elevate (polkit/UAC/osascript) so a privileged helper applies just + // those flags. + if elevErr := cmd.ElevateSSHConfig(p.ProfileName, p.Username, p.EnableSSHRoot, p.DisableSSHAuth); elevErr != nil { + return elevErr + } + req.EnableSSHRoot = nil + req.DisableSSHAuth = nil + _, err = cli.SetConfig(ctx, req) + } return err } +// wantsDangerousSSHConfig reports whether p tries to enable SSH root login or +// disable SSH authentication. +func wantsDangerousSSHConfig(p SetConfigParams) bool { + return (p.EnableSSHRoot != nil && *p.EnableSSHRoot) || (p.DisableSSHAuth != nil && *p.DisableSSHAuth) +} + func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { cli, err := s.conn.Client() if err != nil {