Add elevation for dangerous ssh flags

This commit is contained in:
Theodor S. Midtlien
2026-07-24 21:26:47 +02:00
parent 0bb73b3730
commit cd98648a67
11 changed files with 383 additions and 5 deletions

58
client/cmd/elevate.go Normal file
View File

@@ -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))
}

View File

@@ -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")
}

View File

@@ -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") != ""
}

View File

@@ -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, " "))
}

View File

@@ -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))
})
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {