Complete overhaul

This commit is contained in:
Viktor Liu
2025-06-24 12:19:53 +02:00
parent f56075ca15
commit 9d1554f9f7
74 changed files with 16626 additions and 4524 deletions

View File

@@ -201,6 +201,94 @@ func (p *Preferences) SetServerSSHAllowed(allowed bool) {
p.configInput.ServerSSHAllowed = &allowed
}
// GetEnableSSHRoot reads SSH root login setting from config file
func (p *Preferences) GetEnableSSHRoot() (bool, error) {
if p.configInput.EnableSSHRoot != nil {
return *p.configInput.EnableSSHRoot, nil
}
cfg, err := internal.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
if cfg.EnableSSHRoot == nil {
// Default to false for security on Android
return false, nil
}
return *cfg.EnableSSHRoot, err
}
// SetEnableSSHRoot stores the given value and waits for commit
func (p *Preferences) SetEnableSSHRoot(enabled bool) {
p.configInput.EnableSSHRoot = &enabled
}
// GetEnableSSHSFTP reads SSH SFTP setting from config file
func (p *Preferences) GetEnableSSHSFTP() (bool, error) {
if p.configInput.EnableSSHSFTP != nil {
return *p.configInput.EnableSSHSFTP, nil
}
cfg, err := internal.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
if cfg.EnableSSHSFTP == nil {
// Default to false for security on Android
return false, nil
}
return *cfg.EnableSSHSFTP, err
}
// SetEnableSSHSFTP stores the given value and waits for commit
func (p *Preferences) SetEnableSSHSFTP(enabled bool) {
p.configInput.EnableSSHSFTP = &enabled
}
// GetEnableSSHLocalPortForwarding reads SSH local port forwarding setting from config file
func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) {
if p.configInput.EnableSSHLocalPortForwarding != nil {
return *p.configInput.EnableSSHLocalPortForwarding, nil
}
cfg, err := internal.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
if cfg.EnableSSHLocalPortForwarding == nil {
// Default to false for security on Android
return false, nil
}
return *cfg.EnableSSHLocalPortForwarding, err
}
// SetEnableSSHLocalPortForwarding stores the given value and waits for commit
func (p *Preferences) SetEnableSSHLocalPortForwarding(enabled bool) {
p.configInput.EnableSSHLocalPortForwarding = &enabled
}
// GetEnableSSHRemotePortForwarding reads SSH remote port forwarding setting from config file
func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) {
if p.configInput.EnableSSHRemotePortForwarding != nil {
return *p.configInput.EnableSSHRemotePortForwarding, nil
}
cfg, err := internal.ReadConfig(p.configInput.ConfigPath)
if err != nil {
return false, err
}
if cfg.EnableSSHRemotePortForwarding == nil {
// Default to false for security on Android
return false, nil
}
return *cfg.EnableSSHRemotePortForwarding, err
}
// SetEnableSSHRemotePortForwarding stores the given value and waits for commit
func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) {
p.configInput.EnableSSHRemotePortForwarding = &enabled
}
// GetBlockInbound reads block inbound setting from config file
func (p *Preferences) GetBlockInbound() (bool, error) {
if p.configInput.BlockInbound != nil {

View File

@@ -35,7 +35,6 @@ const (
wireguardPortFlag = "wireguard-port"
networkMonitorFlag = "network-monitor"
disableAutoConnectFlag = "disable-auto-connect"
serverSSHAllowedFlag = "allow-server-ssh"
extraIFaceBlackListFlag = "extra-iface-blacklist"
dnsRouteIntervalFlag = "dns-router-interval"
systemInfoFlag = "system-info"
@@ -67,7 +66,6 @@ var (
customDNSAddress string
rosenpassEnabled bool
rosenpassPermissive bool
serverSSHAllowed bool
interfaceName string
wireguardPort uint16
networkMonitor bool
@@ -182,7 +180,6 @@ func init() {
)
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&serverSSHAllowed, serverSSHAllowedFlag, false, "Allow SSH server on peer. If enabled, the SSH server will be permitted")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand.")

View File

@@ -14,113 +14,375 @@ import (
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/internal"
nbssh "github.com/netbirdio/netbird/client/ssh"
sshclient "github.com/netbirdio/netbird/client/ssh/client"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/util"
)
var (
port int
username string
host string
command string
const (
sshUsernameDesc = "SSH username"
hostArgumentRequired = "host argument required"
serverSSHAllowedFlag = "allow-server-ssh"
enableSSHRootFlag = "enable-ssh-root"
enableSSHSFTPFlag = "enable-ssh-sftp"
enableSSHLocalPortForwardFlag = "enable-ssh-local-port-forwarding"
enableSSHRemotePortForwardFlag = "enable-ssh-remote-port-forwarding"
)
var (
port int
username string
host string
command string
localForwards []string
remoteForwards []string
strictHostKeyChecking bool
knownHostsFile string
identityFile string
)
var (
serverSSHAllowed bool
enableSSHRoot bool
enableSSHSFTP bool
enableSSHLocalPortForward bool
enableSSHRemotePortForward bool
)
func init() {
upCmd.PersistentFlags().BoolVar(&serverSSHAllowed, serverSSHAllowedFlag, false, "Allow SSH server on peer")
upCmd.PersistentFlags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "Enable root login for SSH server")
upCmd.PersistentFlags().BoolVar(&enableSSHSFTP, enableSSHSFTPFlag, false, "Enable SFTP subsystem for SSH server")
upCmd.PersistentFlags().BoolVar(&enableSSHLocalPortForward, enableSSHLocalPortForwardFlag, false, "Enable local port forwarding for SSH server")
upCmd.PersistentFlags().BoolVar(&enableSSHRemotePortForward, enableSSHRemotePortForwardFlag, false, "Enable remote port forwarding for SSH server")
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", sshserver.DefaultSSHPort, "Remote SSH port")
sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", sshUsernameDesc)
sshCmd.PersistentFlags().StringVar(&username, "login", "", sshUsernameDesc+" (alias for --user)")
sshCmd.PersistentFlags().BoolVar(&strictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking (default: true)")
sshCmd.PersistentFlags().StringVarP(&knownHostsFile, "known-hosts", "o", "", "Path to known_hosts file (default: ~/.ssh/known_hosts)")
sshCmd.PersistentFlags().StringVarP(&identityFile, "identity", "i", "", "Path to SSH private key file")
sshCmd.PersistentFlags().StringArrayP("L", "L", []string{}, "Local port forwarding [bind_address:]port:host:hostport")
sshCmd.PersistentFlags().StringArrayP("R", "R", []string{}, "Remote port forwarding [bind_address:]port:host:hostport")
sshCmd.AddCommand(sshSftpCmd)
}
var sshCmd = &cobra.Command{
Use: "ssh [user@]host [command]",
Use: "ssh [flags] [user@]host [command]",
Short: "Connect to a NetBird peer via SSH",
Long: `Connect to a NetBird peer using SSH.
Long: `Connect to a NetBird peer using SSH with support for port forwarding.
Port Forwarding:
-L [bind_address:]port:host:hostport Local port forwarding
-L [bind_address:]port:/path/to/socket Local port forwarding to Unix socket
-R [bind_address:]port:host:hostport Remote port forwarding
-R [bind_address:]port:/path/to/socket Remote port forwarding to Unix socket
SSH Options:
-p, --port int Remote SSH port (default 22)
-u, --user string SSH username
--login string SSH username (alias for --user)
--strict-host-key-checking Enable strict host key checking (default: true)
-o, --known-hosts string Path to known_hosts file
-i, --identity string Path to SSH private key file
Examples:
netbird ssh peer-hostname
netbird ssh root@peer-hostname
netbird ssh --login root peer-hostname
netbird ssh peer-hostname
netbird ssh peer-hostname ls -la
netbird ssh peer-hostname whoami`,
netbird ssh peer-hostname whoami
netbird ssh -L 8080:localhost:80 peer-hostname # Local port forwarding
netbird ssh -R 9090:localhost:3000 peer-hostname # Remote port forwarding
netbird ssh -L "*:8080:localhost:80" peer-hostname # Bind to all interfaces
netbird ssh -L 8080:/tmp/socket peer-hostname # Unix socket forwarding`,
DisableFlagParsing: true,
Args: validateSSHArgsWithoutFlagParsing,
RunE: func(cmd *cobra.Command, args []string) error {
// Check if help was requested
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return cmd.Help()
RunE: sshFn,
Aliases: []string{"ssh"},
}
func sshFn(cmd *cobra.Command, args []string) error {
// Check if help was requested
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return cmd.Help()
}
}
SetFlagsFromEnvVars(rootCmd)
SetFlagsFromEnvVars(cmd)
// Global flags were already parsed by validateSSHArgsWithoutFlagParsing
// No additional parsing needed here
cmd.SetOut(cmd.OutOrStdout())
logOutput := "console"
if logFile != "" && logFile != "/var/log/netbird/client.log" {
logOutput = logFile
}
if err := util.InitLog(logLevel, logOutput); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := internal.CtxInitState(cmd.Context())
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
sshctx, cancel := context.WithCancel(ctx)
go func() {
if err := runSSH(sshctx, host, cmd); err != nil {
cmd.Printf("Error: %v\n", err)
os.Exit(1)
}
cancel()
}()
select {
case <-sig:
cancel()
case <-sshctx.Done():
}
return nil
}
// getEnvOrDefault checks for environment variables with WT_ and NB_ prefixes
func getEnvOrDefault(flagName, defaultValue string) string {
if envValue := os.Getenv("WT_" + flagName); envValue != "" {
return envValue
}
if envValue := os.Getenv("NB_" + flagName); envValue != "" {
return envValue
}
return defaultValue
}
// resetSSHGlobals sets SSH globals to their default values
func resetSSHGlobals() {
port = sshserver.DefaultSSHPort
username = ""
host = ""
command = ""
localForwards = nil
remoteForwards = nil
strictHostKeyChecking = true
knownHostsFile = ""
identityFile = ""
}
// parseCustomSSHFlags extracts -L, -R flags and returns filtered args
func parseCustomSSHFlags(args []string) ([]string, []string, []string) {
var localForwardFlags []string
var remoteForwardFlags []string
var filteredArgs []string
for i := 0; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "-L") {
if arg == "-L" && i+1 < len(args) {
localForwardFlags = append(localForwardFlags, args[i+1])
i++
} else if len(arg) > 2 {
localForwardFlags = append(localForwardFlags, arg[2:])
}
} else if strings.HasPrefix(arg, "-R") {
if arg == "-R" && i+1 < len(args) {
remoteForwardFlags = append(remoteForwardFlags, args[i+1])
i++
} else if len(arg) > 2 {
remoteForwardFlags = append(remoteForwardFlags, arg[2:])
}
} else {
filteredArgs = append(filteredArgs, arg)
}
}
return filteredArgs, localForwardFlags, remoteForwardFlags
}
// extractGlobalFlags parses global flags that were passed before 'ssh' command
func extractGlobalFlags(args []string) {
sshPos := findSSHCommandPosition(args)
if sshPos == -1 {
return
}
globalArgs := args[:sshPos]
parseGlobalArgs(globalArgs)
}
// findSSHCommandPosition locates the 'ssh' command in the argument list
func findSSHCommandPosition(args []string) int {
for i, arg := range args {
if arg == "ssh" {
return i
}
}
return -1
}
const (
configFlag = "config"
logLevelFlag = "log-level"
logFileFlag = "log-file"
)
// parseGlobalArgs processes the global arguments and sets the corresponding variables
func parseGlobalArgs(globalArgs []string) {
flagHandlers := map[string]func(string){
configFlag: func(value string) { configPath = value },
logLevelFlag: func(value string) { logLevel = value },
logFileFlag: func(value string) { logFile = value },
}
shortFlags := map[string]string{
"c": configFlag,
"l": logLevelFlag,
}
for i := 0; i < len(globalArgs); i++ {
arg := globalArgs[i]
if handled, nextIndex := parseFlag(arg, globalArgs, i, flagHandlers, shortFlags); handled {
i = nextIndex
}
}
}
// parseFlag handles generic flag parsing for both long and short forms
func parseFlag(arg string, args []string, currentIndex int, flagHandlers map[string]func(string), shortFlags map[string]string) (bool, int) {
if parsedValue, found := parseEqualsFormat(arg, flagHandlers, shortFlags); found {
flagHandlers[parsedValue.flagName](parsedValue.value)
return true, currentIndex
}
if parsedValue, found := parseSpacedFormat(arg, args, currentIndex, flagHandlers, shortFlags); found {
flagHandlers[parsedValue.flagName](parsedValue.value)
return true, currentIndex + 1
}
return false, currentIndex
}
type parsedFlag struct {
flagName string
value string
}
// parseEqualsFormat handles --flag=value and -f=value formats
func parseEqualsFormat(arg string, flagHandlers map[string]func(string), shortFlags map[string]string) (parsedFlag, bool) {
if !strings.Contains(arg, "=") {
return parsedFlag{}, false
}
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return parsedFlag{}, false
}
if strings.HasPrefix(parts[0], "--") {
flagName := strings.TrimPrefix(parts[0], "--")
if _, exists := flagHandlers[flagName]; exists {
return parsedFlag{flagName: flagName, value: parts[1]}, true
}
}
if strings.HasPrefix(parts[0], "-") && len(parts[0]) == 2 {
shortFlag := strings.TrimPrefix(parts[0], "-")
if longFlag, exists := shortFlags[shortFlag]; exists {
if _, exists := flagHandlers[longFlag]; exists {
return parsedFlag{flagName: longFlag, value: parts[1]}, true
}
}
}
SetFlagsFromEnvVars(rootCmd)
SetFlagsFromEnvVars(cmd)
return parsedFlag{}, false
}
cmd.SetOut(cmd.OutOrStdout())
// parseSpacedFormat handles --flag value and -f value formats
func parseSpacedFormat(arg string, args []string, currentIndex int, flagHandlers map[string]func(string), shortFlags map[string]string) (parsedFlag, bool) {
if currentIndex+1 >= len(args) {
return parsedFlag{}, false
}
if err := util.InitLog(logLevel, "console"); err != nil {
return fmt.Errorf("init log: %w", err)
if strings.HasPrefix(arg, "--") {
flagName := strings.TrimPrefix(arg, "--")
if _, exists := flagHandlers[flagName]; exists {
return parsedFlag{flagName: flagName, value: args[currentIndex+1]}, true
}
}
ctx := internal.CtxInitState(cmd.Context())
config, err := internal.UpdateConfig(internal.ConfigInput{
ConfigPath: configPath,
})
if err != nil {
return fmt.Errorf("update config: %w", err)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
sshctx, cancel := context.WithCancel(ctx)
go func() {
if err := runSSH(sshctx, host, []byte(config.SSHKey), cmd); err != nil {
cmd.Printf("Error: %v\n", err)
os.Exit(1)
if strings.HasPrefix(arg, "-") && len(arg) == 2 {
shortFlag := strings.TrimPrefix(arg, "-")
if longFlag, exists := shortFlags[shortFlag]; exists {
if _, exists := flagHandlers[longFlag]; exists {
return parsedFlag{flagName: longFlag, value: args[currentIndex+1]}, true
}
cancel()
}()
select {
case <-sig:
cancel()
case <-sshctx.Done():
}
}
return nil
},
return parsedFlag{}, false
}
// createSSHFlagSet creates and configures the flag set for SSH command parsing
func createSSHFlagSet() (*flag.FlagSet, *int, *string, *string, *bool, *string, *string, *string, *string, *string) {
defaultConfigPath := getEnvOrDefault("CONFIG", configPath)
defaultLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
defaultLogFile := getEnvOrDefault("LOG_FILE", logFile)
fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError)
fs.SetOutput(nil)
portFlag := fs.Int("p", sshserver.DefaultSSHPort, "SSH port")
fs.Int("port", sshserver.DefaultSSHPort, "SSH port")
userFlag := fs.String("u", "", sshUsernameDesc)
fs.String("user", "", sshUsernameDesc)
loginFlag := fs.String("login", "", sshUsernameDesc+" (alias for --user)")
strictHostKeyCheckingFlag := fs.Bool("strict-host-key-checking", true, "Enable strict host key checking")
knownHostsFlag := fs.String("o", "", "Path to known_hosts file")
fs.String("known-hosts", "", "Path to known_hosts file")
identityFlag := fs.String("i", "", "Path to SSH private key file")
fs.String("identity", "", "Path to SSH private key file")
configFlag := fs.String("c", defaultConfigPath, "Netbird config file location")
fs.String("config", defaultConfigPath, "Netbird config file location")
logLevelFlag := fs.String("l", defaultLogLevel, "sets Netbird log level")
fs.String("log-level", defaultLogLevel, "sets Netbird log level")
logFileFlag := fs.String("log-file", defaultLogFile, "sets Netbird log path")
return fs, portFlag, userFlag, loginFlag, strictHostKeyCheckingFlag, knownHostsFlag, identityFlag, configFlag, logLevelFlag, logFileFlag
}
func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("host argument required")
return errors.New(hostArgumentRequired)
}
// Reset globals to defaults
port = nbssh.DefaultSSHPort
username = ""
host = ""
command = ""
resetSSHGlobals()
// Create a new FlagSet for parsing SSH-specific flags
fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError)
fs.SetOutput(nil) // Suppress error output
// Define SSH-specific flags
portFlag := fs.Int("p", nbssh.DefaultSSHPort, "SSH port")
fs.Int("port", nbssh.DefaultSSHPort, "SSH port")
userFlag := fs.String("u", "", "SSH username")
fs.String("user", "", "SSH username")
loginFlag := fs.String("login", "", "SSH username (alias for --user)")
// Parse flags until we hit the hostname (first non-flag argument)
err := fs.Parse(args)
if err != nil {
// If flag parsing fails, treat everything as hostname + command
// This handles cases like `ssh hostname ls -la` where `-la` should be part of the command
return parseHostnameAndCommand(args)
// Extract global flags that were passed before 'ssh' by checking original command line
if len(os.Args) > 2 {
extractGlobalFlags(os.Args[1:])
}
filteredArgs, localForwardFlags, remoteForwardFlags := parseCustomSSHFlags(args)
fs, portFlag, userFlag, loginFlag, strictHostKeyCheckingFlag, knownHostsFlag, identityFlag, configFlag, logLevelFlag, logFileFlag := createSSHFlagSet()
if err := fs.Parse(filteredArgs); err != nil {
return parseHostnameAndCommand(filteredArgs)
}
// Get the remaining args (hostname and command)
remaining := fs.Args()
if len(remaining) < 1 {
return errors.New("host argument required")
return errors.New(hostArgumentRequired)
}
// Set parsed values
@@ -131,12 +393,31 @@ func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error {
username = *loginFlag
}
strictHostKeyChecking = *strictHostKeyCheckingFlag
knownHostsFile = *knownHostsFlag
identityFile = *identityFlag
// Global flags were already extracted in extractGlobalFlags()
// Only override with SSH-specific flags if they were explicitly provided
if *configFlag != getEnvOrDefault("CONFIG", configPath) {
configPath = *configFlag
}
if *logLevelFlag != getEnvOrDefault("LOG_LEVEL", logLevel) {
logLevel = *logLevelFlag
}
if *logFileFlag != getEnvOrDefault("LOG_FILE", logFile) {
logFile = *logFileFlag
}
localForwards = localForwardFlags
remoteForwards = remoteForwardFlags
return parseHostnameAndCommand(remaining)
}
func parseHostnameAndCommand(args []string) error {
if len(args) < 1 {
return errors.New("host argument required")
return errors.New(hostArgumentRequired)
}
// Parse hostname (possibly with user@host format)
@@ -174,43 +455,221 @@ func parseHostnameAndCommand(args []string) error {
return nil
}
func runSSH(ctx context.Context, addr string, pemKey []byte, cmd *cobra.Command) error {
func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error {
target := fmt.Sprintf("%s:%d", addr, port)
c, err := nbssh.DialWithKey(ctx, target, username, pemKey)
var c *sshclient.Client
var err error
if strictHostKeyChecking {
c, err = sshclient.DialWithOptions(ctx, target, username, sshclient.DialOptions{
KnownHostsFile: knownHostsFile,
IdentityFile: identityFile,
DaemonAddr: daemonAddr,
})
} else {
c, err = sshclient.DialInsecure(ctx, target, username)
}
if err != nil {
cmd.Printf("Failed to connect to %s@%s\n", username, target)
cmd.Printf("\nTroubleshooting steps:\n")
cmd.Printf(" 1. Check peer connectivity: netbird status\n")
cmd.Printf(" 2. Verify SSH server is enabled on the peer\n")
cmd.Printf(" 3. Ensure correct hostname/IP is used\n\n")
cmd.Printf(" 3. Ensure correct hostname/IP is used\n")
if strictHostKeyChecking {
cmd.Printf(" 4. Try --strict-host-key-checking=false to bypass host key verification\n")
}
cmd.Printf("\n")
return fmt.Errorf("dial %s: %w", target, err)
}
sshCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
_ = c.Close()
<-sshCtx.Done()
if err := c.Close(); err != nil {
cmd.Printf("Error closing SSH connection: %v\n", err)
}
}()
if err := startPortForwarding(sshCtx, c, cmd); err != nil {
return fmt.Errorf("start port forwarding: %w", err)
}
if command != "" {
if err := c.ExecuteCommandWithIO(ctx, command); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
return err
return executeSSHCommand(sshCtx, c, command)
}
return openSSHTerminal(sshCtx, c)
}
// executeSSHCommand executes a command over SSH.
func executeSSHCommand(ctx context.Context, c *sshclient.Client, command string) error {
if err := c.ExecuteCommandWithIO(ctx, command); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
} else {
if err := c.OpenTerminal(ctx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
return err
return fmt.Errorf("execute command: %w", err)
}
return nil
}
// openSSHTerminal opens an interactive SSH terminal.
func openSSHTerminal(ctx context.Context, c *sshclient.Client) error {
if err := c.OpenTerminal(ctx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
return fmt.Errorf("open terminal: %w", err)
}
return nil
}
// startPortForwarding starts local and remote port forwarding based on command line flags
func startPortForwarding(ctx context.Context, c *sshclient.Client, cmd *cobra.Command) error {
for _, forward := range localForwards {
if err := parseAndStartLocalForward(ctx, c, forward, cmd); err != nil {
return fmt.Errorf("local port forward %s: %w", forward, err)
}
}
for _, forward := range remoteForwards {
if err := parseAndStartRemoteForward(ctx, c, forward, cmd); err != nil {
return fmt.Errorf("remote port forward %s: %w", forward, err)
}
}
return nil
}
func init() {
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", nbssh.DefaultSSHPort, "Remote SSH port")
sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", "SSH username")
sshCmd.PersistentFlags().StringVar(&username, "login", "", "SSH username (alias for --user)")
// parseAndStartLocalForward parses and starts a local port forward (-L)
func parseAndStartLocalForward(ctx context.Context, c *sshclient.Client, forward string, cmd *cobra.Command) error {
localAddr, remoteAddr, err := parsePortForwardSpec(forward)
if err != nil {
return err
}
cmd.Printf("Local port forwarding: %s -> %s\n", localAddr, remoteAddr)
go func() {
if err := c.LocalPortForward(ctx, localAddr, remoteAddr); err != nil && !errors.Is(err, context.Canceled) {
cmd.Printf("Local port forward error: %v\n", err)
}
}()
return nil
}
// parseAndStartRemoteForward parses and starts a remote port forward (-R)
func parseAndStartRemoteForward(ctx context.Context, c *sshclient.Client, forward string, cmd *cobra.Command) error {
remoteAddr, localAddr, err := parsePortForwardSpec(forward)
if err != nil {
return err
}
cmd.Printf("Remote port forwarding: %s -> %s\n", remoteAddr, localAddr)
go func() {
if err := c.RemotePortForward(ctx, remoteAddr, localAddr); err != nil && !errors.Is(err, context.Canceled) {
cmd.Printf("Remote port forward error: %v\n", err)
}
}()
return nil
}
// parsePortForwardSpec parses port forward specifications like "8080:localhost:80" or "[::1]:8080:localhost:80".
// Also supports Unix sockets like "8080:/tmp/socket" or "127.0.0.1:8080:/tmp/socket".
func parsePortForwardSpec(spec string) (string, string, error) {
// Support formats:
// port:host:hostport -> localhost:port -> host:hostport
// host:port:host:hostport -> host:port -> host:hostport
// [host]:port:host:hostport -> [host]:port -> host:hostport
// port:unix_socket_path -> localhost:port -> unix_socket_path
// host:port:unix_socket_path -> host:port -> unix_socket_path
if strings.HasPrefix(spec, "[") && strings.Contains(spec, "]:") {
return parseIPv6ForwardSpec(spec)
}
parts := strings.Split(spec, ":")
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid port forward specification: %s (expected format: [local_host:]local_port:remote_target)", spec)
}
switch len(parts) {
case 2:
return parseTwoPartForwardSpec(parts, spec)
case 3:
return parseThreePartForwardSpec(parts)
case 4:
return parseFourPartForwardSpec(parts)
default:
return "", "", fmt.Errorf("invalid port forward specification: %s", spec)
}
}
// parseTwoPartForwardSpec handles "port:unix_socket" format.
func parseTwoPartForwardSpec(parts []string, spec string) (string, string, error) {
if isUnixSocket(parts[1]) {
localAddr := "localhost:" + parts[0]
remoteAddr := parts[1]
return localAddr, remoteAddr, nil
}
return "", "", fmt.Errorf("invalid port forward specification: %s (expected format: [local_host:]local_port:remote_host:remote_port or [local_host:]local_port:unix_socket)", spec)
}
// parseThreePartForwardSpec handles "port:host:hostport" or "host:port:unix_socket" formats.
func parseThreePartForwardSpec(parts []string) (string, string, error) {
if isUnixSocket(parts[2]) {
localHost := normalizeLocalHost(parts[0])
localAddr := localHost + ":" + parts[1]
remoteAddr := parts[2]
return localAddr, remoteAddr, nil
}
localAddr := "localhost:" + parts[0]
remoteAddr := parts[1] + ":" + parts[2]
return localAddr, remoteAddr, nil
}
// parseFourPartForwardSpec handles "host:port:host:hostport" format.
func parseFourPartForwardSpec(parts []string) (string, string, error) {
localHost := normalizeLocalHost(parts[0])
localAddr := localHost + ":" + parts[1]
remoteAddr := parts[2] + ":" + parts[3]
return localAddr, remoteAddr, nil
}
// parseIPv6ForwardSpec handles "[host]:port:host:hostport" format.
func parseIPv6ForwardSpec(spec string) (string, string, error) {
idx := strings.Index(spec, "]:")
if idx == -1 {
return "", "", fmt.Errorf("invalid IPv6 port forward specification: %s", spec)
}
ipv6Host := spec[:idx+1]
remaining := spec[idx+2:]
parts := strings.Split(remaining, ":")
if len(parts) != 3 {
return "", "", fmt.Errorf("invalid IPv6 port forward specification: %s (expected [ipv6]:port:host:hostport)", spec)
}
localAddr := ipv6Host + ":" + parts[0]
remoteAddr := parts[1] + ":" + parts[2]
return localAddr, remoteAddr, nil
}
// isUnixSocket checks if a path is a Unix socket path.
func isUnixSocket(path string) bool {
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./")
}
// normalizeLocalHost converts "*" to "0.0.0.0" for binding to all interfaces.
func normalizeLocalHost(host string) string {
if host == "*" {
return "0.0.0.0"
}
return host
}

View File

@@ -0,0 +1,74 @@
//go:build unix
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
)
var (
sshExecUID uint32
sshExecGID uint32
sshExecGroups []uint
sshExecWorkingDir string
sshExecShell string
sshExecCommand string
sshExecPTY bool
)
// sshExecCmd represents the hidden ssh exec subcommand for privilege dropping
var sshExecCmd = &cobra.Command{
Use: "exec",
Short: "Internal SSH execution with privilege dropping (hidden)",
Hidden: true,
RunE: runSSHExec,
}
func init() {
sshExecCmd.Flags().Uint32Var(&sshExecUID, "uid", 0, "Target user ID")
sshExecCmd.Flags().Uint32Var(&sshExecGID, "gid", 0, "Target group ID")
sshExecCmd.Flags().UintSliceVar(&sshExecGroups, "groups", nil, "Supplementary group IDs (can be repeated)")
sshExecCmd.Flags().StringVar(&sshExecWorkingDir, "working-dir", "", "Working directory")
sshExecCmd.Flags().StringVar(&sshExecShell, "shell", "/bin/sh", "Shell to execute")
sshExecCmd.Flags().BoolVar(&sshExecPTY, "pty", false, "Request PTY (will fail as executor doesn't support PTY)")
sshExecCmd.Flags().StringVar(&sshExecCommand, "cmd", "", "Command to execute")
if err := sshExecCmd.MarkFlagRequired("uid"); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to mark uid flag as required: %v\n", err)
os.Exit(1)
}
if err := sshExecCmd.MarkFlagRequired("gid"); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to mark gid flag as required: %v\n", err)
os.Exit(1)
}
sshCmd.AddCommand(sshExecCmd)
}
// runSSHExec handles the SSH exec subcommand execution.
func runSSHExec(cmd *cobra.Command, _ []string) error {
privilegeDropper := sshserver.NewPrivilegeDropper()
var groups []uint32
for _, groupInt := range sshExecGroups {
groups = append(groups, uint32(groupInt))
}
config := sshserver.ExecutorConfig{
UID: sshExecUID,
GID: sshExecGID,
Groups: groups,
WorkingDir: sshExecWorkingDir,
Shell: sshExecShell,
Command: sshExecCommand,
PTY: sshExecPTY,
}
privilegeDropper.ExecuteWithPrivilegeDrop(cmd.Context(), config)
return nil
}

View File

@@ -0,0 +1,94 @@
//go:build unix
package cmd
import (
"errors"
"io"
"os"
"github.com/pkg/sftp"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
)
var (
sftpUID uint32
sftpGID uint32
sftpGroupsInt []uint
sftpWorkingDir string
)
var sshSftpCmd = &cobra.Command{
Use: "sftp",
Short: "SFTP server with privilege dropping (internal use)",
Hidden: true,
RunE: sftpMain,
}
func init() {
sshSftpCmd.Flags().Uint32Var(&sftpUID, "uid", 0, "Target user ID")
sshSftpCmd.Flags().Uint32Var(&sftpGID, "gid", 0, "Target group ID")
sshSftpCmd.Flags().UintSliceVar(&sftpGroupsInt, "groups", nil, "Supplementary group IDs (can be repeated)")
sshSftpCmd.Flags().StringVar(&sftpWorkingDir, "working-dir", "", "Working directory")
}
func sftpMain(cmd *cobra.Command, _ []string) error {
privilegeDropper := sshserver.NewPrivilegeDropper()
var groups []uint32
for _, groupInt := range sftpGroupsInt {
groups = append(groups, uint32(groupInt))
}
config := sshserver.ExecutorConfig{
UID: sftpUID,
GID: sftpGID,
Groups: groups,
WorkingDir: sftpWorkingDir,
Shell: "",
Command: "",
}
log.Tracef("dropping privileges for SFTP to UID=%d, GID=%d, groups=%v", config.UID, config.GID, config.Groups)
if err := privilegeDropper.DropPrivileges(config.UID, config.GID, config.Groups); err != nil {
cmd.PrintErrf("privilege drop failed: %v\n", err)
os.Exit(sshserver.ExitCodePrivilegeDropFail)
}
if config.WorkingDir != "" {
if err := os.Chdir(config.WorkingDir); err != nil {
cmd.PrintErrf("failed to change to working directory %s: %v\n", config.WorkingDir, err)
}
}
sftpServer, err := sftp.NewServer(struct {
io.Reader
io.WriteCloser
}{
Reader: os.Stdin,
WriteCloser: os.Stdout,
})
if err != nil {
cmd.PrintErrf("SFTP server creation failed: %v\n", err)
os.Exit(sshserver.ExitCodeShellExecFail)
}
defer func() {
if err := sftpServer.Close(); err != nil {
cmd.PrintErrf("SFTP server close error: %v\n", err)
}
}()
log.Tracef("starting SFTP server with dropped privileges")
if err := sftpServer.Serve(); err != nil && !errors.Is(err, io.EOF) {
cmd.PrintErrf("SFTP server error: %v\n", err)
os.Exit(sshserver.ExitCodeShellExecFail)
}
os.Exit(sshserver.ExitCodeSuccess)
return nil
}

View File

@@ -0,0 +1,94 @@
//go:build windows
package cmd
import (
"errors"
"fmt"
"io"
"os"
"os/user"
"github.com/pkg/sftp"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
)
var (
sftpWorkingDir string
windowsUsername string
windowsDomain string
)
var sshSftpCmd = &cobra.Command{
Use: "sftp",
Short: "SFTP server with user switching for Windows (internal use)",
Hidden: true,
RunE: sftpMain,
}
func init() {
sshSftpCmd.Flags().StringVar(&sftpWorkingDir, "working-dir", "", "Working directory")
sshSftpCmd.Flags().StringVar(&windowsUsername, "windows-username", "", "Windows username for user switching")
sshSftpCmd.Flags().StringVar(&windowsDomain, "windows-domain", "", "Windows domain for user switching")
}
func sftpMain(cmd *cobra.Command, _ []string) error {
return sftpMainDirect(cmd)
}
func sftpMainDirect(cmd *cobra.Command) error {
currentUser, err := user.Current()
if err != nil {
cmd.PrintErrf("failed to get current user: %v\n", err)
os.Exit(sshserver.ExitCodeValidationFail)
}
if windowsUsername != "" {
expectedUsername := windowsUsername
if windowsDomain != "" {
expectedUsername = fmt.Sprintf(`%s\%s`, windowsDomain, windowsUsername)
}
if currentUser.Username != expectedUsername && currentUser.Username != windowsUsername {
cmd.PrintErrf("user switching failed\n")
os.Exit(sshserver.ExitCodeValidationFail)
}
}
log.Debugf("SFTP process running as: %s (UID: %s, Name: %s)", currentUser.Username, currentUser.Uid, currentUser.Name)
if sftpWorkingDir != "" {
if err := os.Chdir(sftpWorkingDir); err != nil {
cmd.PrintErrf("failed to change to working directory %s: %v\n", sftpWorkingDir, err)
}
}
sftpServer, err := sftp.NewServer(struct {
io.Reader
io.WriteCloser
}{
Reader: os.Stdin,
WriteCloser: os.Stdout,
})
if err != nil {
cmd.PrintErrf("SFTP server creation failed: %v\n", err)
os.Exit(sshserver.ExitCodeShellExecFail)
}
defer func() {
if err := sftpServer.Close(); err != nil {
log.Debugf("SFTP server close error: %v", err)
}
}()
log.Debugf("starting SFTP server")
if err := sftpServer.Serve(); err != nil && !errors.Is(err, io.EOF) {
cmd.PrintErrf("SFTP server error: %v\n", err)
os.Exit(sshserver.ExitCodeShellExecFail)
}
os.Exit(sshserver.ExitCodeSuccess)
return nil
}

View File

@@ -22,7 +22,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
args: []string{"hostname"},
expectedHost: "hostname",
expectedUser: "",
expectedPort: 22022,
expectedPort: 22,
expectedCmd: "",
},
{
@@ -30,7 +30,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
args: []string{"user@hostname"},
expectedHost: "hostname",
expectedUser: "user",
expectedPort: 22022,
expectedPort: 22,
expectedCmd: "",
},
{
@@ -38,7 +38,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
args: []string{"hostname", "echo", "hello"},
expectedHost: "hostname",
expectedUser: "",
expectedPort: 22022,
expectedPort: 22,
expectedCmd: "echo hello",
},
{
@@ -46,7 +46,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
args: []string{"hostname", "ls", "-la", "/tmp"},
expectedHost: "hostname",
expectedUser: "",
expectedPort: 22022,
expectedPort: 22,
expectedCmd: "ls -la /tmp",
},
{
@@ -54,7 +54,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
args: []string{"hostname", "--", "ls", "-la"},
expectedHost: "hostname",
expectedUser: "",
expectedPort: 22022,
expectedPort: 22,
expectedCmd: "-- ls -la",
},
}
@@ -64,7 +64,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22022
port = 22
command = ""
// Mock command for testing
@@ -78,7 +78,7 @@ func TestSSHCommand_FlagParsing(t *testing.T) {
return
}
require.NoError(t, err)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedHost, host, "host mismatch")
if tt.expectedUser != "" {
assert.Equal(t, tt.expectedUser, username, "username mismatch")
@@ -128,12 +128,12 @@ func TestSSHCommand_FlagConflictPrevention(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22022
port = 22
command = ""
cmd := sshCmd
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
require.NoError(t, err)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedCmd, command, tt.description)
})
@@ -192,12 +192,12 @@ func TestSSHCommand_NonInteractiveExecution(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22022
port = 22
command = ""
cmd := sshCmd
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
require.NoError(t, err)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedCmd, command, tt.description)
@@ -258,7 +258,7 @@ func TestSSHCommand_FlagHandling(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22022
port = 22
command = ""
cmd := sshCmd
@@ -269,7 +269,7 @@ func TestSSHCommand_FlagHandling(t *testing.T) {
return
}
require.NoError(t, err)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedHost, host, "host mismatch")
assert.Equal(t, tt.expectedCmd, command, tt.description)
})
@@ -318,7 +318,7 @@ func TestSSHCommand_RegressionFlagParsing(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22022
port = 22
command = ""
cmd := sshCmd
@@ -329,7 +329,7 @@ func TestSSHCommand_RegressionFlagParsing(t *testing.T) {
return
}
require.NoError(t, err)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedHost, host, "host mismatch")
assert.Equal(t, tt.expectedCmd, command, tt.description)
@@ -340,3 +340,330 @@ func TestSSHCommand_RegressionFlagParsing(t *testing.T) {
})
}
}
func TestSSHCommand_PortForwardingFlagParsing(t *testing.T) {
tests := []struct {
name string
args []string
expectedHost string
expectedLocal []string
expectedRemote []string
expectError bool
description string
}{
{
name: "local port forwarding -L",
args: []string{"-L", "8080:localhost:80", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80"},
expectedRemote: []string{},
expectError: false,
description: "Single -L flag should be parsed correctly",
},
{
name: "remote port forwarding -R",
args: []string{"-R", "8080:localhost:80", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{},
expectedRemote: []string{"8080:localhost:80"},
expectError: false,
description: "Single -R flag should be parsed correctly",
},
{
name: "multiple local port forwards",
args: []string{"-L", "8080:localhost:80", "-L", "9090:localhost:443", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80", "9090:localhost:443"},
expectedRemote: []string{},
expectError: false,
description: "Multiple -L flags should be parsed correctly",
},
{
name: "multiple remote port forwards",
args: []string{"-R", "8080:localhost:80", "-R", "9090:localhost:443", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{},
expectedRemote: []string{"8080:localhost:80", "9090:localhost:443"},
expectError: false,
description: "Multiple -R flags should be parsed correctly",
},
{
name: "mixed local and remote forwards",
args: []string{"-L", "8080:localhost:80", "-R", "9090:localhost:443", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80"},
expectedRemote: []string{"9090:localhost:443"},
expectError: false,
description: "Mixed -L and -R flags should be parsed correctly",
},
{
name: "port forwarding with bind address",
args: []string{"-L", "127.0.0.1:8080:localhost:80", "hostname"},
expectedHost: "hostname",
expectedLocal: []string{"127.0.0.1:8080:localhost:80"},
expectedRemote: []string{},
expectError: false,
description: "Port forwarding with bind address should work",
},
{
name: "port forwarding with command",
args: []string{"-L", "8080:localhost:80", "hostname", "ls", "-la"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80"},
expectedRemote: []string{},
expectError: false,
description: "Port forwarding with command should work",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22
command = ""
localForwards = nil
remoteForwards = nil
cmd := sshCmd
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedHost, host, "host mismatch")
// Handle nil vs empty slice comparison
if len(tt.expectedLocal) == 0 {
assert.True(t, len(localForwards) == 0, tt.description+" - local forwards should be empty")
} else {
assert.Equal(t, tt.expectedLocal, localForwards, tt.description+" - local forwards")
}
if len(tt.expectedRemote) == 0 {
assert.True(t, len(remoteForwards) == 0, tt.description+" - remote forwards should be empty")
} else {
assert.Equal(t, tt.expectedRemote, remoteForwards, tt.description+" - remote forwards")
}
})
}
}
func TestParsePortForward(t *testing.T) {
tests := []struct {
name string
spec string
expectedLocal string
expectedRemote string
expectError bool
description string
}{
{
name: "simple port forward",
spec: "8080:localhost:80",
expectedLocal: "localhost:8080",
expectedRemote: "localhost:80",
expectError: false,
description: "Simple port:host:port format should work",
},
{
name: "port forward with bind address",
spec: "127.0.0.1:8080:localhost:80",
expectedLocal: "127.0.0.1:8080",
expectedRemote: "localhost:80",
expectError: false,
description: "bind_address:port:host:port format should work",
},
{
name: "port forward to different host",
spec: "8080:example.com:443",
expectedLocal: "localhost:8080",
expectedRemote: "example.com:443",
expectError: false,
description: "Forwarding to different host should work",
},
{
name: "port forward with IPv6 (needs bracket support)",
spec: "::1:8080:localhost:80",
expectError: true,
description: "IPv6 without brackets fails as expected (feature to implement)",
},
{
name: "invalid format - too few parts",
spec: "8080:localhost",
expectError: true,
description: "Invalid format with too few parts should fail",
},
{
name: "invalid format - too many parts",
spec: "127.0.0.1:8080:localhost:80:extra",
expectError: true,
description: "Invalid format with too many parts should fail",
},
{
name: "empty spec",
spec: "",
expectError: true,
description: "Empty spec should fail",
},
{
name: "unix socket local forward",
spec: "8080:/tmp/socket",
expectedLocal: "localhost:8080",
expectedRemote: "/tmp/socket",
expectError: false,
description: "Unix socket forwarding should work",
},
{
name: "unix socket with bind address",
spec: "127.0.0.1:8080:/tmp/socket",
expectedLocal: "127.0.0.1:8080",
expectedRemote: "/tmp/socket",
expectError: false,
description: "Unix socket with bind address should work",
},
{
name: "wildcard bind all interfaces",
spec: "*:8080:localhost:80",
expectedLocal: "0.0.0.0:8080",
expectedRemote: "localhost:80",
expectError: false,
description: "Wildcard * should bind to all interfaces (0.0.0.0)",
},
{
name: "wildcard for port only",
spec: "8080:*:80",
expectedLocal: "localhost:8080",
expectedRemote: "*:80",
expectError: false,
description: "Wildcard in remote host should be preserved",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
localAddr, remoteAddr, err := parsePortForwardSpec(tt.spec)
if tt.expectError {
assert.Error(t, err, tt.description)
return
}
require.NoError(t, err, tt.description)
assert.Equal(t, tt.expectedLocal, localAddr, tt.description+" - local address")
assert.Equal(t, tt.expectedRemote, remoteAddr, tt.description+" - remote address")
})
}
}
func TestSSHCommand_IntegrationPortForwarding(t *testing.T) {
// Integration test for port forwarding with the actual SSH command implementation
tests := []struct {
name string
args []string
expectedHost string
expectedLocal []string
expectedRemote []string
expectedCmd string
description string
}{
{
name: "local forward with command",
args: []string{"-L", "8080:localhost:80", "hostname", "echo", "test"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80"},
expectedRemote: []string{},
expectedCmd: "echo test",
description: "Local forwarding should work with commands",
},
{
name: "remote forward with command",
args: []string{"-R", "8080:localhost:80", "hostname", "ls", "-la"},
expectedHost: "hostname",
expectedLocal: []string{},
expectedRemote: []string{"8080:localhost:80"},
expectedCmd: "ls -la",
description: "Remote forwarding should work with commands",
},
{
name: "multiple forwards with user and command",
args: []string{"-L", "8080:localhost:80", "-R", "9090:localhost:443", "user@hostname", "ps", "aux"},
expectedHost: "hostname",
expectedLocal: []string{"8080:localhost:80"},
expectedRemote: []string{"9090:localhost:443"},
expectedCmd: "ps aux",
description: "Complex case with multiple forwards, user, and command",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset global variables
host = ""
username = ""
port = 22
command = ""
localForwards = nil
remoteForwards = nil
cmd := sshCmd
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
require.NoError(t, err, "SSH args validation should succeed for valid input")
assert.Equal(t, tt.expectedHost, host, "host mismatch")
// Handle nil vs empty slice comparison
if len(tt.expectedLocal) == 0 {
assert.True(t, len(localForwards) == 0, tt.description+" - local forwards should be empty")
} else {
assert.Equal(t, tt.expectedLocal, localForwards, tt.description+" - local forwards")
}
if len(tt.expectedRemote) == 0 {
assert.True(t, len(remoteForwards) == 0, tt.description+" - remote forwards should be empty")
} else {
assert.Equal(t, tt.expectedRemote, remoteForwards, tt.description+" - remote forwards")
}
assert.Equal(t, tt.expectedCmd, command, tt.description+" - command")
})
}
}
func TestSSHCommand_ParameterIsolation(t *testing.T) {
tests := []struct {
name string
args []string
expectedCmd string
}{
{
name: "cmd flag passed as command",
args: []string{"hostname", "--cmd", "echo test"},
expectedCmd: "--cmd echo test",
},
{
name: "uid flag passed as command",
args: []string{"hostname", "--uid", "1000"},
expectedCmd: "--uid 1000",
},
{
name: "shell flag passed as command",
args: []string{"hostname", "--shell", "/bin/bash"},
expectedCmd: "--shell /bin/bash",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host = ""
username = ""
port = 22
command = ""
err := validateSSHArgsWithoutFlagParsing(sshCmd, tt.args)
require.NoError(t, err)
assert.Equal(t, "hostname", host)
assert.Equal(t, tt.expectedCmd, command)
})
}
}

View File

@@ -258,6 +258,22 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command) (*interna
ic.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
ic.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
ic.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
return nil, err
@@ -352,6 +368,22 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
loginRequest.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
loginRequest.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableAutoConnectFlag).Changed {
loginRequest.DisableAutoConnect = &autoConnectDisabled
}

View File

@@ -260,6 +260,22 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
return m.router.UpdateSet(set, prefixes)
}
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort)
}
// RemoveInboundDNAT removes inbound DNAT rule
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
}
func getConntrackEstablished() []string {
return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}
}

View File

@@ -880,6 +880,54 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
return nberrors.FormatErrorOrNil(merr)
}
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
if _, exists := r.rules[ruleID]; exists {
return nil
}
dnatRule := []string{
"-i", r.wgIface.Name(),
"-p", strings.ToLower(string(protocol)),
"--dport", strconv.Itoa(int(sourcePort)),
"-d", localAddr.String(),
"-m", "addrtype", "--dst-type", "LOCAL",
"-j", "DNAT",
"--to-destination", ":" + strconv.Itoa(int(targetPort)),
}
ruleInfo := ruleInfo{
table: tableNat,
chain: chainRTRDR,
rule: dnatRule,
}
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
return fmt.Errorf("add inbound DNAT rule: %w", err)
}
r.rules[ruleID] = ruleInfo.rule
r.updateState()
return nil
}
// RemoveInboundDNAT removes inbound DNAT rule
func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
if dnatRule, exists := r.rules[ruleID]; exists {
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
return fmt.Errorf("delete inbound DNAT rule: %w", err)
}
delete(r.rules, ruleID)
}
r.updateState()
return nil
}
func applyPort(flag string, port *firewall.Port) []string {
if port == nil {
return nil

View File

@@ -151,14 +151,20 @@ type Manager interface {
DisableRouting() error
// AddDNATRule adds a DNAT rule
// AddDNATRule adds outbound DNAT rule for forwarding external traffic to the NetBird network.
AddDNATRule(ForwardRule) (Rule, error)
// DeleteDNATRule deletes a DNAT rule
// DeleteDNATRule deletes the outbound DNAT rule.
DeleteDNATRule(Rule) error
// UpdateSet updates the set with the given prefixes
UpdateSet(hash Set, prefixes []netip.Prefix) error
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
AddInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
// RemoveInboundDNAT removes inbound DNAT rule
RemoveInboundDNAT(localAddr netip.Addr, protocol Protocol, sourcePort, targetPort uint16) error
}
func GenKey(format string, pair RouterPair) string {

View File

@@ -376,6 +376,22 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
return m.router.UpdateSet(set, prefixes)
}
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.router.AddInboundDNAT(localAddr, protocol, sourcePort, targetPort)
}
// RemoveInboundDNAT removes inbound DNAT rule
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.router.RemoveInboundDNAT(localAddr, protocol, sourcePort, targetPort)
}
func (m *Manager) createWorkTable() (*nftables.Table, error) {
tables, err := m.rConn.ListTablesOfFamily(nftables.TableFamilyIPv4)
if err != nil {

View File

@@ -1350,6 +1350,103 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
return nil
}
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services
func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
if _, exists := r.rules[ruleID]; exists {
return nil
}
protoNum, err := protoToInt(protocol)
if err != nil {
return fmt.Errorf("convert protocol to number: %w", err)
}
exprs := []expr.Any{
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: ifname(r.wgIface.Name()),
},
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 2},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 2,
Data: []byte{protoNum},
},
&expr.Payload{
DestRegister: 3,
Base: expr.PayloadBaseTransportHeader,
Offset: 2,
Len: 2,
},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 3,
Data: binaryutil.BigEndian.PutUint16(sourcePort),
},
}
exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...)
exprs = append(exprs,
&expr.Immediate{
Register: 1,
Data: localAddr.AsSlice(),
},
&expr.Immediate{
Register: 2,
Data: binaryutil.BigEndian.PutUint16(targetPort),
},
&expr.NAT{
Type: expr.NATTypeDestNAT,
Family: uint32(nftables.TableFamilyIPv4),
RegAddrMin: 1,
RegProtoMin: 2,
RegProtoMax: 0,
},
)
dnatRule := &nftables.Rule{
Table: r.workTable,
Chain: r.chains[chainNameRoutingRdr],
Exprs: exprs,
UserData: []byte(ruleID),
}
r.conn.AddRule(dnatRule)
if err := r.conn.Flush(); err != nil {
return fmt.Errorf("add inbound DNAT rule: %w", err)
}
r.rules[ruleID] = dnatRule
return nil
}
// RemoveInboundDNAT removes inbound DNAT rule
func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
if err := r.refreshRulesMap(); err != nil {
return fmt.Errorf(refreshRulesMapError, err)
}
ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort)
if rule, exists := r.rules[ruleID]; exists {
if err := r.conn.DelRule(rule); err != nil {
return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err)
}
if err := r.conn.Flush(); err != nil {
return fmt.Errorf("flush delete inbound DNAT rule: %w", err)
}
delete(r.rules, ruleID)
}
return nil
}
// applyNetwork generates nftables expressions for networks (CIDR) or sets
func (r *router) applyNetwork(
network firewall.Network,

View File

@@ -29,6 +29,12 @@ import (
const layerTypeAll = 0
// serviceKey represents a protocol/port combination for netstack service registry
type serviceKey struct {
protocol gopacket.LayerType
port uint16
}
const (
// EnvDisableConntrack disables the stateful filter, replies to outbound traffic won't be allowed.
EnvDisableConntrack = "NB_DISABLE_CONNTRACK"
@@ -110,6 +116,15 @@ type Manager struct {
dnatMappings map[netip.Addr]netip.Addr
dnatMutex sync.RWMutex
dnatBiMap *biDNATMap
// Port-specific DNAT for SSH redirection
portDNATEnabled atomic.Bool
portDNATMap *portDNATMap
portDNATMutex sync.RWMutex
portNATTracker *portNATTracker
netstackServices map[serviceKey]struct{}
netstackServiceMutex sync.RWMutex
}
// decoder for packages
@@ -196,6 +211,9 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
netstack: netstack.IsEnabled(),
localForwarding: enableLocalForwarding,
dnatMappings: make(map[netip.Addr]netip.Addr),
portDNATMap: &portDNATMap{rules: make([]portDNATRule, 0)},
portNATTracker: newPortNATTracker(),
netstackServices: make(map[serviceKey]struct{}),
}
m.routingEnabled.Store(false)
@@ -333,18 +351,22 @@ func (m *Manager) initForwarder() error {
return nil
}
// Init initializes the firewall manager with state manager.
func (m *Manager) Init(*statemanager.Manager) error {
return nil
}
// IsServerRouteSupported returns whether server routes are supported.
func (m *Manager) IsServerRouteSupported() bool {
return true
}
// IsStateful returns whether the firewall manager tracks connection state.
func (m *Manager) IsStateful() bool {
return m.stateful
}
// AddNatRule adds a routing firewall rule for NAT translation.
func (m *Manager) AddNatRule(pair firewall.RouterPair) error {
if m.nativeRouter.Load() && m.nativeFirewall != nil {
return m.nativeFirewall.AddNatRule(pair)
@@ -611,6 +633,7 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool {
m.trackOutbound(d, srcIP, dstIP, size)
m.translateOutboundDNAT(packetData, d)
m.translateOutboundPortReverse(packetData, d)
return false
}
@@ -738,6 +761,15 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return false
}
if translated := m.translateInboundPortDNAT(packetData, d); translated {
// Re-decode after port DNAT translation to update port information
if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil {
m.logger.Error("Failed to re-decode packet after port DNAT: %v", err)
return true
}
srcIP, dstIP = m.extractIPs(d)
}
if translated := m.translateInboundReverse(packetData, d); translated {
// Re-decode after translation to get original addresses
if err := d.parser.DecodeLayers(packetData, &d.decoded); err != nil {
@@ -786,9 +818,7 @@ func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packet
return true
}
// If requested we pass local traffic to internal interfaces to the forwarder.
// netstack doesn't have an interface to forward packets to the native stack so we always need to use the forwarder.
if m.localForwarding && (m.netstack || dstIP != m.wgIface.Address().IP) {
if m.shouldForward(d, dstIP) {
return m.handleForwardedLocalTraffic(packetData)
}
@@ -1215,3 +1245,95 @@ func (m *Manager) DisableRouting() error {
return nil
}
// RegisterNetstackService registers a service as listening on the netstack for the given protocol and port
func (m *Manager) RegisterNetstackService(protocol nftypes.Protocol, port uint16) {
m.netstackServiceMutex.Lock()
defer m.netstackServiceMutex.Unlock()
layerType := m.protocolToLayerType(protocol)
key := serviceKey{protocol: layerType, port: port}
m.netstackServices[key] = struct{}{}
m.logger.Debug("RegisterNetstackService: registered %s:%d (layerType=%s)", protocol, port, layerType)
m.logger.Debug("RegisterNetstackService: current registry size: %d", len(m.netstackServices))
}
// UnregisterNetstackService removes a service from the netstack registry
func (m *Manager) UnregisterNetstackService(protocol nftypes.Protocol, port uint16) {
m.netstackServiceMutex.Lock()
defer m.netstackServiceMutex.Unlock()
layerType := m.protocolToLayerType(protocol)
key := serviceKey{protocol: layerType, port: port}
delete(m.netstackServices, key)
m.logger.Debug("Unregistered netstack service on protocol %s port %d", protocol, port)
}
// isNetstackService checks if a service is registered as listening on netstack for the given protocol and port
func (m *Manager) isNetstackService(layerType gopacket.LayerType, port uint16) bool {
m.netstackServiceMutex.RLock()
defer m.netstackServiceMutex.RUnlock()
key := serviceKey{protocol: layerType, port: port}
_, exists := m.netstackServices[key]
return exists
}
// protocolToLayerType converts nftypes.Protocol to gopacket.LayerType for internal use
func (m *Manager) protocolToLayerType(protocol nftypes.Protocol) gopacket.LayerType {
switch protocol {
case nftypes.TCP:
return layers.LayerTypeTCP
case nftypes.UDP:
return layers.LayerTypeUDP
case nftypes.ICMP:
return layers.LayerTypeICMPv4
default:
return gopacket.LayerType(0) // Invalid/unknown
}
}
// shouldForward determines if a packet should be forwarded to the forwarder.
// The forwarder handles routing packets to the native OS network stack.
// Returns true if packet should go to the forwarder, false if it should go to netstack listeners or the native stack directly.
func (m *Manager) shouldForward(d *decoder, dstIP netip.Addr) bool {
// not enabled, never forward
if !m.localForwarding {
return false
}
// netstack always needs to forward because it's lacking a native interface
// exception for registered netstack services, those should go to netstack listeners
if m.netstack {
return !m.hasMatchingNetstackService(d)
}
// traffic to our other local interfaces (not NetBird IP) - always forward
if dstIP != m.wgIface.Address().IP {
return true
}
// traffic to our NetBird IP, not netstack mode - send to netstack listeners
return false
}
// hasMatchingNetstackService checks if there's a registered netstack service for this packet
func (m *Manager) hasMatchingNetstackService(d *decoder) bool {
if len(d.decoded) < 2 {
return false
}
var dstPort uint16
switch d.decoded[1] {
case layers.LayerTypeTCP:
dstPort = uint16(d.tcp.DstPort)
case layers.LayerTypeUDP:
dstPort = uint16(d.udp.DstPort)
default:
return false
}
key := serviceKey{protocol: d.decoded[1], port: dstPort}
m.netstackServiceMutex.RLock()
_, exists := m.netstackServices[key]
m.netstackServiceMutex.RUnlock()
return exists
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/netflow"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
"github.com/netbirdio/netbird/management/domain"
)
@@ -896,3 +897,138 @@ func TestUpdateSetDeduplication(t *testing.T) {
require.Equal(t, tc.expected, isAllowed, tc.desc)
}
}
func TestShouldForward(t *testing.T) {
// Set up test addresses
wgIP := netip.MustParseAddr("100.10.0.1")
otherIP := netip.MustParseAddr("100.10.0.2")
// Create test manager with mock interface
ifaceMock := &IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}
// Set the mock to return our test WG IP
ifaceMock.AddressFunc = func() wgaddr.Address {
return wgaddr.Address{IP: wgIP, Network: netip.PrefixFrom(wgIP, 24)}
}
manager, err := Create(ifaceMock, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Helper to create decoder with TCP packet
createTCPDecoder := func(dstPort uint16) *decoder {
ipv4 := &layers.IPv4{
Version: 4,
Protocol: layers.IPProtocolTCP,
SrcIP: net.ParseIP("192.168.1.100"),
DstIP: wgIP.AsSlice(),
}
tcp := &layers.TCP{
SrcPort: 54321,
DstPort: layers.TCPPort(dstPort),
}
err := tcp.SetNetworkLayerForChecksum(ipv4)
require.NoError(t, err)
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
err = gopacket.SerializeLayers(buf, opts, ipv4, tcp, gopacket.Payload("test"))
require.NoError(t, err)
d := &decoder{
decoded: []gopacket.LayerType{},
}
d.parser = gopacket.NewDecodingLayerParser(
layers.LayerTypeIPv4,
&d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp,
)
d.parser.IgnoreUnsupported = true
err = d.parser.DecodeLayers(buf.Bytes(), &d.decoded)
require.NoError(t, err)
return d
}
tests := []struct {
name string
localForwarding bool
netstack bool
dstIP netip.Addr
serviceRegistered bool
servicePort uint16
expected bool
description string
}{
{
name: "no local forwarding",
localForwarding: false,
netstack: true,
dstIP: wgIP,
expected: false,
description: "should never forward when local forwarding disabled",
},
{
name: "traffic to other local interface",
localForwarding: true,
netstack: false,
dstIP: otherIP,
expected: true,
description: "should forward traffic to our other local interfaces (not NetBird IP)",
},
{
name: "traffic to NetBird IP, no netstack",
localForwarding: true,
netstack: false,
dstIP: wgIP,
expected: false,
description: "should send to netstack listeners (final return false path)",
},
{
name: "traffic to our IP, netstack mode, no service",
localForwarding: true,
netstack: true,
dstIP: wgIP,
expected: true,
description: "should forward when in netstack mode with no matching service",
},
{
name: "traffic to our IP, netstack mode, with service",
localForwarding: true,
netstack: true,
dstIP: wgIP,
serviceRegistered: true,
servicePort: 22,
expected: false,
description: "should send to netstack listeners when service is registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Configure manager
manager.localForwarding = tt.localForwarding
manager.netstack = tt.netstack
// Register service if needed
if tt.serviceRegistered {
manager.RegisterNetstackService(nftypes.TCP, tt.servicePort)
defer manager.UnregisterNetstackService(nftypes.TCP, tt.servicePort)
}
// Create decoder for the test
decoder := createTCPDecoder(tt.servicePort)
if !tt.serviceRegistered {
decoder = createTCPDecoder(8080) // Use non-registered port
}
// Test the method
result := manager.shouldForward(decoder, tt.dstIP)
require.Equal(t, tt.expected, result, tt.description)
})
}
}

View File

@@ -5,7 +5,10 @@ import (
"errors"
"fmt"
"net/netip"
"sync"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
@@ -13,6 +16,12 @@ import (
var ErrIPv4Only = errors.New("only IPv4 is supported for DNAT")
const (
invalidIPHeaderLengthMsg = "invalid IP header length"
errRewriteTCPDestinationPort = "rewrite TCP destination port: %v"
)
// ipv4Checksum calculates IPv4 header checksum using optimized parallel processing for performance.
func ipv4Checksum(header []byte) uint16 {
if len(header) < 20 {
return 0
@@ -20,13 +29,11 @@ func ipv4Checksum(header []byte) uint16 {
var sum1, sum2 uint32
// Parallel processing - unroll and compute two sums simultaneously
sum1 += uint32(binary.BigEndian.Uint16(header[0:2]))
sum2 += uint32(binary.BigEndian.Uint16(header[2:4]))
sum1 += uint32(binary.BigEndian.Uint16(header[4:6]))
sum2 += uint32(binary.BigEndian.Uint16(header[6:8]))
sum1 += uint32(binary.BigEndian.Uint16(header[8:10]))
// Skip checksum field at [10:12]
sum2 += uint32(binary.BigEndian.Uint16(header[12:14]))
sum1 += uint32(binary.BigEndian.Uint16(header[14:16]))
sum2 += uint32(binary.BigEndian.Uint16(header[16:18]))
@@ -34,7 +41,6 @@ func ipv4Checksum(header []byte) uint16 {
sum := sum1 + sum2
// Handle remaining bytes for headers > 20 bytes
for i := 20; i < len(header)-1; i += 2 {
sum += uint32(binary.BigEndian.Uint16(header[i : i+2]))
}
@@ -43,7 +49,6 @@ func ipv4Checksum(header []byte) uint16 {
sum += uint32(header[len(header)-1]) << 8
}
// Optimized carry fold - single iteration handles most cases
sum = (sum & 0xFFFF) + (sum >> 16)
if sum > 0xFFFF {
sum++
@@ -52,11 +57,11 @@ func ipv4Checksum(header []byte) uint16 {
return ^uint16(sum)
}
// icmpChecksum calculates ICMP checksum using parallel accumulation for high-performance processing.
func icmpChecksum(data []byte) uint16 {
var sum1, sum2, sum3, sum4 uint32
i := 0
// Process 16 bytes at once with 4 parallel accumulators
for i <= len(data)-16 {
sum1 += uint32(binary.BigEndian.Uint16(data[i : i+2]))
sum2 += uint32(binary.BigEndian.Uint16(data[i+2 : i+4]))
@@ -71,7 +76,6 @@ func icmpChecksum(data []byte) uint16 {
sum := sum1 + sum2 + sum3 + sum4
// Handle remaining bytes
for i < len(data)-1 {
sum += uint32(binary.BigEndian.Uint16(data[i : i+2]))
i += 2
@@ -89,11 +93,131 @@ func icmpChecksum(data []byte) uint16 {
return ^uint16(sum)
}
// biDNATMap maintains bidirectional DNAT mappings for efficient forward and reverse lookups.
type biDNATMap struct {
forward map[netip.Addr]netip.Addr
reverse map[netip.Addr]netip.Addr
}
// portDNATRule represents a port-specific DNAT rule
type portDNATRule struct {
protocol gopacket.LayerType
sourcePort uint16
targetPort uint16
targetIP netip.Addr
}
// portDNATMap manages port-specific DNAT rules
type portDNATMap struct {
rules []portDNATRule
}
// ConnKey represents a connection 4-tuple for NAT tracking.
type ConnKey struct {
SrcIP netip.Addr
DstIP netip.Addr
SrcPort uint16
DstPort uint16
}
// portNATConn tracks port NAT state for a specific connection.
type portNATConn struct {
rule portDNATRule
originalPort uint16
translatedAt time.Time
}
// portNATTracker tracks connection-specific port NAT state
type portNATTracker struct {
connections map[ConnKey]*portNATConn
mutex sync.RWMutex
}
// newPortNATTracker creates a new port NAT tracker for stateful connection tracking.
func newPortNATTracker() *portNATTracker {
return &portNATTracker{
connections: make(map[ConnKey]*portNATConn),
}
}
// trackConnection tracks a connection that has port NAT applied using translated port as key.
func (t *portNATTracker) trackConnection(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, rule portDNATRule) {
t.mutex.Lock()
defer t.mutex.Unlock()
key := ConnKey{
SrcIP: srcIP,
DstIP: dstIP,
SrcPort: srcPort,
DstPort: rule.targetPort,
}
t.connections[key] = &portNATConn{
rule: rule,
originalPort: dstPort,
translatedAt: time.Now(),
}
}
// getConnectionNAT returns NAT info for a connection if tracked, looking up by connection 4-tuple.
func (t *portNATTracker) getConnectionNAT(srcIP, dstIP netip.Addr, srcPort, dstPort uint16) (*portNATConn, bool) {
t.mutex.RLock()
defer t.mutex.RUnlock()
key := ConnKey{
SrcIP: srcIP,
DstIP: dstIP,
SrcPort: srcPort,
DstPort: dstPort,
}
conn, exists := t.connections[key]
return conn, exists
}
// removeConnection removes a tracked connection from the NAT tracking table.
func (t *portNATTracker) removeConnection(srcIP, dstIP netip.Addr, srcPort, dstPort uint16) {
t.mutex.Lock()
defer t.mutex.Unlock()
key := ConnKey{
SrcIP: srcIP,
DstIP: dstIP,
SrcPort: srcPort,
DstPort: dstPort,
}
delete(t.connections, key)
}
// shouldApplyNAT checks if NAT should be applied to a new connection to prevent bidirectional conflicts.
func (t *portNATTracker) shouldApplyNAT(srcIP, dstIP netip.Addr, dstPort uint16) bool {
t.mutex.RLock()
defer t.mutex.RUnlock()
for key, conn := range t.connections {
if key.SrcIP == dstIP && key.DstIP == srcIP &&
conn.rule.sourcePort == dstPort && conn.originalPort == dstPort {
return false
}
}
return true
}
// cleanupConnection removes a NAT connection based on original 4-tuple for connection cleanup.
func (t *portNATTracker) cleanupConnection(srcIP, dstIP netip.Addr, srcPort uint16) {
t.mutex.Lock()
defer t.mutex.Unlock()
for key := range t.connections {
if key.SrcIP == srcIP && key.DstIP == dstIP && key.SrcPort == srcPort {
delete(t.connections, key)
return
}
}
}
// newBiDNATMap creates a new bidirectional DNAT mapping structure for efficient forward/reverse lookups.
func newBiDNATMap() *biDNATMap {
return &biDNATMap{
forward: make(map[netip.Addr]netip.Addr),
@@ -101,11 +225,13 @@ func newBiDNATMap() *biDNATMap {
}
}
// set adds a bidirectional DNAT mapping between original and translated addresses for both directions.
func (b *biDNATMap) set(original, translated netip.Addr) {
b.forward[original] = translated
b.reverse[translated] = original
}
// delete removes a bidirectional DNAT mapping for the given original address.
func (b *biDNATMap) delete(original netip.Addr) {
if translated, exists := b.forward[original]; exists {
delete(b.forward, original)
@@ -113,19 +239,25 @@ func (b *biDNATMap) delete(original netip.Addr) {
}
}
// getTranslated returns the translated address for a given original address from forward mapping.
func (b *biDNATMap) getTranslated(original netip.Addr) (netip.Addr, bool) {
translated, exists := b.forward[original]
return translated, exists
}
// getOriginal returns the original address for a given translated address from reverse mapping.
func (b *biDNATMap) getOriginal(translated netip.Addr) (netip.Addr, bool) {
original, exists := b.reverse[translated]
return original, exists
}
// AddInternalDNATMapping adds a 1:1 IP address mapping for internal DNAT translation.
func (m *Manager) AddInternalDNATMapping(originalAddr, translatedAddr netip.Addr) error {
if !originalAddr.IsValid() || !translatedAddr.IsValid() {
return fmt.Errorf("invalid IP addresses")
if !originalAddr.IsValid() {
return fmt.Errorf("invalid original IP address")
}
if !translatedAddr.IsValid() {
return fmt.Errorf("invalid translated IP address")
}
if m.localipmanager.IsLocalIP(translatedAddr) {
@@ -135,7 +267,6 @@ func (m *Manager) AddInternalDNATMapping(originalAddr, translatedAddr netip.Addr
m.dnatMutex.Lock()
defer m.dnatMutex.Unlock()
// Initialize both maps together if either is nil
if m.dnatMappings == nil || m.dnatBiMap == nil {
m.dnatMappings = make(map[netip.Addr]netip.Addr)
m.dnatBiMap = newBiDNATMap()
@@ -151,7 +282,7 @@ func (m *Manager) AddInternalDNATMapping(originalAddr, translatedAddr netip.Addr
return nil
}
// RemoveInternalDNATMapping removes a 1:1 IP address mapping
// RemoveInternalDNATMapping removes a 1:1 IP address mapping.
func (m *Manager) RemoveInternalDNATMapping(originalAddr netip.Addr) error {
m.dnatMutex.Lock()
defer m.dnatMutex.Unlock()
@@ -169,7 +300,7 @@ func (m *Manager) RemoveInternalDNATMapping(originalAddr netip.Addr) error {
return nil
}
// getDNATTranslation returns the translated address if a mapping exists
// getDNATTranslation returns the translated address if a mapping exists with fast-path optimization.
func (m *Manager) getDNATTranslation(addr netip.Addr) (netip.Addr, bool) {
if !m.dnatEnabled.Load() {
return addr, false
@@ -181,7 +312,7 @@ func (m *Manager) getDNATTranslation(addr netip.Addr) (netip.Addr, bool) {
return translated, exists
}
// findReverseDNATMapping finds original address for return traffic
// findReverseDNATMapping finds original address for return traffic using reverse mapping.
func (m *Manager) findReverseDNATMapping(translatedAddr netip.Addr) (netip.Addr, bool) {
if !m.dnatEnabled.Load() {
return translatedAddr, false
@@ -193,7 +324,7 @@ func (m *Manager) findReverseDNATMapping(translatedAddr netip.Addr) (netip.Addr,
return original, exists
}
// translateOutboundDNAT applies DNAT translation to outbound packets
// translateOutboundDNAT applies DNAT translation to outbound packets for 1:1 IP mapping.
func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool {
if !m.dnatEnabled.Load() {
return false
@@ -211,7 +342,7 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool {
}
if err := m.rewritePacketDestination(packetData, d, translatedIP); err != nil {
m.logger.Error("Failed to rewrite packet destination: %v", err)
m.logger.Error("rewrite packet destination: %v", err)
return false
}
@@ -219,7 +350,7 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool {
return true
}
// translateInboundReverse applies reverse DNAT to inbound return traffic
// translateInboundReverse applies reverse DNAT to inbound return traffic for 1:1 IP mapping.
func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool {
if !m.dnatEnabled.Load() {
return false
@@ -237,7 +368,7 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool {
}
if err := m.rewritePacketSource(packetData, d, originalIP); err != nil {
m.logger.Error("Failed to rewrite packet source: %v", err)
m.logger.Error("rewrite packet source: %v", err)
return false
}
@@ -245,7 +376,7 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool {
return true
}
// rewritePacketDestination replaces destination IP in the packet
// rewritePacketDestination replaces destination IP in the packet and updates checksums.
func (m *Manager) rewritePacketDestination(packetData []byte, d *decoder, newIP netip.Addr) error {
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 || !newIP.Is4() {
return ErrIPv4Only
@@ -259,7 +390,7 @@ func (m *Manager) rewritePacketDestination(packetData []byte, d *decoder, newIP
ipHeaderLen := int(d.ip4.IHL) * 4
if ipHeaderLen < 20 || ipHeaderLen > len(packetData) {
return fmt.Errorf("invalid IP header length")
return fmt.Errorf(invalidIPHeaderLengthMsg)
}
binary.BigEndian.PutUint16(packetData[10:12], 0)
@@ -280,7 +411,7 @@ func (m *Manager) rewritePacketDestination(packetData []byte, d *decoder, newIP
return nil
}
// rewritePacketSource replaces the source IP address in the packet
// rewritePacketSource replaces the source IP address in the packet and updates checksums.
func (m *Manager) rewritePacketSource(packetData []byte, d *decoder, newIP netip.Addr) error {
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 || !newIP.Is4() {
return ErrIPv4Only
@@ -294,7 +425,7 @@ func (m *Manager) rewritePacketSource(packetData []byte, d *decoder, newIP netip
ipHeaderLen := int(d.ip4.IHL) * 4
if ipHeaderLen < 20 || ipHeaderLen > len(packetData) {
return fmt.Errorf("invalid IP header length")
return fmt.Errorf(invalidIPHeaderLengthMsg)
}
binary.BigEndian.PutUint16(packetData[10:12], 0)
@@ -315,6 +446,7 @@ func (m *Manager) rewritePacketSource(packetData []byte, d *decoder, newIP netip
return nil
}
// updateTCPChecksum updates TCP checksum after IP address change using incremental update per RFC 1624.
func (m *Manager) updateTCPChecksum(packetData []byte, ipHeaderLen int, oldIP, newIP []byte) {
tcpStart := ipHeaderLen
if len(packetData) < tcpStart+18 {
@@ -327,6 +459,7 @@ func (m *Manager) updateTCPChecksum(packetData []byte, ipHeaderLen int, oldIP, n
binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum)
}
// updateUDPChecksum updates UDP checksum after IP address change using incremental update per RFC 1624.
func (m *Manager) updateUDPChecksum(packetData []byte, ipHeaderLen int, oldIP, newIP []byte) {
udpStart := ipHeaderLen
if len(packetData) < udpStart+8 {
@@ -344,6 +477,7 @@ func (m *Manager) updateUDPChecksum(packetData []byte, ipHeaderLen int, oldIP, n
binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum)
}
// updateICMPChecksum recalculates ICMP checksum after packet modification using full recalculation.
func (m *Manager) updateICMPChecksum(packetData []byte, ipHeaderLen int) {
icmpStart := ipHeaderLen
if len(packetData) < icmpStart+8 {
@@ -356,18 +490,16 @@ func (m *Manager) updateICMPChecksum(packetData []byte, ipHeaderLen int) {
binary.BigEndian.PutUint16(icmpData[2:4], checksum)
}
// incrementalUpdate performs incremental checksum update per RFC 1624
// incrementalUpdate performs incremental checksum update per RFC 1624 for performance.
func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 {
sum := uint32(^oldChecksum)
// Fast path for IPv4 addresses (4 bytes) - most common case
if len(oldBytes) == 4 && len(newBytes) == 4 {
sum += uint32(^binary.BigEndian.Uint16(oldBytes[0:2]))
sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4]))
sum += uint32(binary.BigEndian.Uint16(newBytes[0:2]))
sum += uint32(binary.BigEndian.Uint16(newBytes[2:4]))
} else {
// Fallback for other lengths
for i := 0; i < len(oldBytes)-1; i += 2 {
sum += uint32(^binary.BigEndian.Uint16(oldBytes[i : i+2]))
}
@@ -391,7 +523,7 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 {
return ^uint16(sum)
}
// AddDNATRule adds a DNAT rule (delegates to native firewall for port forwarding)
// AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network.
func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
if m.nativeFirewall == nil {
return nil, errNatNotSupported
@@ -399,10 +531,318 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error)
return m.nativeFirewall.AddDNATRule(rule)
}
// DeleteDNATRule deletes a DNAT rule (delegates to native firewall)
// DeleteDNATRule deletes outbound DNAT rule.
func (m *Manager) DeleteDNATRule(rule firewall.Rule) error {
if m.nativeFirewall == nil {
return errNatNotSupported
}
return m.nativeFirewall.DeleteDNATRule(rule)
}
// addPortRedirection adds port redirection rule for specified target IP, protocol and ports.
func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error {
m.portDNATMutex.Lock()
defer m.portDNATMutex.Unlock()
rule := portDNATRule{
protocol: protocol,
sourcePort: sourcePort,
targetPort: targetPort,
targetIP: targetIP,
}
m.portDNATMap.rules = append(m.portDNATMap.rules, rule)
m.portDNATEnabled.Store(true)
return nil
}
// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services on specific ports.
func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
var layerType gopacket.LayerType
if protocol == firewall.ProtocolTCP {
layerType = layers.LayerTypeTCP
} else if protocol == firewall.ProtocolUDP {
layerType = layers.LayerTypeUDP
} else {
return fmt.Errorf("unsupported protocol: %s", protocol)
}
return m.addPortRedirection(localAddr, layerType, sourcePort, targetPort)
}
// removePortRedirection removes port redirection rule for specified target IP, protocol and ports.
func (m *Manager) removePortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, sourcePort, targetPort uint16) error {
m.portDNATMutex.Lock()
defer m.portDNATMutex.Unlock()
var filteredRules []portDNATRule
for _, rule := range m.portDNATMap.rules {
if !(rule.protocol == protocol && rule.sourcePort == sourcePort && rule.targetPort == targetPort && rule.targetIP.Compare(targetIP) == 0) {
filteredRules = append(filteredRules, rule)
}
}
m.portDNATMap.rules = filteredRules
if len(m.portDNATMap.rules) == 0 {
m.portDNATEnabled.Store(false)
}
return nil
}
// RemoveInboundDNAT removes inbound DNAT rule for specified local address and ports.
func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error {
var layerType gopacket.LayerType
if protocol == firewall.ProtocolTCP {
layerType = layers.LayerTypeTCP
} else if protocol == firewall.ProtocolUDP {
layerType = layers.LayerTypeUDP
} else {
return fmt.Errorf("unsupported protocol: %s", protocol)
}
return m.removePortRedirection(localAddr, layerType, sourcePort, targetPort)
}
// translateInboundPortDNAT applies stateful port-specific DNAT translation to inbound packets.
func (m *Manager) translateInboundPortDNAT(packetData []byte, d *decoder) bool {
if !m.portDNATEnabled.Load() {
return false
}
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 {
return false
}
if len(d.decoded) < 2 || d.decoded[1] != layers.LayerTypeTCP {
return false
}
srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]})
dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]})
srcPort := uint16(d.tcp.SrcPort)
dstPort := uint16(d.tcp.DstPort)
if m.handleReturnTraffic(packetData, d, srcIP, dstIP, srcPort, dstPort) {
return true
}
return m.handleNewConnection(packetData, d, srcIP, dstIP, srcPort, dstPort)
}
// handleReturnTraffic processes return traffic for existing NAT connections.
func (m *Manager) handleReturnTraffic(packetData []byte, d *decoder, srcIP, dstIP netip.Addr, srcPort, dstPort uint16) bool {
if m.isTranslatedPortTraffic(srcIP, srcPort) {
return false
}
if handled := m.handleExistingNATConnection(packetData, d, srcIP, dstIP, srcPort, dstPort); handled {
return true
}
return m.handleForwardTrafficInExistingConnections(packetData, d, srcIP, dstIP, srcPort, dstPort)
}
// isTranslatedPortTraffic checks if traffic is from a translated port that should be handled by outbound reverse.
func (m *Manager) isTranslatedPortTraffic(srcIP netip.Addr, srcPort uint16) bool {
m.portDNATMutex.RLock()
defer m.portDNATMutex.RUnlock()
for _, rule := range m.portDNATMap.rules {
if rule.protocol == layers.LayerTypeTCP && rule.targetPort == srcPort &&
rule.targetIP.Unmap().Compare(srcIP.Unmap()) == 0 {
return true
}
}
return false
}
// handleExistingNATConnection processes return traffic for existing NAT connections.
func (m *Manager) handleExistingNATConnection(packetData []byte, d *decoder, srcIP, dstIP netip.Addr, srcPort, dstPort uint16) bool {
if natConn, exists := m.portNATTracker.getConnectionNAT(dstIP, srcIP, dstPort, srcPort); exists {
if err := m.rewriteTCPDestinationPort(packetData, d, natConn.originalPort); err != nil {
m.logger.Error(errRewriteTCPDestinationPort, err)
return false
}
m.logger.Trace("Inbound Port DNAT (return): %s:%d -> %s:%d", dstIP, srcPort, dstIP, natConn.originalPort)
return true
}
return false
}
// handleForwardTrafficInExistingConnections processes forward traffic in existing connections.
func (m *Manager) handleForwardTrafficInExistingConnections(packetData []byte, d *decoder, srcIP, dstIP netip.Addr, srcPort, dstPort uint16) bool {
m.portDNATMutex.RLock()
defer m.portDNATMutex.RUnlock()
for _, rule := range m.portDNATMap.rules {
if rule.protocol != layers.LayerTypeTCP || rule.sourcePort != dstPort {
continue
}
if rule.targetIP.Unmap().Compare(dstIP.Unmap()) != 0 {
continue
}
if _, exists := m.portNATTracker.getConnectionNAT(srcIP, dstIP, srcPort, rule.targetPort); !exists {
continue
}
if err := m.rewriteTCPDestinationPort(packetData, d, rule.targetPort); err != nil {
m.logger.Error(errRewriteTCPDestinationPort, err)
return false
}
return true
}
return false
}
// handleNewConnection processes new connections that match port DNAT rules.
func (m *Manager) handleNewConnection(packetData []byte, d *decoder, srcIP, dstIP netip.Addr, srcPort, dstPort uint16) bool {
m.portDNATMutex.RLock()
defer m.portDNATMutex.RUnlock()
for _, rule := range m.portDNATMap.rules {
if m.applyPortDNATRule(packetData, d, rule, srcIP, dstIP, srcPort, dstPort) {
return true
}
}
return false
}
// applyPortDNATRule applies a specific port DNAT rule if conditions are met.
func (m *Manager) applyPortDNATRule(packetData []byte, d *decoder, rule portDNATRule, srcIP, dstIP netip.Addr, srcPort, dstPort uint16) bool {
if rule.protocol != layers.LayerTypeTCP || rule.sourcePort != dstPort {
return false
}
if rule.targetIP.Unmap().Compare(dstIP.Unmap()) != 0 {
return false
}
if !m.portNATTracker.shouldApplyNAT(srcIP, dstIP, dstPort) {
return false
}
if err := m.rewriteTCPDestinationPort(packetData, d, rule.targetPort); err != nil {
m.logger.Error(errRewriteTCPDestinationPort, err)
return false
}
m.portNATTracker.trackConnection(srcIP, dstIP, srcPort, dstPort, rule)
m.logger.Trace("Inbound Port DNAT (new): %s:%d -> %s:%d (tracked: %s:%d -> %s:%d)", dstIP, rule.sourcePort, dstIP, rule.targetPort, srcIP, srcPort, dstIP, rule.targetPort)
return true
}
// rewriteTCPDestinationPort rewrites the destination port in a TCP packet and updates checksum.
func (m *Manager) rewriteTCPDestinationPort(packetData []byte, d *decoder, newPort uint16) error {
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 {
return ErrIPv4Only
}
if len(d.decoded) < 2 || d.decoded[1] != layers.LayerTypeTCP {
return fmt.Errorf("not a TCP packet")
}
ipHeaderLen := int(d.ip4.IHL) * 4
if ipHeaderLen < 20 || ipHeaderLen > len(packetData) {
return fmt.Errorf(invalidIPHeaderLengthMsg)
}
tcpStart := ipHeaderLen
if len(packetData) < tcpStart+4 {
return fmt.Errorf("packet too short for TCP header")
}
oldPort := binary.BigEndian.Uint16(packetData[tcpStart+2 : tcpStart+4])
binary.BigEndian.PutUint16(packetData[tcpStart+2:tcpStart+4], newPort)
if len(packetData) >= tcpStart+18 {
checksumOffset := tcpStart + 16
oldChecksum := binary.BigEndian.Uint16(packetData[checksumOffset : checksumOffset+2])
var oldPortBytes, newPortBytes [2]byte
binary.BigEndian.PutUint16(oldPortBytes[:], oldPort)
binary.BigEndian.PutUint16(newPortBytes[:], newPort)
newChecksum := incrementalUpdate(oldChecksum, oldPortBytes[:], newPortBytes[:])
binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum)
}
return nil
}
// rewriteTCPSourcePort rewrites the source port in a TCP packet and updates checksum.
func (m *Manager) rewriteTCPSourcePort(packetData []byte, d *decoder, newPort uint16) error {
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 {
return ErrIPv4Only
}
if len(d.decoded) < 2 || d.decoded[1] != layers.LayerTypeTCP {
return fmt.Errorf("not a TCP packet")
}
ipHeaderLen := int(d.ip4.IHL) * 4
if ipHeaderLen < 20 || ipHeaderLen > len(packetData) {
return fmt.Errorf(invalidIPHeaderLengthMsg)
}
tcpStart := ipHeaderLen
if len(packetData) < tcpStart+4 {
return fmt.Errorf("packet too short for TCP header")
}
oldPort := binary.BigEndian.Uint16(packetData[tcpStart : tcpStart+2])
binary.BigEndian.PutUint16(packetData[tcpStart:tcpStart+2], newPort)
if len(packetData) >= tcpStart+18 {
checksumOffset := tcpStart + 16
oldChecksum := binary.BigEndian.Uint16(packetData[checksumOffset : checksumOffset+2])
var oldPortBytes, newPortBytes [2]byte
binary.BigEndian.PutUint16(oldPortBytes[:], oldPort)
binary.BigEndian.PutUint16(newPortBytes[:], newPort)
newChecksum := incrementalUpdate(oldChecksum, oldPortBytes[:], newPortBytes[:])
binary.BigEndian.PutUint16(packetData[checksumOffset:checksumOffset+2], newChecksum)
}
return nil
}
// translateOutboundPortReverse applies stateful reverse port DNAT to outbound return traffic for SSH redirection.
func (m *Manager) translateOutboundPortReverse(packetData []byte, d *decoder) bool {
if !m.portDNATEnabled.Load() {
return false
}
if len(packetData) < 20 || d.decoded[0] != layers.LayerTypeIPv4 {
return false
}
if len(d.decoded) < 2 || d.decoded[1] != layers.LayerTypeTCP {
return false
}
srcIP := netip.AddrFrom4([4]byte{packetData[12], packetData[13], packetData[14], packetData[15]})
dstIP := netip.AddrFrom4([4]byte{packetData[16], packetData[17], packetData[18], packetData[19]})
srcPort := uint16(d.tcp.SrcPort)
dstPort := uint16(d.tcp.DstPort)
// For outbound reverse, we need to find the connection using the same key as when it was stored
// Connection was stored as: srcIP, dstIP, srcPort, translatedPort
// So for return traffic (srcIP=server, dstIP=client), we need: dstIP, srcIP, dstPort, srcPort
if natConn, exists := m.portNATTracker.getConnectionNAT(dstIP, srcIP, dstPort, srcPort); exists {
if err := m.rewriteTCPSourcePort(packetData, d, natConn.rule.sourcePort); err != nil {
m.logger.Error("rewrite TCP source port: %v", err)
return false
}
return true
}
return false
}

View File

@@ -0,0 +1,111 @@
package uspfilter
import (
"net/netip"
"testing"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/device"
)
// TestStatefulNATBidirectionalSSH tests that stateful NAT prevents interference
// when two peers try to SSH to each other simultaneously
func TestStatefulNATBidirectionalSSH(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define peer IPs
peerA := netip.MustParseAddr("100.10.0.50")
peerB := netip.MustParseAddr("100.10.0.51")
// Add SSH port redirection rule for peer B (the target)
err = manager.addPortRedirection(peerB, layers.LayerTypeTCP, 22, 22022)
require.NoError(t, err)
// Scenario: Peer A connects to Peer B on port 22 (should get NAT)
// This simulates: ssh user@100.10.0.51
packetAtoB := generateDNATTestPacket(t, peerA, peerB, layers.IPProtocolTCP, 54321, 22)
translatedAtoB := manager.translateInboundPortDNAT(packetAtoB, parsePacket(t, packetAtoB))
require.True(t, translatedAtoB, "Peer A to Peer B should be translated (NAT applied)")
// Verify port was translated to 22022
d := parsePacket(t, packetAtoB)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Port should be rewritten to 22022")
// Verify NAT connection is tracked (with translated port as key)
natConn, exists := manager.portNATTracker.getConnectionNAT(peerA, peerB, 54321, 22022)
require.True(t, exists, "NAT connection should be tracked")
require.Equal(t, uint16(22), natConn.originalPort, "Original port should be stored")
// Scenario: Peer B tries to connect to Peer A on port 22 (should NOT get NAT)
// This simulates the reverse direction to prevent interference
packetBtoA := generateDNATTestPacket(t, peerB, peerA, layers.IPProtocolTCP, 54322, 22)
translatedBtoA := manager.translateInboundPortDNAT(packetBtoA, parsePacket(t, packetBtoA))
require.False(t, translatedBtoA, "Peer B to Peer A should NOT be translated (prevent interference)")
// Verify port was NOT translated
d2 := parsePacket(t, packetBtoA)
require.Equal(t, uint16(22), uint16(d2.tcp.DstPort), "Port should remain 22 (no translation)")
// Verify no reverse NAT connection is tracked
_, reverseExists := manager.portNATTracker.getConnectionNAT(peerB, peerA, 54322, 22)
require.False(t, reverseExists, "Reverse NAT connection should NOT be tracked")
// Scenario: Return traffic from Peer B (SSH server) to Peer A (should be reverse translated)
returnPacket := generateDNATTestPacket(t, peerB, peerA, layers.IPProtocolTCP, 22022, 54321)
translatedReturn := manager.translateOutboundPortReverse(returnPacket, parsePacket(t, returnPacket))
require.True(t, translatedReturn, "Return traffic should be reverse translated")
// Verify return traffic port was translated back to 22
d3 := parsePacket(t, returnPacket)
require.Equal(t, uint16(22), uint16(d3.tcp.SrcPort), "Return traffic source port should be 22")
}
// TestStatefulNATConnectionCleanup tests connection cleanup functionality
func TestStatefulNATConnectionCleanup(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define peer IPs
peerA := netip.MustParseAddr("100.10.0.50")
peerB := netip.MustParseAddr("100.10.0.51")
// Add SSH port redirection rules for both peers
err = manager.addPortRedirection(peerA, layers.LayerTypeTCP, 22, 22022)
require.NoError(t, err)
err = manager.addPortRedirection(peerB, layers.LayerTypeTCP, 22, 22022)
require.NoError(t, err)
// Establish connection with NAT
packet := generateDNATTestPacket(t, peerA, peerB, layers.IPProtocolTCP, 54321, 22)
translated := manager.translateInboundPortDNAT(packet, parsePacket(t, packet))
require.True(t, translated, "Initial connection should be translated")
// Verify connection is tracked (using translated port as key)
_, exists := manager.portNATTracker.getConnectionNAT(peerA, peerB, 54321, 22022)
require.True(t, exists, "Connection should be tracked")
// Clean up connection
manager.portNATTracker.cleanupConnection(peerA, peerB, 54321)
// Verify connection is no longer tracked (using translated port as key)
_, stillExists := manager.portNATTracker.getConnectionNAT(peerA, peerB, 54321, 22022)
require.False(t, stillExists, "Connection should be cleaned up")
// Verify new connection from opposite direction now works
reversePacket := generateDNATTestPacket(t, peerB, peerA, layers.IPProtocolTCP, 54322, 22)
reverseTranslated := manager.translateInboundPortDNAT(reversePacket, parsePacket(t, reversePacket))
require.True(t, reverseTranslated, "Reverse connection should now work after cleanup")
}

View File

@@ -1,13 +1,17 @@
package uspfilter
import (
"io"
"net"
"net/netip"
"testing"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/iface/device"
)
@@ -143,3 +147,520 @@ func TestDNATMappingManagement(t *testing.T) {
err = manager.RemoveInternalDNATMapping(originalIP)
require.Error(t, err, "Should error when removing non-existent mapping")
}
// TestSSHPortRedirection tests SSH port redirection functionality
func TestSSHPortRedirection(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define NetBird network range
peerIP := netip.MustParseAddr("100.10.0.50")
clientIP := netip.MustParseAddr("100.10.0.100")
// Add SSH port redirection rule
err = manager.AddInboundDNAT(peerIP, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
// Verify port DNAT is enabled
require.True(t, manager.portDNATEnabled.Load(), "Port DNAT should be enabled")
require.Len(t, manager.portDNATMap.rules, 1, "Should have one port DNAT rule")
// Verify the rule configuration
rule := manager.portDNATMap.rules[0]
require.Equal(t, gopacket.LayerType(layers.LayerTypeTCP), rule.protocol)
require.Equal(t, uint16(22), rule.sourcePort)
require.Equal(t, uint16(22022), rule.targetPort)
require.Equal(t, peerIP, rule.targetIP)
// Test inbound SSH packet (client -> peer:22, should redirect to peer:22022)
inboundPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 22)
originalInbound := make([]byte, len(inboundPacket))
copy(originalInbound, inboundPacket)
// Process inbound packet
translated := manager.translateInboundPortDNAT(inboundPacket, parsePacket(t, inboundPacket))
require.True(t, translated, "Inbound SSH packet should be translated")
// Verify destination port was changed from 22 to 22022
d := parsePacket(t, inboundPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Destination port should be rewritten to 22022")
// Verify destination IP remains unchanged
dstIPAfter := netip.AddrFrom4([4]byte{inboundPacket[16], inboundPacket[17], inboundPacket[18], inboundPacket[19]})
require.Equal(t, peerIP, dstIPAfter, "Destination IP should remain unchanged")
// Test outbound return packet (peer:22022 -> client, should rewrite source port to 22)
outboundPacket := generateDNATTestPacket(t, peerIP, clientIP, layers.IPProtocolTCP, 22022, 54321)
originalOutbound := make([]byte, len(outboundPacket))
copy(originalOutbound, outboundPacket)
// Process outbound return packet
reversed := manager.translateOutboundPortReverse(outboundPacket, parsePacket(t, outboundPacket))
require.True(t, reversed, "Outbound return packet should be reverse translated")
// Verify source port was changed from 22022 to 22
d = parsePacket(t, outboundPacket)
require.Equal(t, uint16(22), uint16(d.tcp.SrcPort), "Source port should be rewritten to 22")
// Verify source IP remains unchanged
srcIPAfter := netip.AddrFrom4([4]byte{outboundPacket[12], outboundPacket[13], outboundPacket[14], outboundPacket[15]})
require.Equal(t, peerIP, srcIPAfter, "Source IP should remain unchanged")
// Test removal of SSH port redirection
err = manager.RemoveInboundDNAT(peerIP, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
require.False(t, manager.portDNATEnabled.Load(), "Port DNAT should be disabled after removal")
require.Len(t, manager.portDNATMap.rules, 0, "Should have no port DNAT rules after removal")
}
// TestSSHPortRedirectionNetworkFiltering tests that SSH redirection only applies to specified networks
func TestSSHPortRedirectionNetworkFiltering(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define NetBird network range
peerInNetwork := netip.MustParseAddr("100.10.0.50")
peerOutsideNetwork := netip.MustParseAddr("192.168.1.50")
clientIP := netip.MustParseAddr("100.10.0.100")
// Add SSH port redirection rule for NetBird network only
err = manager.AddInboundDNAT(peerInNetwork, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
// Test SSH packet to peer within NetBird network (should be redirected)
inNetworkPacket := generateDNATTestPacket(t, clientIP, peerInNetwork, layers.IPProtocolTCP, 54321, 22)
translated := manager.translateInboundPortDNAT(inNetworkPacket, parsePacket(t, inNetworkPacket))
require.True(t, translated, "SSH packet to NetBird peer should be translated")
// Verify port was changed
d := parsePacket(t, inNetworkPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Port should be redirected for NetBird peer")
// Test SSH packet to peer outside NetBird network (should NOT be redirected)
outOfNetworkPacket := generateDNATTestPacket(t, clientIP, peerOutsideNetwork, layers.IPProtocolTCP, 54321, 22)
originalOutOfNetwork := make([]byte, len(outOfNetworkPacket))
copy(originalOutOfNetwork, outOfNetworkPacket)
notTranslated := manager.translateInboundPortDNAT(outOfNetworkPacket, parsePacket(t, outOfNetworkPacket))
require.False(t, notTranslated, "SSH packet to non-NetBird peer should NOT be translated")
// Verify packet was not modified
require.Equal(t, originalOutOfNetwork, outOfNetworkPacket, "Packet to non-NetBird peer should remain unchanged")
}
// TestSSHPortRedirectionNonTCPTraffic tests that only TCP traffic is affected
func TestSSHPortRedirectionNonTCPTraffic(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define NetBird network range
peerIP := netip.MustParseAddr("100.10.0.50")
clientIP := netip.MustParseAddr("100.10.0.100")
// Add SSH port redirection rule
err = manager.AddInboundDNAT(peerIP, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
// Test UDP packet on port 22 (should NOT be redirected)
udpPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolUDP, 54321, 22)
originalUDP := make([]byte, len(udpPacket))
copy(originalUDP, udpPacket)
translated := manager.translateInboundPortDNAT(udpPacket, parsePacket(t, udpPacket))
require.False(t, translated, "UDP packet should NOT be translated by SSH port redirection")
require.Equal(t, originalUDP, udpPacket, "UDP packet should remain unchanged")
// Test ICMP packet (should NOT be redirected)
icmpPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolICMPv4, 0, 0)
originalICMP := make([]byte, len(icmpPacket))
copy(originalICMP, icmpPacket)
translated = manager.translateInboundPortDNAT(icmpPacket, parsePacket(t, icmpPacket))
require.False(t, translated, "ICMP packet should NOT be translated by SSH port redirection")
require.Equal(t, originalICMP, icmpPacket, "ICMP packet should remain unchanged")
}
// TestSSHPortRedirectionNonSSHPorts tests that only port 22 is redirected
func TestSSHPortRedirectionNonSSHPorts(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define NetBird network range
peerIP := netip.MustParseAddr("100.10.0.50")
clientIP := netip.MustParseAddr("100.10.0.100")
// Add SSH port redirection rule
err = manager.AddInboundDNAT(peerIP, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
// Test TCP packet on port 80 (should NOT be redirected)
httpPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 80)
originalHTTP := make([]byte, len(httpPacket))
copy(originalHTTP, httpPacket)
translated := manager.translateInboundPortDNAT(httpPacket, parsePacket(t, httpPacket))
require.False(t, translated, "Non-SSH TCP packet should NOT be translated")
require.Equal(t, originalHTTP, httpPacket, "Non-SSH TCP packet should remain unchanged")
// Test TCP packet on port 443 (should NOT be redirected)
httpsPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 443)
originalHTTPS := make([]byte, len(httpsPacket))
copy(originalHTTPS, httpsPacket)
translated = manager.translateInboundPortDNAT(httpsPacket, parsePacket(t, httpsPacket))
require.False(t, translated, "Non-SSH TCP packet should NOT be translated")
require.Equal(t, originalHTTPS, httpsPacket, "Non-SSH TCP packet should remain unchanged")
// Test TCP packet on port 22 (SHOULD be redirected)
sshPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 22)
translated = manager.translateInboundPortDNAT(sshPacket, parsePacket(t, sshPacket))
require.True(t, translated, "SSH TCP packet should be translated")
// Verify port was changed to 22022
d := parsePacket(t, sshPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "SSH port should be redirected to 22022")
}
// TestFlexiblePortRedirection tests the flexible port redirection functionality
func TestFlexiblePortRedirection(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define peer and client IPs
peerIP := netip.MustParseAddr("10.0.0.50")
clientIP := netip.MustParseAddr("10.0.0.100")
// Add custom port redirection: TCP port 8080 -> 3000 for peer IP
err = manager.addPortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 8080, 3000)
require.NoError(t, err)
// Verify port DNAT is enabled
require.True(t, manager.portDNATEnabled.Load(), "Port DNAT should be enabled")
require.Len(t, manager.portDNATMap.rules, 1, "Should have one port DNAT rule")
// Verify the rule configuration
rule := manager.portDNATMap.rules[0]
require.Equal(t, gopacket.LayerType(layers.LayerTypeTCP), rule.protocol)
require.Equal(t, uint16(8080), rule.sourcePort)
require.Equal(t, uint16(3000), rule.targetPort)
require.Equal(t, peerIP, rule.targetIP)
// Test inbound packet (client -> peer:8080, should redirect to peer:3000)
inboundPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 8080)
translated := manager.translateInboundPortDNAT(inboundPacket, parsePacket(t, inboundPacket))
require.True(t, translated, "Inbound packet should be translated")
// Verify destination port was changed from 8080 to 3000
d := parsePacket(t, inboundPacket)
require.Equal(t, uint16(3000), uint16(d.tcp.DstPort), "Destination port should be rewritten to 3000")
// Test outbound return packet (peer:3000 -> client, should rewrite source port to 8080)
outboundPacket := generateDNATTestPacket(t, peerIP, clientIP, layers.IPProtocolTCP, 3000, 54321)
reversed := manager.translateOutboundPortReverse(outboundPacket, parsePacket(t, outboundPacket))
require.True(t, reversed, "Outbound return packet should be reverse translated")
// Verify source port was changed from 3000 to 8080
d = parsePacket(t, outboundPacket)
require.Equal(t, uint16(8080), uint16(d.tcp.SrcPort), "Source port should be rewritten to 8080")
// Test removal of port redirection
err = manager.removePortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 8080, 3000)
require.NoError(t, err)
require.False(t, manager.portDNATEnabled.Load(), "Port DNAT should be disabled after removal")
require.Len(t, manager.portDNATMap.rules, 0, "Should have no port DNAT rules after removal")
}
// TestMultiplePortRedirections tests multiple port redirection rules
func TestMultiplePortRedirections(t *testing.T) {
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define peer and client IPs
peerIP := netip.MustParseAddr("172.16.0.50")
clientIP := netip.MustParseAddr("172.16.0.100")
// Add multiple port redirections for peer IP
err = manager.addPortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 22, 22022) // SSH
require.NoError(t, err)
err = manager.addPortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 80, 8080) // HTTP
require.NoError(t, err)
err = manager.addPortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 443, 8443) // HTTPS
require.NoError(t, err)
// Verify all rules are present
require.True(t, manager.portDNATEnabled.Load(), "Port DNAT should be enabled")
require.Len(t, manager.portDNATMap.rules, 3, "Should have three port DNAT rules")
// Test SSH redirection (22 -> 22022)
sshPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 22)
translated := manager.translateInboundPortDNAT(sshPacket, parsePacket(t, sshPacket))
require.True(t, translated, "SSH packet should be translated")
d := parsePacket(t, sshPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "SSH should redirect to 22022")
// Test HTTP redirection (80 -> 8080)
httpPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 80)
translated = manager.translateInboundPortDNAT(httpPacket, parsePacket(t, httpPacket))
require.True(t, translated, "HTTP packet should be translated")
d = parsePacket(t, httpPacket)
require.Equal(t, uint16(8080), uint16(d.tcp.DstPort), "HTTP should redirect to 8080")
// Test HTTPS redirection (443 -> 8443)
httpsPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 443)
translated = manager.translateInboundPortDNAT(httpsPacket, parsePacket(t, httpsPacket))
require.True(t, translated, "HTTPS packet should be translated")
d = parsePacket(t, httpsPacket)
require.Equal(t, uint16(8443), uint16(d.tcp.DstPort), "HTTPS should redirect to 8443")
// Test removing one rule (HTTP)
err = manager.removePortRedirection(peerIP, gopacket.LayerType(layers.LayerTypeTCP), 80, 8080)
require.NoError(t, err)
require.Len(t, manager.portDNATMap.rules, 2, "Should have two rules after removing HTTP rule")
// Verify HTTP is no longer redirected
httpPacket2 := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 80)
originalHTTP := make([]byte, len(httpPacket2))
copy(originalHTTP, httpPacket2)
translated = manager.translateInboundPortDNAT(httpPacket2, parsePacket(t, httpPacket2))
require.False(t, translated, "HTTP packet should NOT be translated after rule removal")
require.Equal(t, originalHTTP, httpPacket2, "HTTP packet should remain unchanged")
// Verify SSH and HTTPS still work
sshPacket2 := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 22)
translated = manager.translateInboundPortDNAT(sshPacket2, parsePacket(t, sshPacket2))
require.True(t, translated, "SSH should still be translated")
httpsPacket2 := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 443)
translated = manager.translateInboundPortDNAT(httpsPacket2, parsePacket(t, httpsPacket2))
require.True(t, translated, "HTTPS should still be translated")
}
// TestSSHPortRedirectionEndToEnd tests actual network delivery through sockets
func TestSSHPortRedirectionEndToEnd(t *testing.T) {
// Start a mock SSH server on port 22022 (NetBird SSH server)
mockSSHServer, err := net.Listen("tcp", "127.0.0.1:22022")
require.NoError(t, err, "Should be able to bind to NetBird SSH port")
defer func() {
require.NoError(t, mockSSHServer.Close())
}()
// Handle connections on the SSH server
serverReceivedData := make(chan string, 1)
go func() {
for {
conn, err := mockSSHServer.Accept()
if err != nil {
return // Server closed
}
go func(conn net.Conn) {
defer func() {
require.NoError(t, conn.Close())
}()
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil && err != io.EOF {
t.Logf("Server read error: %v", err)
return
}
receivedData := string(buf[:n])
serverReceivedData <- receivedData
// Echo back a response
_, err = conn.Write([]byte("SSH-2.0-MockNetBirdSSH\r\n"))
if err != nil {
t.Logf("Server write error: %v", err)
}
}(conn)
}
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
// This test demonstrates what SHOULD happen after port redirection:
// 1. Client connects to 127.0.0.1:22 (standard SSH port)
// 2. Firewall redirects to 127.0.0.1:22022 (NetBird SSH server)
// 3. NetBird SSH server receives the connection
t.Run("DirectConnectionToNetBirdSSHPort", func(t *testing.T) {
// This simulates what should happen AFTER port redirection
// Connect directly to 22022 (where NetBird SSH server listens)
conn, err := net.DialTimeout("tcp", "127.0.0.1:22022", 5*time.Second)
require.NoError(t, err, "Should connect to NetBird SSH server")
defer func() {
require.NoError(t, conn.Close())
}()
// Send SSH client identification
testData := "SSH-2.0-TestClient\r\n"
_, err = conn.Write([]byte(testData))
require.NoError(t, err, "Should send data to SSH server")
// Verify server received the data
select {
case received := <-serverReceivedData:
require.Equal(t, testData, received, "Server should receive client data")
case <-time.After(2 * time.Second):
t.Fatal("Server did not receive data within timeout")
}
// Read server response
buf := make([]byte, 1024)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
require.NoError(t, err, "Should read server response")
response := string(buf[:n])
require.Equal(t, "SSH-2.0-MockNetBirdSSH\r\n", response, "Should receive SSH server identification")
})
t.Run("PortRedirectionSimulation", func(t *testing.T) {
// This test simulates the port redirection process
// Note: This doesn't test the actual userspace packet interception,
// but demonstrates the expected behavior
t.Log("NOTE: This test demonstrates expected behavior after implementing")
t.Log("full userspace packet interception. Currently, we test packet")
t.Log("translation logic separately from actual network delivery.")
// In a real implementation with userspace packet interception:
// 1. Client would connect to 127.0.0.1:22
// 2. Userspace firewall would intercept packets
// 3. translateInboundPortDNAT would rewrite port 22 -> 22022
// 4. Packets would be delivered to 127.0.0.1:22022
// 5. NetBird SSH server would receive the connection
// For now, we verify that the packet translation logic works correctly
// (this is tested in other test functions) and that the target server
// is reachable (tested above)
clientIP := netip.MustParseAddr("127.0.0.1")
serverIP := netip.MustParseAddr("127.0.0.1")
// Create manager with SSH port redirection
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Add SSH port redirection for localhost (for testing)
err = manager.AddInboundDNAT(netip.MustParseAddr("127.0.0.1"), firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
// Generate packet: client connecting to server:22
sshPacket := generateDNATTestPacket(t, clientIP, serverIP, layers.IPProtocolTCP, 54321, 22)
originalPacket := make([]byte, len(sshPacket))
copy(originalPacket, sshPacket)
// Apply port redirection
translated := manager.translateInboundPortDNAT(sshPacket, parsePacket(t, sshPacket))
require.True(t, translated, "SSH packet should be translated")
// Verify port was redirected from 22 to 22022
d := parsePacket(t, sshPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Port should be redirected to NetBird SSH server")
require.NotEqual(t, originalPacket, sshPacket, "Packet should be modified")
t.Log("✓ Packet translation verified: port 22 redirected to 22022")
t.Log("✓ Target SSH server (port 22022) is reachable and responsive")
t.Log("→ Integration complete: SSH port redirection ready for userspace interception")
})
}
// TestFullSSHRedirectionWorkflow demonstrates the complete SSH redirection workflow
func TestFullSSHRedirectionWorkflow(t *testing.T) {
t.Log("=== SSH Port Redirection Workflow Test ===")
t.Log("This test demonstrates the complete SSH redirection process:")
t.Log("1. Client connects to peer:22 (standard SSH)")
t.Log("2. Userspace firewall intercepts and redirects to peer:22022")
t.Log("3. NetBird SSH server receives connection on port 22022")
t.Log("4. Return traffic is reverse-translated (22022 -> 22)")
// Setup test environment
manager, err := Create(&IFaceMock{
SetFilterFunc: func(device.PacketFilter) error { return nil },
}, false, flowLogger)
require.NoError(t, err)
defer func() {
require.NoError(t, manager.Close(nil))
}()
// Define NetBird network and peer IPs
peerIP := netip.MustParseAddr("100.10.0.50")
clientIP := netip.MustParseAddr("100.10.0.100")
// Step 1: Configure SSH port redirection
err = manager.AddInboundDNAT(peerIP, firewall.ProtocolTCP, 22, 22022)
require.NoError(t, err)
t.Log("✓ SSH port redirection configured for NetBird network")
// Step 2: Simulate inbound SSH connection (client -> peer:22)
t.Log("→ Simulating: ssh user@100.10.0.50")
inboundPacket := generateDNATTestPacket(t, clientIP, peerIP, layers.IPProtocolTCP, 54321, 22)
// Step 3: Apply inbound port redirection
translated := manager.translateInboundPortDNAT(inboundPacket, parsePacket(t, inboundPacket))
require.True(t, translated, "Inbound SSH packet should be redirected")
d := parsePacket(t, inboundPacket)
require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Should redirect to NetBird SSH server port")
t.Log("✓ Inbound packet redirected: 100.10.0.50:22 → 100.10.0.50:22022")
// Step 4: Simulate outbound return traffic (peer:22022 -> client)
t.Log("→ Simulating return traffic from NetBird SSH server")
outboundPacket := generateDNATTestPacket(t, peerIP, clientIP, layers.IPProtocolTCP, 22022, 54321)
// Step 5: Apply outbound reverse translation
reversed := manager.translateOutboundPortReverse(outboundPacket, parsePacket(t, outboundPacket))
require.True(t, reversed, "Outbound return packet should be reverse translated")
d = parsePacket(t, outboundPacket)
require.Equal(t, uint16(22), uint16(d.tcp.SrcPort), "Should restore original SSH port")
t.Log("✓ Outbound packet reverse translated: 100.10.0.50:22022 → 100.10.0.50:22")
// Step 6: Verify client sees standard SSH connection
srcIPAfter := netip.AddrFrom4([4]byte{outboundPacket[12], outboundPacket[13], outboundPacket[14], outboundPacket[15]})
require.Equal(t, peerIP, srcIPAfter, "Client should see traffic from peer IP")
t.Log("✓ Client receives traffic from 100.10.0.50:22 (transparent redirection)")
t.Log("=== SSH Port Redirection Workflow Complete ===")
t.Log("Result: Standard SSH clients can connect to NetBird peers using:")
t.Log(" ssh user@100.10.0.50")
t.Log("Instead of:")
t.Log(" ssh user@100.10.0.50 -p 22022")
}

View File

@@ -45,24 +45,28 @@ var defaultInterfaceBlacklist = []string{
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
AdminURL string
ConfigPath string
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
NATExternalIPs []string
CustomDNSAddress []byte
RosenpassEnabled *bool
RosenpassPermissive *bool
InterfaceName *string
WireguardPort *int
NetworkMonitor *bool
DisableAutoConnect *bool
ExtraIFaceBlackList []string
DNSRouteInterval *time.Duration
ClientCertPath string
ClientCertKeyPath string
ManagementURL string
AdminURL string
ConfigPath string
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
EnableSSHRemotePortForwarding *bool
NATExternalIPs []string
CustomDNSAddress []byte
RosenpassEnabled *bool
RosenpassPermissive *bool
InterfaceName *string
WireguardPort *int
NetworkMonitor *bool
DisableAutoConnect *bool
ExtraIFaceBlackList []string
DNSRouteInterval *time.Duration
ClientCertPath string
ClientCertKeyPath string
DisableClientRoutes *bool
DisableServerRoutes *bool
@@ -81,18 +85,22 @@ type ConfigInput struct {
// Config Configuration type
type Config struct {
// Wireguard private key of local peer
PrivateKey string
PreSharedKey string
ManagementURL *url.URL
AdminURL *url.URL
WgIface string
WgPort int
NetworkMonitor *bool
IFaceBlackList []string
DisableIPv6Discovery bool
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
PrivateKey string
PreSharedKey string
ManagementURL *url.URL
AdminURL *url.URL
WgIface string
WgPort int
NetworkMonitor *bool
IFaceBlackList []string
DisableIPv6Discovery bool
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
EnableSSHRemotePortForwarding *bool
DisableClientRoutes bool
DisableServerRoutes bool
@@ -426,6 +434,46 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
if *input.EnableSSHRoot {
log.Infof("enabling SSH root login")
} else {
log.Infof("disabling SSH root login")
}
config.EnableSSHRoot = input.EnableSSHRoot
updated = true
}
if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP {
if *input.EnableSSHSFTP {
log.Infof("enabling SSH SFTP subsystem")
} else {
log.Infof("disabling SSH SFTP subsystem")
}
config.EnableSSHSFTP = input.EnableSSHSFTP
updated = true
}
if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding {
if *input.EnableSSHLocalPortForwarding {
log.Infof("enabling SSH local port forwarding")
} else {
log.Infof("disabling SSH local port forwarding")
}
config.EnableSSHLocalPortForwarding = input.EnableSSHLocalPortForwarding
updated = true
}
if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding {
if *input.EnableSSHRemotePortForwarding {
log.Infof("enabling SSH remote port forwarding")
} else {
log.Infof("disabling SSH remote port forwarding")
}
config.EnableSSHRemotePortForwarding = input.EnableSSHRemotePortForwarding
updated = true
}
if input.DNSRouteInterval != nil && *input.DNSRouteInterval != config.DNSRouteInterval {
log.Infof("updating DNS route interval to %s (old value %s)",
input.DNSRouteInterval.String(), config.DNSRouteInterval.String())

View File

@@ -419,20 +419,24 @@ func createEngineConfig(key wgtypes.Key, config *Config, peerConfig *mgmProto.Pe
nm = *config.NetworkMonitor
}
engineConf := &EngineConfig{
WgIfaceName: config.WgIface,
WgAddr: peerConfig.Address,
IFaceBlackList: config.IFaceBlackList,
DisableIPv6Discovery: config.DisableIPv6Discovery,
WgPrivateKey: key,
WgPort: config.WgPort,
NetworkMonitor: nm,
SSHKey: []byte(config.SSHKey),
NATExternalIPs: config.NATExternalIPs,
CustomDNSAddress: config.CustomDNSAddress,
RosenpassEnabled: config.RosenpassEnabled,
RosenpassPermissive: config.RosenpassPermissive,
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
DNSRouteInterval: config.DNSRouteInterval,
WgIfaceName: config.WgIface,
WgAddr: peerConfig.Address,
IFaceBlackList: config.IFaceBlackList,
DisableIPv6Discovery: config.DisableIPv6Discovery,
WgPrivateKey: key,
WgPort: config.WgPort,
NetworkMonitor: nm,
SSHKey: []byte(config.SSHKey),
NATExternalIPs: config.NATExternalIPs,
CustomDNSAddress: config.CustomDNSAddress,
RosenpassEnabled: config.RosenpassEnabled,
RosenpassPermissive: config.RosenpassPermissive,
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
EnableSSHRoot: config.EnableSSHRoot,
EnableSSHSFTP: config.EnableSSHSFTP,
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
EnableSSHRemotePortForwarding: config.EnableSSHRemotePortForwarding,
DNSRouteInterval: config.DNSRouteInterval,
DisableClientRoutes: config.DisableClientRoutes,
DisableServerRoutes: config.DisableServerRoutes || config.BlockInbound,
@@ -502,6 +506,10 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.LazyConnectionEnabled,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,
config.EnableSSHRemotePortForwarding,
)
loginResp, err := client.Login(*serverPublicKey, sysInfo, pubSSHKey, config.DNSLabels)
if err != nil {

View File

@@ -378,6 +378,18 @@ 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.EnableSSHRoot != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot))
}
if g.internalConfig.EnableSSHSFTP != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHSFTP: %v\n", *g.internalConfig.EnableSSHSFTP))
}
if g.internalConfig.EnableSSHLocalPortForwarding != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHLocalPortForwarding: %v\n", *g.internalConfig.EnableSSHLocalPortForwarding))
}
if g.internalConfig.EnableSSHRemotePortForwarding != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHRemotePortForwarding: %v\n", *g.internalConfig.EnableSSHRemotePortForwarding))
}
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))

View File

@@ -28,7 +28,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/bind"
"github.com/netbirdio/netbird/client/iface/device"
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
@@ -50,7 +49,6 @@ import (
"github.com/netbirdio/netbird/management/domain"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
mgm "github.com/netbirdio/netbird/management/client"
@@ -76,14 +74,6 @@ const (
var ErrResetConnection = fmt.Errorf("reset connection")
// sshServer interface for SSH server operations
type sshServer interface {
Start(addr string) error
Stop() error
RemoveAuthorizedKey(peer string)
AddAuthorizedKey(peer, newKey string) error
}
// EngineConfig is a config for the Engine
type EngineConfig struct {
WgPort int
@@ -120,7 +110,11 @@ type EngineConfig struct {
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed bool
ServerSSHAllowed bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
EnableSSHRemotePortForwarding *bool
DNSRouteInterval time.Duration
@@ -283,6 +277,12 @@ func (e *Engine) Stop() error {
}
log.Info("Network monitor: stopped")
if err := e.stopSSHServer(); err != nil {
log.Warnf("failed to stop SSH server: %v", err)
}
e.cleanupSSHConfig()
// stop/restore DNS first so dbus and friends don't complain because of a missing interface
e.stopDNSServer()
@@ -801,6 +801,10 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.LazyConnectionEnabled,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
)
if err := e.mgmClient.SyncMeta(info); err != nil {
@@ -810,75 +814,6 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}
func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
if e.config.BlockInbound {
log.Info("SSH server is disabled because inbound connections are blocked")
return e.stopSSHServer()
}
if !e.config.ServerSSHAllowed {
log.Info("SSH server is disabled in config")
return e.stopSSHServer()
}
if !sshConf.GetSshEnabled() {
return e.stopSSHServer()
}
// SSH is enabled and supported - start server if not already running
if e.sshServer != nil {
log.Debug("SSH server is already running")
return nil
}
return e.startSSHServer()
}
func (e *Engine) startSSHServer() error {
if e.wgInterface == nil {
return fmt.Errorf("wg interface not initialized")
}
listenAddr := fmt.Sprintf("%s:%d", e.wgInterface.Address().IP.String(), nbssh.DefaultSSHPort)
if nbnetstack.IsEnabled() {
listenAddr = fmt.Sprintf("127.0.0.1:%d", nbssh.DefaultSSHPort)
}
server := nbssh.NewServer(e.config.SSHKey)
e.sshServer = server
log.Infof("starting SSH server on %s", listenAddr)
go func() {
err := server.Start(listenAddr)
if err != nil {
log.Debugf("SSH server stopped with error: %v", err)
}
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.sshServer == server {
e.sshServer = nil
log.Info("SSH server stopped")
}
}()
return nil
}
func (e *Engine) stopSSHServer() error {
if e.sshServer == nil {
return nil
}
log.Info("stopping SSH server")
err := e.sshServer.Stop()
if err != nil {
log.Warnf("failed to stop SSH server: %v", err)
}
e.sshServer = nil
return err
}
func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
if e.wgInterface == nil {
return errors.New("wireguard interface is not initialized")
@@ -896,8 +831,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
}
if conf.GetSshConfig() != nil {
err := e.updateSSH(conf.GetSshConfig())
if err != nil {
if err := e.updateSSH(conf.GetSshConfig()); err != nil {
log.Warnf("failed handling SSH server setup: %v", err)
}
}
@@ -933,6 +867,10 @@ func (e *Engine) receiveManagementEvents() {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.LazyConnectionEnabled,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
)
// err = e.mgmClient.Sync(info, e.handleSync)
@@ -1099,6 +1037,14 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
}
}
}
// update peer SSH host keys in status recorder for daemon API access
e.updatePeerSSHHostKeys(networkMap.GetRemotePeers())
// update SSH client known_hosts with peer host keys for OpenSSH client
if err := e.updateSSHKnownHosts(networkMap.GetRemotePeers()); err != nil {
log.Warnf("failed to update SSH known_hosts: %v", err)
}
}
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
@@ -1284,6 +1230,7 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
conn.AddBeforeAddPeerHook(e.beforePeerHook)
conn.AddAfterRemovePeerHook(e.afterPeerHook)
}
return nil
}
@@ -1491,13 +1438,6 @@ func (e *Engine) close() {
e.statusRecorder.SetWgIface(nil)
}
if e.sshServer != nil {
err := e.sshServer.Stop()
if err != nil {
log.Warnf("failed stopping the SSH server: %v", err)
}
}
if e.firewall != nil {
err := e.firewall.Close(e.stateManager)
if err != nil {
@@ -1528,6 +1468,10 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.LazyConnectionEnabled,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
)
netMap, err := e.mgmClient.GetNetworkMap(info)

View File

@@ -0,0 +1,359 @@
package internal
import (
"context"
"errors"
"fmt"
"net/netip"
"runtime"
"strings"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
mgmProto "github.com/netbirdio/netbird/management/proto"
)
type sshServer interface {
Start(ctx context.Context, addr netip.AddrPort) error
Stop() error
RemoveAuthorizedKey(peer string)
AddAuthorizedKey(peer, newKey string) error
SetSocketFilter(ifIdx int)
SetupSSHClientConfigWithPeers(peerKeys []sshconfig.PeerHostKey) error
}
func (e *Engine) setupSSHPortRedirection() error {
if e.firewall == nil || e.wgInterface == nil {
return nil
}
localAddr := e.wgInterface.Address().IP
if !localAddr.IsValid() {
return errors.New("invalid local NetBird address")
}
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, 22, 22022); err != nil {
return fmt.Errorf("add SSH port redirection: %w", err)
}
log.Infof("SSH port redirection enabled: %s:22 -> %s:22022", localAddr, localAddr)
return nil
}
func (e *Engine) setupSSHSocketFilter(server sshServer) error {
if runtime.GOOS != "linux" {
return nil
}
netInterface := e.wgInterface.ToInterface()
if netInterface == nil {
return errors.New("failed to get WireGuard network interface")
}
server.SetSocketFilter(netInterface.Index)
log.Debugf("SSH socket filter configured for interface %s (index: %d)", netInterface.Name, netInterface.Index)
return nil
}
func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
if e.config.BlockInbound {
log.Info("SSH server is disabled because inbound connections are blocked")
return e.stopSSHServer()
}
if !e.config.ServerSSHAllowed {
log.Info("SSH server is disabled in config")
return e.stopSSHServer()
}
if !sshConf.GetSshEnabled() {
if e.config.ServerSSHAllowed {
log.Info("SSH server is locally allowed but disabled by management server")
}
return e.stopSSHServer()
}
if e.sshServer != nil {
log.Debug("SSH server is already running")
return nil
}
return e.startSSHServer()
}
// updateSSHKnownHosts updates the SSH known_hosts file with peer host keys for OpenSSH client
func (e *Engine) updateSSHKnownHosts(remotePeers []*mgmProto.RemotePeerConfig) error {
peerKeys := e.extractPeerHostKeys(remotePeers)
if len(peerKeys) == 0 {
log.Debug("no SSH-enabled peers found, skipping known_hosts update")
return nil
}
if err := e.updateKnownHostsFile(peerKeys); err != nil {
return err
}
e.updateSSHClientConfig(peerKeys)
log.Debugf("updated SSH known_hosts with %d peer host keys", len(peerKeys))
return nil
}
// extractPeerHostKeys extracts SSH host keys from peer configurations
func (e *Engine) extractPeerHostKeys(remotePeers []*mgmProto.RemotePeerConfig) []sshconfig.PeerHostKey {
var peerKeys []sshconfig.PeerHostKey
for _, peerConfig := range remotePeers {
peerHostKey, ok := e.parsePeerHostKey(peerConfig)
if ok {
peerKeys = append(peerKeys, peerHostKey)
}
}
return peerKeys
}
// parsePeerHostKey parses a single peer's SSH host key configuration
func (e *Engine) parsePeerHostKey(peerConfig *mgmProto.RemotePeerConfig) (sshconfig.PeerHostKey, bool) {
if peerConfig.GetSshConfig() == nil {
return sshconfig.PeerHostKey{}, false
}
sshPubKeyBytes := peerConfig.GetSshConfig().GetSshPubKey()
if len(sshPubKeyBytes) == 0 {
return sshconfig.PeerHostKey{}, false
}
hostKey, _, _, _, err := ssh.ParseAuthorizedKey(sshPubKeyBytes)
if err != nil {
log.Warnf("failed to parse SSH public key for peer %s: %v", peerConfig.GetWgPubKey(), err)
return sshconfig.PeerHostKey{}, false
}
peerIP := e.extractPeerIP(peerConfig)
hostname := e.extractHostname(peerConfig)
return sshconfig.PeerHostKey{
Hostname: hostname,
IP: peerIP,
FQDN: peerConfig.GetFqdn(),
HostKey: hostKey,
}, true
}
// extractPeerIP extracts IP address from peer's allowed IPs
func (e *Engine) extractPeerIP(peerConfig *mgmProto.RemotePeerConfig) string {
if len(peerConfig.GetAllowedIps()) == 0 {
return ""
}
if prefix, err := netip.ParsePrefix(peerConfig.GetAllowedIps()[0]); err == nil {
return prefix.Addr().String()
}
return ""
}
// extractHostname extracts short hostname from FQDN
func (e *Engine) extractHostname(peerConfig *mgmProto.RemotePeerConfig) string {
fqdn := peerConfig.GetFqdn()
if fqdn == "" {
return ""
}
parts := strings.Split(fqdn, ".")
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
return ""
}
// updateKnownHostsFile updates the SSH known_hosts file
func (e *Engine) updateKnownHostsFile(peerKeys []sshconfig.PeerHostKey) error {
configMgr := sshconfig.NewManager()
if err := configMgr.UpdatePeerHostKeys(peerKeys); err != nil {
return fmt.Errorf("update peer host keys: %w", err)
}
return nil
}
// updateSSHClientConfig updates SSH client configuration with peer hostnames
func (e *Engine) updateSSHClientConfig(peerKeys []sshconfig.PeerHostKey) {
if e.sshServer == nil {
return
}
if err := e.sshServer.SetupSSHClientConfigWithPeers(peerKeys); err != nil {
log.Warnf("failed to update SSH client config with peer hostnames: %v", err)
} else {
log.Debugf("updated SSH client config with %d peer hostnames", len(peerKeys))
}
}
// updatePeerSSHHostKeys updates peer SSH host keys in the status recorder for daemon API access
func (e *Engine) updatePeerSSHHostKeys(remotePeers []*mgmProto.RemotePeerConfig) {
for _, peerConfig := range remotePeers {
if peerConfig.GetSshConfig() == nil {
continue
}
sshPubKeyBytes := peerConfig.GetSshConfig().GetSshPubKey()
if len(sshPubKeyBytes) == 0 {
continue
}
if err := e.statusRecorder.UpdatePeerSSHHostKey(peerConfig.GetWgPubKey(), sshPubKeyBytes); err != nil {
log.Warnf("failed to update SSH host key for peer %s: %v", peerConfig.GetWgPubKey(), err)
}
}
log.Debugf("updated peer SSH host keys for daemon API access")
}
// cleanupSSHConfig removes NetBird SSH client configuration on shutdown
func (e *Engine) cleanupSSHConfig() {
configMgr := sshconfig.NewManager()
if err := configMgr.RemoveSSHClientConfig(); err != nil {
log.Warnf("failed to remove SSH client config: %v", err)
} else {
log.Debugf("SSH client config cleanup completed")
}
if err := configMgr.RemoveKnownHostsFile(); err != nil {
log.Warnf("failed to remove SSH known_hosts: %v", err)
} else {
log.Debugf("SSH known_hosts cleanup completed")
}
}
// startSSHServer initializes and starts the SSH server with proper configuration.
func (e *Engine) startSSHServer() error {
if e.wgInterface == nil {
return errors.New("wg interface not initialized")
}
server := sshserver.New(e.config.SSHKey)
wgAddr := e.wgInterface.Address()
server.SetNetworkValidation(wgAddr)
netbirdIP := wgAddr.IP
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
server.SetNetstackNet(netstackNet)
if registrar, ok := e.firewall.(interface {
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
}); ok {
registrar.RegisterNetstackService(nftypes.TCP, sshserver.InternalSSHPort)
log.Debugf("registered SSH service with netstack for TCP:%d", sshserver.InternalSSHPort)
}
}
e.configureSSHServer(server)
e.sshServer = server
if err := e.setupSSHPortRedirection(); err != nil {
log.Warnf("failed to setup SSH port redirection: %v", err)
}
if err := e.setupSSHSocketFilter(server); err != nil {
return fmt.Errorf("set socket filter: %w", err)
}
if err := server.Start(e.ctx, listenAddr); err != nil {
return fmt.Errorf("start SSH server: %w", err)
}
if err := server.SetupSSHClientConfig(); err != nil {
log.Warnf("failed to setup SSH client config: %v", err)
}
return nil
}
// configureSSHServer applies SSH configuration options to the server.
func (e *Engine) configureSSHServer(server *sshserver.Server) {
if e.config.EnableSSHRoot != nil && *e.config.EnableSSHRoot {
server.SetAllowRootLogin(true)
log.Info("SSH root login enabled")
} else {
server.SetAllowRootLogin(false)
log.Info("SSH root login disabled (default)")
}
if e.config.EnableSSHSFTP != nil && *e.config.EnableSSHSFTP {
server.SetAllowSFTP(true)
log.Info("SSH SFTP subsystem enabled")
} else {
server.SetAllowSFTP(false)
log.Info("SSH SFTP subsystem disabled (default)")
}
if e.config.EnableSSHLocalPortForwarding != nil && *e.config.EnableSSHLocalPortForwarding {
server.SetAllowLocalPortForwarding(true)
log.Info("SSH local port forwarding enabled")
} else {
server.SetAllowLocalPortForwarding(false)
log.Info("SSH local port forwarding disabled (default)")
}
if e.config.EnableSSHRemotePortForwarding != nil && *e.config.EnableSSHRemotePortForwarding {
server.SetAllowRemotePortForwarding(true)
log.Info("SSH remote port forwarding enabled")
} else {
server.SetAllowRemotePortForwarding(false)
log.Info("SSH remote port forwarding disabled (default)")
}
}
func (e *Engine) cleanupSSHPortRedirection() error {
if e.firewall == nil || e.wgInterface == nil {
return nil
}
localAddr := e.wgInterface.Address().IP
if !localAddr.IsValid() {
return errors.New("invalid local NetBird address")
}
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, 22, 22022); err != nil {
return fmt.Errorf("remove SSH port redirection: %w", err)
}
log.Debugf("SSH port redirection removed: %s:22 -> %s:22022", localAddr, localAddr)
return nil
}
func (e *Engine) stopSSHServer() error {
if e.sshServer == nil {
return nil
}
if err := e.cleanupSSHPortRedirection(); err != nil {
log.Warnf("failed to cleanup SSH port redirection: %v", err)
}
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
if registrar, ok := e.firewall.(interface {
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
}); ok {
registrar.UnregisterNetstackService(nftypes.TCP, sshserver.InternalSSHPort)
log.Debugf("unregistered SSH service from netstack for TCP:%d", sshserver.InternalSSHPort)
}
}
log.Info("stopping SSH server")
err := e.sshServer.Stop()
e.sshServer = nil
if err != nil {
return fmt.Errorf("stop: %w", err)
}
return nil
}

View File

@@ -119,6 +119,10 @@ func doMgmLogin(ctx context.Context, mgmClient *mgm.GrpcClient, pubSSHKey []byte
config.BlockLANAccess,
config.BlockInbound,
config.LazyConnectionEnabled,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,
config.EnableSSHRemotePortForwarding,
)
_, err = mgmClient.Login(*serverKey, sysInfo, pubSSHKey, config.DNSLabels)
return serverKey, err
@@ -145,6 +149,10 @@ func registerPeer(ctx context.Context, serverPublicKey wgtypes.Key, client *mgm.
config.BlockLANAccess,
config.BlockInbound,
config.LazyConnectionEnabled,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,
config.EnableSSHRemotePortForwarding,
)
loginResp, err := client.Register(serverPublicKey, validSetupKey.String(), jwtToken, info, pubSSHKey, config.DNSLabels)
if err != nil {

View File

@@ -67,6 +67,7 @@ type State struct {
BytesRx int64
Latency time.Duration
RosenpassEnabled bool
SSHHostKey []byte
routes map[string]struct{}
}
@@ -572,6 +573,22 @@ func (d *Status) UpdatePeerFQDN(peerPubKey, fqdn string) error {
return nil
}
// UpdatePeerSSHHostKey updates peer's SSH host key
func (d *Status) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) error {
d.mux.Lock()
defer d.mux.Unlock()
peerState, ok := d.peers[peerPubKey]
if !ok {
return errors.New("peer doesn't exist")
}
peerState.SSHHostKey = sshHostKey
d.peers[peerPubKey] = peerState
return nil
}
// FinishPeerListModifications this event invoke the notification
func (d *Status) FinishPeerListModifications() {
d.mux.Lock()

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,9 @@ service DaemonService {
rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {}
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
rpc GetPeerSSHHostKey(GetPeerSSHHostKeyRequest) returns (GetPeerSSHHostKeyResponse) {}
}
@@ -109,6 +112,10 @@ message LoginRequest {
optional bool disableAutoConnect = 14;
optional bool serverSSHAllowed = 15;
optional bool enableSSHRoot = 30;
optional bool enableSSHSFTP = 33;
optional bool enableSSHLocalPortForwarding = 31;
optional bool enableSSHRemotePortForwarding = 32;
optional bool rosenpassPermissive = 16;
@@ -199,6 +206,14 @@ message GetConfigResponse {
bool serverSSHAllowed = 10;
bool enableSSHRoot = 21;
bool enableSSHSFTP = 24;
bool enableSSHLocalPortForwarding = 22;
bool enableSSHRemotePortForwarding = 23;
bool rosenpassEnabled = 11;
bool rosenpassPermissive = 12;
@@ -239,6 +254,7 @@ message PeerState {
repeated string networks = 16;
google.protobuf.Duration latency = 17;
string relayAddress = 18;
bytes sshHostKey = 19;
}
// LocalPeerState contains the latest state of the local peer
@@ -496,3 +512,21 @@ message GetEventsRequest {}
message GetEventsResponse {
repeated SystemEvent events = 1;
}
// GetPeerSSHHostKeyRequest for retrieving SSH host key for a specific peer
message GetPeerSSHHostKeyRequest {
// peer IP address or FQDN to get SSH host key for
string peerAddress = 1;
}
// GetPeerSSHHostKeyResponse contains the SSH host key for the requested peer
message GetPeerSSHHostKeyResponse {
// SSH host key in SSH public key format (e.g., "ssh-ed25519 AAAAC3... hostname")
bytes sshHostKey = 1;
// peer IP address
string peerIP = 2;
// peer FQDN
string peerFQDN = 3;
// indicates if the SSH host key was found
bool found = 4;
}

View File

@@ -1,8 +1,4 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// source: daemon.proto
package proto
@@ -15,31 +11,8 @@ import (
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
DaemonService_Login_FullMethodName = "/daemon.DaemonService/Login"
DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin"
DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up"
DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status"
DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down"
DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig"
DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks"
DaemonService_SelectNetworks_FullMethodName = "/daemon.DaemonService/SelectNetworks"
DaemonService_DeselectNetworks_FullMethodName = "/daemon.DaemonService/DeselectNetworks"
DaemonService_ForwardingRules_FullMethodName = "/daemon.DaemonService/ForwardingRules"
DaemonService_DebugBundle_FullMethodName = "/daemon.DaemonService/DebugBundle"
DaemonService_GetLogLevel_FullMethodName = "/daemon.DaemonService/GetLogLevel"
DaemonService_SetLogLevel_FullMethodName = "/daemon.DaemonService/SetLogLevel"
DaemonService_ListStates_FullMethodName = "/daemon.DaemonService/ListStates"
DaemonService_CleanState_FullMethodName = "/daemon.DaemonService/CleanState"
DaemonService_DeleteState_FullMethodName = "/daemon.DaemonService/DeleteState"
DaemonService_SetNetworkMapPersistence_FullMethodName = "/daemon.DaemonService/SetNetworkMapPersistence"
DaemonService_TracePacket_FullMethodName = "/daemon.DaemonService/TracePacket"
DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents"
DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents"
)
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// DaemonServiceClient is the client API for DaemonService service.
//
@@ -80,8 +53,10 @@ type DaemonServiceClient interface {
// SetNetworkMapPersistence enables or disables network map persistence
SetNetworkMapPersistence(ctx context.Context, in *SetNetworkMapPersistenceRequest, opts ...grpc.CallOption) (*SetNetworkMapPersistenceResponse, error)
TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error)
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error)
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error)
GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error)
}
type daemonServiceClient struct {
@@ -93,9 +68,8 @@ func NewDaemonServiceClient(cc grpc.ClientConnInterface) DaemonServiceClient {
}
func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LoginResponse)
err := c.cc.Invoke(ctx, DaemonService_Login_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/Login", in, out, opts...)
if err != nil {
return nil, err
}
@@ -103,9 +77,8 @@ func (c *daemonServiceClient) Login(ctx context.Context, in *LoginRequest, opts
}
func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLoginRequest, opts ...grpc.CallOption) (*WaitSSOLoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(WaitSSOLoginResponse)
err := c.cc.Invoke(ctx, DaemonService_WaitSSOLogin_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/WaitSSOLogin", in, out, opts...)
if err != nil {
return nil, err
}
@@ -113,9 +86,8 @@ func (c *daemonServiceClient) WaitSSOLogin(ctx context.Context, in *WaitSSOLogin
}
func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpResponse)
err := c.cc.Invoke(ctx, DaemonService_Up_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/Up", in, out, opts...)
if err != nil {
return nil, err
}
@@ -123,9 +95,8 @@ func (c *daemonServiceClient) Up(ctx context.Context, in *UpRequest, opts ...grp
}
func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StatusResponse)
err := c.cc.Invoke(ctx, DaemonService_Status_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/Status", in, out, opts...)
if err != nil {
return nil, err
}
@@ -133,9 +104,8 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt
}
func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DownResponse)
err := c.cc.Invoke(ctx, DaemonService_Down_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/Down", in, out, opts...)
if err != nil {
return nil, err
}
@@ -143,9 +113,8 @@ func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ..
}
func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetConfigResponse)
err := c.cc.Invoke(ctx, DaemonService_GetConfig_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetConfig", in, out, opts...)
if err != nil {
return nil, err
}
@@ -153,9 +122,8 @@ func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigReques
}
func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworksRequest, opts ...grpc.CallOption) (*ListNetworksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListNetworksResponse)
err := c.cc.Invoke(ctx, DaemonService_ListNetworks_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListNetworks", in, out, opts...)
if err != nil {
return nil, err
}
@@ -163,9 +131,8 @@ func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworks
}
func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SelectNetworksResponse)
err := c.cc.Invoke(ctx, DaemonService_SelectNetworks_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/SelectNetworks", in, out, opts...)
if err != nil {
return nil, err
}
@@ -173,9 +140,8 @@ func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetw
}
func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SelectNetworksResponse)
err := c.cc.Invoke(ctx, DaemonService_DeselectNetworks_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeselectNetworks", in, out, opts...)
if err != nil {
return nil, err
}
@@ -183,9 +149,8 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe
}
func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ForwardingRulesResponse)
err := c.cc.Invoke(ctx, DaemonService_ForwardingRules_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/ForwardingRules", in, out, opts...)
if err != nil {
return nil, err
}
@@ -193,9 +158,8 @@ func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequ
}
func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DebugBundleResponse)
err := c.cc.Invoke(ctx, DaemonService_DebugBundle_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/DebugBundle", in, out, opts...)
if err != nil {
return nil, err
}
@@ -203,9 +167,8 @@ func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRe
}
func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRequest, opts ...grpc.CallOption) (*GetLogLevelResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLogLevelResponse)
err := c.cc.Invoke(ctx, DaemonService_GetLogLevel_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetLogLevel", in, out, opts...)
if err != nil {
return nil, err
}
@@ -213,9 +176,8 @@ func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRe
}
func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRequest, opts ...grpc.CallOption) (*SetLogLevelResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetLogLevelResponse)
err := c.cc.Invoke(ctx, DaemonService_SetLogLevel_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetLogLevel", in, out, opts...)
if err != nil {
return nil, err
}
@@ -223,9 +185,8 @@ func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRe
}
func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequest, opts ...grpc.CallOption) (*ListStatesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListStatesResponse)
err := c.cc.Invoke(ctx, DaemonService_ListStates_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListStates", in, out, opts...)
if err != nil {
return nil, err
}
@@ -233,9 +194,8 @@ func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequ
}
func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequest, opts ...grpc.CallOption) (*CleanStateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CleanStateResponse)
err := c.cc.Invoke(ctx, DaemonService_CleanState_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/CleanState", in, out, opts...)
if err != nil {
return nil, err
}
@@ -243,9 +203,8 @@ func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequ
}
func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteStateResponse)
err := c.cc.Invoke(ctx, DaemonService_DeleteState_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeleteState", in, out, opts...)
if err != nil {
return nil, err
}
@@ -253,9 +212,8 @@ func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRe
}
func (c *daemonServiceClient) SetNetworkMapPersistence(ctx context.Context, in *SetNetworkMapPersistenceRequest, opts ...grpc.CallOption) (*SetNetworkMapPersistenceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetNetworkMapPersistenceResponse)
err := c.cc.Invoke(ctx, DaemonService_SetNetworkMapPersistence_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetNetworkMapPersistence", in, out, opts...)
if err != nil {
return nil, err
}
@@ -263,22 +221,20 @@ func (c *daemonServiceClient) SetNetworkMapPersistence(ctx context.Context, in *
}
func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TracePacketResponse)
err := c.cc.Invoke(ctx, DaemonService_TracePacket_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/TracePacket", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeEvents_FullMethodName, cOpts...)
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) {
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/SubscribeEvents", opts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[SubscribeRequest, SystemEvent]{ClientStream: stream}
x := &daemonServiceSubscribeEventsClient{stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
@@ -288,13 +244,35 @@ func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *Subscribe
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_SubscribeEventsClient = grpc.ServerStreamingClient[SystemEvent]
type DaemonService_SubscribeEventsClient interface {
Recv() (*SystemEvent, error)
grpc.ClientStream
}
type daemonServiceSubscribeEventsClient struct {
grpc.ClientStream
}
func (x *daemonServiceSubscribeEventsClient) Recv() (*SystemEvent, error) {
m := new(SystemEvent)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetEventsResponse)
err := c.cc.Invoke(ctx, DaemonService_GetEvents_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetEvents", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) {
out := new(GetPeerSSHHostKeyResponse)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetPeerSSHHostKey", in, out, opts...)
if err != nil {
return nil, err
}
@@ -303,7 +281,7 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility.
// for forward compatibility
type DaemonServiceServer interface {
// Login uses setup key to prepare configuration for the daemon.
Login(context.Context, *LoginRequest) (*LoginResponse, error)
@@ -340,17 +318,16 @@ type DaemonServiceServer interface {
// SetNetworkMapPersistence enables or disables network map persistence
SetNetworkMapPersistence(context.Context, *SetNetworkMapPersistenceRequest) (*SetNetworkMapPersistenceResponse, error)
TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error)
SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error
SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error)
mustEmbedUnimplementedDaemonServiceServer()
}
// UnimplementedDaemonServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedDaemonServiceServer struct{}
// UnimplementedDaemonServiceServer must be embedded to have forward compatible implementations.
type UnimplementedDaemonServiceServer struct {
}
func (UnimplementedDaemonServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
@@ -406,14 +383,16 @@ func (UnimplementedDaemonServiceServer) SetNetworkMapPersistence(context.Context
func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented")
}
func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error {
func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error {
return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented")
}
func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetEvents not implemented")
}
func (UnimplementedDaemonServiceServer) GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetPeerSSHHostKey not implemented")
}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {}
// UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DaemonServiceServer will
@@ -423,13 +402,6 @@ type UnsafeDaemonServiceServer interface {
}
func RegisterDaemonServiceServer(s grpc.ServiceRegistrar, srv DaemonServiceServer) {
// If the following call pancis, it indicates UnimplementedDaemonServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&DaemonService_ServiceDesc, srv)
}
@@ -443,7 +415,7 @@ func _DaemonService_Login_Handler(srv interface{}, ctx context.Context, dec func
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_Login_FullMethodName,
FullMethod: "/daemon.DaemonService/Login",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).Login(ctx, req.(*LoginRequest))
@@ -461,7 +433,7 @@ func _DaemonService_WaitSSOLogin_Handler(srv interface{}, ctx context.Context, d
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_WaitSSOLogin_FullMethodName,
FullMethod: "/daemon.DaemonService/WaitSSOLogin",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).WaitSSOLogin(ctx, req.(*WaitSSOLoginRequest))
@@ -479,7 +451,7 @@ func _DaemonService_Up_Handler(srv interface{}, ctx context.Context, dec func(in
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_Up_FullMethodName,
FullMethod: "/daemon.DaemonService/Up",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).Up(ctx, req.(*UpRequest))
@@ -497,7 +469,7 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_Status_FullMethodName,
FullMethod: "/daemon.DaemonService/Status",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).Status(ctx, req.(*StatusRequest))
@@ -515,7 +487,7 @@ func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_Down_FullMethodName,
FullMethod: "/daemon.DaemonService/Down",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).Down(ctx, req.(*DownRequest))
@@ -533,7 +505,7 @@ func _DaemonService_GetConfig_Handler(srv interface{}, ctx context.Context, dec
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_GetConfig_FullMethodName,
FullMethod: "/daemon.DaemonService/GetConfig",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).GetConfig(ctx, req.(*GetConfigRequest))
@@ -551,7 +523,7 @@ func _DaemonService_ListNetworks_Handler(srv interface{}, ctx context.Context, d
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ListNetworks_FullMethodName,
FullMethod: "/daemon.DaemonService/ListNetworks",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ListNetworks(ctx, req.(*ListNetworksRequest))
@@ -569,7 +541,7 @@ func _DaemonService_SelectNetworks_Handler(srv interface{}, ctx context.Context,
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_SelectNetworks_FullMethodName,
FullMethod: "/daemon.DaemonService/SelectNetworks",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).SelectNetworks(ctx, req.(*SelectNetworksRequest))
@@ -587,7 +559,7 @@ func _DaemonService_DeselectNetworks_Handler(srv interface{}, ctx context.Contex
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_DeselectNetworks_FullMethodName,
FullMethod: "/daemon.DaemonService/DeselectNetworks",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).DeselectNetworks(ctx, req.(*SelectNetworksRequest))
@@ -605,7 +577,7 @@ func _DaemonService_ForwardingRules_Handler(srv interface{}, ctx context.Context
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ForwardingRules_FullMethodName,
FullMethod: "/daemon.DaemonService/ForwardingRules",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ForwardingRules(ctx, req.(*EmptyRequest))
@@ -623,7 +595,7 @@ func _DaemonService_DebugBundle_Handler(srv interface{}, ctx context.Context, de
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_DebugBundle_FullMethodName,
FullMethod: "/daemon.DaemonService/DebugBundle",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).DebugBundle(ctx, req.(*DebugBundleRequest))
@@ -641,7 +613,7 @@ func _DaemonService_GetLogLevel_Handler(srv interface{}, ctx context.Context, de
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_GetLogLevel_FullMethodName,
FullMethod: "/daemon.DaemonService/GetLogLevel",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).GetLogLevel(ctx, req.(*GetLogLevelRequest))
@@ -659,7 +631,7 @@ func _DaemonService_SetLogLevel_Handler(srv interface{}, ctx context.Context, de
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_SetLogLevel_FullMethodName,
FullMethod: "/daemon.DaemonService/SetLogLevel",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).SetLogLevel(ctx, req.(*SetLogLevelRequest))
@@ -677,7 +649,7 @@ func _DaemonService_ListStates_Handler(srv interface{}, ctx context.Context, dec
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ListStates_FullMethodName,
FullMethod: "/daemon.DaemonService/ListStates",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ListStates(ctx, req.(*ListStatesRequest))
@@ -695,7 +667,7 @@ func _DaemonService_CleanState_Handler(srv interface{}, ctx context.Context, dec
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_CleanState_FullMethodName,
FullMethod: "/daemon.DaemonService/CleanState",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).CleanState(ctx, req.(*CleanStateRequest))
@@ -713,7 +685,7 @@ func _DaemonService_DeleteState_Handler(srv interface{}, ctx context.Context, de
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_DeleteState_FullMethodName,
FullMethod: "/daemon.DaemonService/DeleteState",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).DeleteState(ctx, req.(*DeleteStateRequest))
@@ -731,7 +703,7 @@ func _DaemonService_SetNetworkMapPersistence_Handler(srv interface{}, ctx contex
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_SetNetworkMapPersistence_FullMethodName,
FullMethod: "/daemon.DaemonService/SetNetworkMapPersistence",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).SetNetworkMapPersistence(ctx, req.(*SetNetworkMapPersistenceRequest))
@@ -749,7 +721,7 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_TracePacket_FullMethodName,
FullMethod: "/daemon.DaemonService/TracePacket",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).TracePacket(ctx, req.(*TracePacketRequest))
@@ -762,11 +734,21 @@ func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerS
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DaemonServiceServer).SubscribeEvents(m, &grpc.GenericServerStream[SubscribeRequest, SystemEvent]{ServerStream: stream})
return srv.(DaemonServiceServer).SubscribeEvents(m, &daemonServiceSubscribeEventsServer{stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_SubscribeEventsServer = grpc.ServerStreamingServer[SystemEvent]
type DaemonService_SubscribeEventsServer interface {
Send(*SystemEvent) error
grpc.ServerStream
}
type daemonServiceSubscribeEventsServer struct {
grpc.ServerStream
}
func (x *daemonServiceSubscribeEventsServer) Send(m *SystemEvent) error {
return x.ServerStream.SendMsg(m)
}
func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetEventsRequest)
@@ -778,7 +760,7 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_GetEvents_FullMethodName,
FullMethod: "/daemon.DaemonService/GetEvents",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).GetEvents(ctx, req.(*GetEventsRequest))
@@ -786,6 +768,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _DaemonService_GetPeerSSHHostKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetPeerSSHHostKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/daemon.DaemonService/GetPeerSSHHostKey",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, req.(*GetPeerSSHHostKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -869,6 +869,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetEvents",
Handler: _DaemonService_GetEvents_Handler,
},
{
MethodName: "GetPeerSSHHostKey",
Handler: _DaemonService_GetPeerSSHHostKey_Handler,
},
},
Streams: []grpc.StreamDesc{
{

View File

@@ -408,6 +408,22 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
inputConfig.BlockInbound = msg.BlockInbound
s.latestConfigInput.BlockInbound = msg.BlockInbound
}
if msg.EnableSSHRoot != nil {
inputConfig.EnableSSHRoot = msg.EnableSSHRoot
s.latestConfigInput.EnableSSHRoot = msg.EnableSSHRoot
}
if msg.EnableSSHSFTP != nil {
inputConfig.EnableSSHSFTP = msg.EnableSSHSFTP
s.latestConfigInput.EnableSSHSFTP = msg.EnableSSHSFTP
}
if msg.EnableSSHLocalPortForwarding != nil {
inputConfig.EnableSSHLocalPortForwarding = msg.EnableSSHLocalPortForwarding
s.latestConfigInput.EnableSSHLocalPortForwarding = msg.EnableSSHLocalPortForwarding
}
if msg.EnableSSHRemotePortForwarding != nil {
inputConfig.EnableSSHRemotePortForwarding = msg.EnableSSHRemotePortForwarding
s.latestConfigInput.EnableSSHRemotePortForwarding = msg.EnableSSHRemotePortForwarding
}
if msg.CleanDNSLabels {
inputConfig.DNSLabels = domain.List{}
@@ -720,6 +736,45 @@ func (s *Server) Status(
return &statusResponse, nil
}
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
func (s *Server) GetPeerSSHHostKey(
ctx context.Context,
req *proto.GetPeerSSHHostKeyRequest,
) (*proto.GetPeerSSHHostKeyResponse, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
s.mutex.Lock()
defer s.mutex.Unlock()
response := &proto.GetPeerSSHHostKeyResponse{
Found: false,
}
if s.statusRecorder == nil {
return response, nil
}
fullStatus := s.statusRecorder.GetFullStatus()
peerAddress := req.GetPeerAddress()
// Search for peer by IP or FQDN
for _, peerState := range fullStatus.Peers {
if peerState.IP == peerAddress || peerState.FQDN == peerAddress {
if len(peerState.SSHHostKey) > 0 {
response.SshHostKey = peerState.SSHHostKey
response.PeerIP = peerState.IP
response.PeerFQDN = peerState.FQDN
response.Found = true
}
break
}
}
return response, nil
}
func (s *Server) runProbes() {
if s.connectClient == nil {
return
@@ -776,26 +831,50 @@ func (s *Server) GetConfig(_ context.Context, _ *proto.GetConfigRequest) (*proto
disableServerRoutes := s.config.DisableServerRoutes
blockLANAccess := s.config.BlockLANAccess
enableSSHRoot := false
if s.config.EnableSSHRoot != nil {
enableSSHRoot = *s.config.EnableSSHRoot
}
enableSSHSFTP := false
if s.config.EnableSSHSFTP != nil {
enableSSHSFTP = *s.config.EnableSSHSFTP
}
enableSSHLocalPortForwarding := false
if s.config.EnableSSHLocalPortForwarding != nil {
enableSSHLocalPortForwarding = *s.config.EnableSSHLocalPortForwarding
}
enableSSHRemotePortForwarding := false
if s.config.EnableSSHRemotePortForwarding != nil {
enableSSHRemotePortForwarding = *s.config.EnableSSHRemotePortForwarding
}
return &proto.GetConfigResponse{
ManagementUrl: managementURL,
ConfigFile: s.latestConfigInput.ConfigPath,
LogFile: s.logFile,
PreSharedKey: preSharedKey,
AdminURL: adminURL,
InterfaceName: s.config.WgIface,
WireguardPort: int64(s.config.WgPort),
DisableAutoConnect: s.config.DisableAutoConnect,
ServerSSHAllowed: *s.config.ServerSSHAllowed,
RosenpassEnabled: s.config.RosenpassEnabled,
RosenpassPermissive: s.config.RosenpassPermissive,
LazyConnectionEnabled: s.config.LazyConnectionEnabled,
BlockInbound: s.config.BlockInbound,
DisableNotifications: disableNotifications,
NetworkMonitor: networkMonitor,
DisableDns: disableDNS,
DisableClientRoutes: disableClientRoutes,
DisableServerRoutes: disableServerRoutes,
BlockLanAccess: blockLANAccess,
ManagementUrl: managementURL,
ConfigFile: s.latestConfigInput.ConfigPath,
LogFile: s.logFile,
PreSharedKey: preSharedKey,
AdminURL: adminURL,
InterfaceName: s.config.WgIface,
WireguardPort: int64(s.config.WgPort),
DisableAutoConnect: s.config.DisableAutoConnect,
ServerSSHAllowed: *s.config.ServerSSHAllowed,
RosenpassEnabled: s.config.RosenpassEnabled,
RosenpassPermissive: s.config.RosenpassPermissive,
LazyConnectionEnabled: s.config.LazyConnectionEnabled,
BlockInbound: s.config.BlockInbound,
DisableNotifications: disableNotifications,
NetworkMonitor: networkMonitor,
DisableDns: disableDNS,
DisableClientRoutes: disableClientRoutes,
DisableServerRoutes: disableServerRoutes,
BlockLanAccess: blockLANAccess,
EnableSSHRoot: enableSSHRoot,
EnableSSHSFTP: enableSSHSFTP,
EnableSSHLocalPortForwarding: enableSSHLocalPortForwarding,
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
}, nil
}
@@ -859,6 +938,7 @@ func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
RosenpassEnabled: peerState.RosenpassEnabled,
Networks: maps.Keys(peerState.GetRoutes()),
Latency: durationpb.New(peerState.Latency),
SshHostKey: peerState.SSHHostKey,
}
pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState)
}

View File

@@ -1,297 +0,0 @@
package ssh
import (
"context"
"errors"
"fmt"
"net"
"os"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
)
// Client wraps crypto/ssh Client for simplified SSH operations
type Client struct {
client *ssh.Client
terminalState *term.State
terminalFd int
// Windows-specific console state
windowsStdoutMode uint32 // nolint:unused // Used in Windows-specific terminal restoration
windowsStdinMode uint32 // nolint:unused // Used in Windows-specific terminal restoration
}
// Close terminates the SSH connection
func (c *Client) Close() error {
return c.client.Close()
}
// OpenTerminal opens an interactive terminal session
func (c *Client) OpenTerminal(ctx context.Context) error {
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
defer func() {
_ = session.Close()
}()
if err := c.setupTerminalMode(ctx, session); err != nil {
return err
}
c.setupSessionIO(session)
if err := session.Shell(); err != nil {
return fmt.Errorf("start shell: %w", err)
}
return c.waitForSession(ctx, session)
}
// setupSessionIO connects session streams to local terminal
func (c *Client) setupSessionIO(session *ssh.Session) {
session.Stdout = os.Stdout
session.Stderr = os.Stderr
session.Stdin = os.Stdin
}
// waitForSession waits for the session to complete with context cancellation
func (c *Client) waitForSession(ctx context.Context, session *ssh.Session) error {
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
defer c.restoreTerminal()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return c.handleSessionError(err)
}
}
// handleSessionError processes session termination errors
func (c *Client) handleSessionError(err error) error {
if err == nil {
return nil
}
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
// Only return actual errors (not exit status errors)
return fmt.Errorf("session wait: %w", err)
}
// SSH should behave like regular command execution:
// Non-zero exit codes are normal and should not be treated as errors
// The command ran successfully, it just returned a non-zero exit code
// ExitMissingError is also normal - session was torn down cleanly
return nil
}
// restoreTerminal restores the terminal to its original state
func (c *Client) restoreTerminal() {
if c.terminalState != nil {
_ = term.Restore(c.terminalFd, c.terminalState)
c.terminalState = nil
c.terminalFd = 0
}
// Windows console restoration
c.restoreWindowsConsoleState()
}
// ExecuteCommand executes a command on the remote host and returns the output
func (c *Client) ExecuteCommand(ctx context.Context, command string) ([]byte, error) {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return nil, err
}
defer cleanup()
// Execute the command and capture output
output, err := session.CombinedOutput(command)
if err != nil {
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
// Only return actual errors (not exit status errors)
return output, fmt.Errorf("execute command: %w", err)
}
// SSH should behave like regular command execution:
// Non-zero exit codes are normal and should not be treated as errors
// ExitMissingError is also normal - session was torn down cleanly
// Return the output even for non-zero exit codes
}
return output, nil
}
func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return fmt.Errorf("create session: %w", err)
}
defer cleanup()
c.setupSessionIO(session)
if err := session.Start(command); err != nil {
return fmt.Errorf("start command: %w", err)
}
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
// Wait a bit for the signal to take effect, then return context error
select {
case <-done:
// Process exited due to signal, this is expected
return ctx.Err()
case <-time.After(100 * time.Millisecond):
// Signal didn't take effect quickly, still return context error
return ctx.Err()
}
case err := <-done:
return c.handleCommandError(err)
}
}
func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) error {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return err
}
defer cleanup()
if err := c.setupTerminalMode(ctx, session); err != nil {
return fmt.Errorf("setup terminal mode: %w", err)
}
c.setupSessionIO(session)
if err := session.Start(command); err != nil {
return fmt.Errorf("start command: %w", err)
}
defer c.restoreTerminal()
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
// Wait a bit for the signal to take effect, then return context error
select {
case <-done:
// Process exited due to signal, this is expected
return ctx.Err()
case <-time.After(100 * time.Millisecond):
// Signal didn't take effect quickly, still return context error
return ctx.Err()
}
case err := <-done:
return c.handleCommandError(err)
}
}
func (c *Client) handleCommandError(err error) error {
if err == nil {
return nil
}
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
return fmt.Errorf("execute command: %w", err)
}
// SSH should behave like regular command execution:
// Non-zero exit codes are normal and should not be treated as errors
// ExitMissingError is also normal - session was torn down cleanly
return nil
}
// setupContextCancellation sets up context cancellation for a session
func (c *Client) setupContextCancellation(ctx context.Context, session *ssh.Session) func() {
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
_ = session.Close()
case <-done:
}
}()
return func() { close(done) }
}
// createSession creates a new SSH session with context cancellation setup
func (c *Client) createSession(ctx context.Context) (*ssh.Session, func(), error) {
session, err := c.client.NewSession()
if err != nil {
return nil, nil, fmt.Errorf("new session: %w", err)
}
cancel := c.setupContextCancellation(ctx, session)
cleanup := func() {
cancel()
_ = session.Close()
}
return session, cleanup, nil
}
// DialWithKey connects using private key authentication
func DialWithKey(ctx context.Context, addr, user string, privateKey []byte) (*Client, error) {
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
config := &ssh.ClientConfig{
User: user,
Timeout: 30 * time.Second,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }),
}
return Dial(ctx, "tcp", addr, config)
}
// Dial establishes an SSH connection
func Dial(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) {
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
if closeErr := conn.Close(); closeErr != nil {
return nil, fmt.Errorf("ssh handshake: %w (failed to close connection: %v)", err, closeErr)
}
return nil, fmt.Errorf("ssh handshake: %w", err)
}
client := ssh.NewClient(clientConn, chans, reqs)
return &Client{
client: client,
}, nil
}

712
client/ssh/client/client.go Normal file
View File

@@ -0,0 +1,712 @@
package client
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
)
// Client wraps crypto/ssh Client for simplified SSH operations
type Client struct {
client *ssh.Client
terminalState *term.State
terminalFd int
windowsStdoutMode uint32 // nolint:unused
windowsStdinMode uint32 // nolint:unused
}
// Close terminates the SSH connection
func (c *Client) Close() error {
return c.client.Close()
}
// OpenTerminal opens an interactive terminal session
func (c *Client) OpenTerminal(ctx context.Context) error {
session, err := c.client.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
defer func() {
if err := session.Close(); err != nil {
log.Debugf("session close error: %v", err)
}
}()
if err := c.setupTerminalMode(ctx, session); err != nil {
return err
}
c.setupSessionIO(ctx, session)
if err := session.Shell(); err != nil {
return fmt.Errorf("start shell: %w", err)
}
return c.waitForSession(ctx, session)
}
// setupSessionIO connects session streams to local terminal
func (c *Client) setupSessionIO(ctx context.Context, session *ssh.Session) {
session.Stdout = os.Stdout
session.Stderr = os.Stderr
session.Stdin = os.Stdin
}
// waitForSession waits for the session to complete with context cancellation
func (c *Client) waitForSession(ctx context.Context, session *ssh.Session) error {
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
defer c.restoreTerminal()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return c.handleSessionError(err)
}
}
// handleSessionError processes session termination errors
func (c *Client) handleSessionError(err error) error {
if err == nil {
return nil
}
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
return fmt.Errorf("session wait: %w", err)
}
return nil
}
// restoreTerminal restores the terminal to its original state
func (c *Client) restoreTerminal() {
if c.terminalState != nil {
_ = term.Restore(c.terminalFd, c.terminalState)
c.terminalState = nil
c.terminalFd = 0
}
if err := c.restoreWindowsConsoleState(); err != nil {
log.Debugf("restore Windows console state: %v", err)
}
}
// ExecuteCommand executes a command on the remote host and returns the output
func (c *Client) ExecuteCommand(ctx context.Context, command string) ([]byte, error) {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return nil, err
}
defer cleanup()
output, err := session.CombinedOutput(command)
if err != nil {
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
return output, fmt.Errorf("execute command: %w", err)
}
}
return output, nil
}
// ExecuteCommandWithIO executes a command with interactive I/O connected to local terminal
func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return fmt.Errorf("create session: %w", err)
}
defer cleanup()
c.setupSessionIO(ctx, session)
if err := session.Start(command); err != nil {
return fmt.Errorf("start command: %w", err)
}
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
select {
case <-done:
return ctx.Err()
case <-time.After(100 * time.Millisecond):
return ctx.Err()
}
case err := <-done:
return c.handleCommandError(err)
}
}
// ExecuteCommandWithPTY executes a command with a pseudo-terminal for interactive sessions
func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) error {
session, cleanup, err := c.createSession(ctx)
if err != nil {
return err
}
defer cleanup()
if err := c.setupTerminalMode(ctx, session); err != nil {
return fmt.Errorf("setup terminal mode: %w", err)
}
c.setupSessionIO(ctx, session)
if err := session.Start(command); err != nil {
return fmt.Errorf("start command: %w", err)
}
defer c.restoreTerminal()
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
select {
case <-done:
return ctx.Err()
case <-time.After(100 * time.Millisecond):
return ctx.Err()
}
case err := <-done:
return c.handleCommandError(err)
}
}
// handleCommandError processes command execution errors, treating exit codes as normal
func (c *Client) handleCommandError(err error) error {
if err == nil {
return nil
}
var e *ssh.ExitError
var em *ssh.ExitMissingError
if !errors.As(err, &e) && !errors.As(err, &em) {
return fmt.Errorf("execute command: %w", err)
}
return nil
}
// setupContextCancellation sets up context cancellation for a session
func (c *Client) setupContextCancellation(ctx context.Context, session *ssh.Session) func() {
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
_ = session.Close()
case <-done:
}
}()
return func() { close(done) }
}
// createSession creates a new SSH session with context cancellation setup
func (c *Client) createSession(ctx context.Context) (*ssh.Session, func(), error) {
session, err := c.client.NewSession()
if err != nil {
return nil, nil, fmt.Errorf("new session: %w", err)
}
cancel := c.setupContextCancellation(ctx, session)
cleanup := func() {
cancel()
_ = session.Close()
}
return session, cleanup, nil
}
// Dial connects to the given ssh server with proper host key verification
func Dial(ctx context.Context, addr, user string) (*Client, error) {
hostKeyCallback, err := createHostKeyCallback(addr)
if err != nil {
return nil, fmt.Errorf("create host key callback: %w", err)
}
config := &ssh.ClientConfig{
User: user,
Timeout: 30 * time.Second,
HostKeyCallback: hostKeyCallback,
}
return dial(ctx, "tcp", addr, config)
}
// DialInsecure connects to the given ssh server without host key verification (for testing only)
func DialInsecure(ctx context.Context, addr, user string) (*Client, error) {
config := &ssh.ClientConfig{
User: user,
Timeout: 30 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
return dial(ctx, "tcp", addr, config)
}
// DialOptions contains options for SSH connections
type DialOptions struct {
KnownHostsFile string
IdentityFile string
DaemonAddr string
}
// DialWithOptions connects to the given ssh server with specified options
func DialWithOptions(ctx context.Context, addr, user string, opts DialOptions) (*Client, error) {
hostKeyCallback, err := createHostKeyCallbackWithOptions(addr, opts)
if err != nil {
return nil, fmt.Errorf("create host key callback: %w", err)
}
config := &ssh.ClientConfig{
User: user,
Timeout: 30 * time.Second,
HostKeyCallback: hostKeyCallback,
}
// Add SSH key authentication if identity file is specified
if opts.IdentityFile != "" {
authMethod, err := createSSHKeyAuth(opts.IdentityFile)
if err != nil {
return nil, fmt.Errorf("create SSH key auth: %w", err)
}
config.Auth = append(config.Auth, authMethod)
}
return dial(ctx, "tcp", addr, config)
}
// dial establishes an SSH connection
func dial(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) {
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("connection close after handshake failure: %v", closeErr)
}
return nil, fmt.Errorf("ssh handshake: %w", err)
}
client := ssh.NewClient(clientConn, chans, reqs)
return &Client{
client: client,
}, nil
}
// createHostKeyCallback creates a host key verification callback that checks daemon first, then known_hosts files
func createHostKeyCallback(addr string) (ssh.HostKeyCallback, error) {
return createHostKeyCallbackWithDaemonAddr(addr, "unix:///var/run/netbird.sock")
}
// createHostKeyCallbackWithDaemonAddr creates a host key verification callback with specified daemon address
func createHostKeyCallbackWithDaemonAddr(addr, daemonAddr string) (ssh.HostKeyCallback, error) {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// First try to get host key from NetBird daemon
if err := verifyHostKeyViaDaemon(hostname, remote, key, daemonAddr); err == nil {
return nil
}
// Fallback to known_hosts files
knownHostsFiles := getKnownHostsFiles()
var hostKeyCallbacks []ssh.HostKeyCallback
for _, file := range knownHostsFiles {
if callback, err := knownhosts.New(file); err == nil {
hostKeyCallbacks = append(hostKeyCallbacks, callback)
}
}
// Try each known_hosts callback
for _, callback := range hostKeyCallbacks {
if err := callback(hostname, remote, key); err == nil {
return nil
}
}
return fmt.Errorf("host key verification failed: key not found in NetBird daemon or any known_hosts file")
}, nil
}
// verifyHostKeyViaDaemon verifies SSH host key by querying the NetBird daemon
func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, daemonAddr string) error {
// Connect to NetBird daemon using the same logic as CLI
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
strings.TrimPrefix(daemonAddr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
log.Debugf("failed to connect to NetBird daemon at %s: %v", daemonAddr, err)
return fmt.Errorf("failed to connect to NetBird daemon: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("daemon connection close error: %v", err)
}
}()
client := proto.NewDaemonServiceClient(conn)
// Try both hostname and IP address from remote.String()
addresses := []string{hostname}
if host, _, err := net.SplitHostPort(remote.String()); err == nil {
if host != hostname {
addresses = append(addresses, host)
}
}
log.Debugf("verifying SSH host key for hostname=%s, remote=%s, addresses=%v", hostname, remote.String(), addresses)
for _, addr := range addresses {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
response, err := client.GetPeerSSHHostKey(ctx, &proto.GetPeerSSHHostKeyRequest{
PeerAddress: addr,
})
cancel()
log.Debugf("daemon query for address %s: found=%v, error=%v", addr, response != nil && response.GetFound(), err)
if err != nil {
log.Debugf("daemon query error for %s: %v", addr, err)
continue
}
if !response.GetFound() {
log.Debugf("SSH host key not found in daemon for address: %s", addr)
continue
}
// Parse the stored SSH host key
storedKey, _, _, _, err := ssh.ParseAuthorizedKey(response.GetSshHostKey())
if err != nil {
log.Debugf("failed to parse stored SSH host key for %s: %v", addr, err)
continue
}
// Compare the keys
if key.Type() == storedKey.Type() && string(key.Marshal()) == string(storedKey.Marshal()) {
log.Debugf("SSH host key verified via NetBird daemon for %s", addr)
return nil
} else {
log.Debugf("SSH host key mismatch for %s: stored type=%s, presented type=%s", addr, storedKey.Type(), key.Type())
}
}
return fmt.Errorf("SSH host key not found or does not match in NetBird daemon")
}
// getKnownHostsFiles returns paths to known_hosts files in order of preference
func getKnownHostsFiles() []string {
var files []string
// User's known_hosts file (highest priority)
if homeDir, err := os.UserHomeDir(); err == nil {
userKnownHosts := filepath.Join(homeDir, ".ssh", "known_hosts")
files = append(files, userKnownHosts)
}
// NetBird managed known_hosts files
if runtime.GOOS == "windows" {
programData := os.Getenv("PROGRAMDATA")
if programData == "" {
programData = `C:\ProgramData`
}
netbirdKnownHosts := filepath.Join(programData, "ssh", "ssh_known_hosts.d", "99-netbird")
files = append(files, netbirdKnownHosts)
} else {
files = append(files, "/etc/ssh/ssh_known_hosts.d/99-netbird")
files = append(files, "/etc/ssh/ssh_known_hosts")
}
return files
}
// createHostKeyCallbackWithOptions creates a host key verification callback with custom options
func createHostKeyCallbackWithOptions(addr string, opts DialOptions) (ssh.HostKeyCallback, error) {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// First try to get host key from NetBird daemon (if daemon address provided)
if opts.DaemonAddr != "" {
if err := verifyHostKeyViaDaemon(hostname, remote, key, opts.DaemonAddr); err == nil {
return nil
}
}
// Fallback to known_hosts files
var knownHostsFiles []string
if opts.KnownHostsFile != "" {
knownHostsFiles = append(knownHostsFiles, opts.KnownHostsFile)
} else {
knownHostsFiles = getKnownHostsFiles()
}
var hostKeyCallbacks []ssh.HostKeyCallback
for _, file := range knownHostsFiles {
if callback, err := knownhosts.New(file); err == nil {
hostKeyCallbacks = append(hostKeyCallbacks, callback)
}
}
// Try each known_hosts callback
for _, callback := range hostKeyCallbacks {
if err := callback(hostname, remote, key); err == nil {
return nil
}
}
return fmt.Errorf("host key verification failed: key not found in NetBird daemon or any known_hosts file")
}, nil
}
// createSSHKeyAuth creates SSH key authentication from a private key file
func createSSHKeyAuth(keyFile string) (ssh.AuthMethod, error) {
keyData, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("read SSH key file %s: %w", keyFile, err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse SSH private key: %w", err)
}
return ssh.PublicKeys(signer), nil
}
// LocalPortForward sets up local port forwarding, binding to localAddr and forwarding to remoteAddr
func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr string) error {
localListener, err := net.Listen("tcp", localAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", localAddr, err)
}
go func() {
defer func() {
if err := localListener.Close(); err != nil {
log.Debugf("local listener close error: %v", err)
}
}()
for {
localConn, err := localListener.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
go c.handleLocalForward(localConn, remoteAddr)
}
}()
<-ctx.Done()
return ctx.Err()
}
// handleLocalForward handles a single local port forwarding connection
func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) {
defer func() {
if err := localConn.Close(); err != nil {
log.Debugf("local connection close error: %v", err)
}
}()
channel, err := c.client.Dial("tcp", remoteAddr)
if err != nil {
if strings.Contains(err.Error(), "administratively prohibited") {
_, _ = fmt.Fprintf(os.Stderr, "channel open failed: administratively prohibited: port forwarding is disabled\n")
} else {
log.Debugf("local port forwarding to %s failed: %v", remoteAddr, err)
}
return
}
defer func() {
if err := channel.Close(); err != nil {
log.Debugf("remote channel close error: %v", err)
}
}()
go func() {
if _, err := io.Copy(channel, localConn); err != nil {
log.Debugf("local forward copy error (local->remote): %v", err)
}
}()
if _, err := io.Copy(localConn, channel); err != nil {
log.Debugf("local forward copy error (remote->local): %v", err)
}
}
// RemotePortForward sets up remote port forwarding, binding on remote and forwarding to localAddr
func (c *Client) RemotePortForward(ctx context.Context, remoteAddr, localAddr string) error {
host, port, err := c.parseRemoteAddress(remoteAddr)
if err != nil {
return err
}
req := c.buildTCPIPForwardRequest(host, port)
if err := c.sendTCPIPForwardRequest(req); err != nil {
return err
}
go c.handleRemoteForwardChannels(ctx, localAddr)
<-ctx.Done()
if err := c.cancelTCPIPForwardRequest(req); err != nil {
return fmt.Errorf("cancel tcpip-forward: %w", err)
}
return ctx.Err()
}
// parseRemoteAddress parses host and port from remote address string
func (c *Client) parseRemoteAddress(remoteAddr string) (string, uint32, error) {
host, portStr, err := net.SplitHostPort(remoteAddr)
if err != nil {
return "", 0, fmt.Errorf("parse remote address %s: %w", remoteAddr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return "", 0, fmt.Errorf("parse remote port %s: %w", portStr, err)
}
return host, uint32(port), nil
}
// buildTCPIPForwardRequest creates a tcpip-forward request message
func (c *Client) buildTCPIPForwardRequest(host string, port uint32) tcpipForwardMsg {
return tcpipForwardMsg{
Host: host,
Port: port,
}
}
// sendTCPIPForwardRequest sends the tcpip-forward request to establish remote port forwarding
func (c *Client) sendTCPIPForwardRequest(req tcpipForwardMsg) error {
ok, _, err := c.client.SendRequest("tcpip-forward", true, ssh.Marshal(&req))
if err != nil {
return fmt.Errorf("send tcpip-forward request: %w", err)
}
if !ok {
return fmt.Errorf("remote port forwarding denied by server (check if --allow-ssh-remote-port-forwarding is enabled)")
}
return nil
}
// cancelTCPIPForwardRequest cancels the tcpip-forward request
func (c *Client) cancelTCPIPForwardRequest(req tcpipForwardMsg) error {
_, _, err := c.client.SendRequest("cancel-tcpip-forward", true, ssh.Marshal(&req))
if err != nil {
return fmt.Errorf("send cancel-tcpip-forward request: %w", err)
}
return nil
}
// handleRemoteForwardChannels handles incoming forwarded-tcpip channels
func (c *Client) handleRemoteForwardChannels(ctx context.Context, localAddr string) {
// Get the channel once - subsequent calls return nil!
channelRequests := c.client.HandleChannelOpen("forwarded-tcpip")
if channelRequests == nil {
log.Debugf("forwarded-tcpip channel type already being handled")
return
}
for {
select {
case <-ctx.Done():
return
case newChan := <-channelRequests:
if newChan != nil {
go c.handleRemoteForwardChannel(newChan, localAddr)
}
}
}
}
// handleRemoteForwardChannel handles a single forwarded-tcpip channel
func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr string) {
channel, reqs, err := newChan.Accept()
if err != nil {
return
}
defer func() {
if err := channel.Close(); err != nil {
log.Debugf("remote channel close error: %v", err)
}
}()
go ssh.DiscardRequests(reqs)
localConn, err := net.Dial("tcp", localAddr)
if err != nil {
return
}
defer func() {
if err := localConn.Close(); err != nil {
log.Debugf("local connection close error: %v", err)
}
}()
go func() {
if _, err := io.Copy(localConn, channel); err != nil {
log.Debugf("remote forward copy error (remote->local): %v", err)
}
}()
if _, err := io.Copy(channel, localConn); err != nil {
log.Debugf("remote forward copy error (local->remote): %v", err)
}
}
// tcpipForwardMsg represents the structure for tcpip-forward requests
type tcpipForwardMsg struct {
Host string
Port uint32
}

View File

@@ -0,0 +1,468 @@
package client
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/user"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
)
func TestSSHClient_DialWithKey(t *testing.T) {
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create and start server
server := sshserver.New(hostKey)
server.SetAllowRootLogin(true) // Allow root/admin login for tests
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := sshserver.StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Test Dial
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
currentUser := getCurrentUsername()
client, err := DialInsecure(ctx, serverAddr, currentUser)
require.NoError(t, err)
defer func() {
err := client.Close()
assert.NoError(t, err)
}()
// Verify client is connected
assert.NotNil(t, client.client)
}
func TestSSHClient_CommandExecution(t *testing.T) {
server, _, client := setupTestSSHServerAndClient(t)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
defer func() {
err := client.Close()
assert.NoError(t, err)
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
t.Run("ExecuteCommand captures output", func(t *testing.T) {
output, err := client.ExecuteCommand(ctx, "echo hello")
assert.NoError(t, err)
assert.Contains(t, string(output), "hello")
})
t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
err := client.ExecuteCommandWithIO(ctx, "echo world")
assert.NoError(t, err)
})
t.Run("commands with flags work", func(t *testing.T) {
output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
assert.NoError(t, err)
assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
})
t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
var testCmd string
if runtime.GOOS == "windows" {
testCmd = "echo hello | Select-String notfound"
} else {
testCmd = "echo 'hello' | grep 'notfound'"
}
_, err := client.ExecuteCommand(ctx, testCmd)
assert.NoError(t, err)
})
}
func TestSSHClient_ConnectionHandling(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Generate client key for multiple connections
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
err = server.AddAuthorizedKey("multi-peer", string(clientPubKey))
require.NoError(t, err)
const numClients = 3
clients := make([]*Client, numClients)
for i := 0; i < numClients; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
currentUser := getCurrentUsername()
client, err := DialInsecure(ctx, serverAddr, fmt.Sprintf("%s-%d", currentUser, i))
cancel()
require.NoError(t, err, "Client %d should connect successfully", i)
clients[i] = client
}
for i, client := range clients {
err := client.Close()
assert.NoError(t, err, "Client %d should close without error", i)
}
}
func TestSSHClient_ContextCancellation(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
err = server.AddAuthorizedKey("cancel-peer", string(clientPubKey))
require.NoError(t, err)
t.Run("connection with short timeout", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
currentUser := getCurrentUsername()
_, err = DialInsecure(ctx, serverAddr, currentUser)
if err != nil {
assert.Contains(t, err.Error(), "context")
}
})
t.Run("command execution cancellation", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
currentUser := getCurrentUsername()
client, err := DialInsecure(ctx, serverAddr, currentUser)
require.NoError(t, err)
defer func() {
if err := client.Close(); err != nil {
t.Logf("client close error: %v", err)
}
}()
cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cmdCancel()
err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
if err != nil {
var exitMissingErr *cryptossh.ExitMissingError
isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) ||
errors.As(err, &exitMissingErr)
assert.True(t, isValidCancellation, "Should handle command cancellation properly")
}
})
}
func TestSSHClient_NoAuthMode(t *testing.T) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
server := sshserver.New(hostKey)
server.SetAllowRootLogin(true) // Allow root/admin login for tests
serverAddr := sshserver.StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
currentUser := getCurrentUsername()
t.Run("any key succeeds in no-auth mode", func(t *testing.T) {
client, err := DialInsecure(ctx, serverAddr, currentUser)
assert.NoError(t, err)
if client != nil {
require.NoError(t, client.Close(), "Client should close without error")
}
})
}
func TestSSHClient_TerminalState(t *testing.T) {
server, _, client := setupTestSSHServerAndClient(t)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
defer func() {
err := client.Close()
assert.NoError(t, err)
}()
assert.Nil(t, client.terminalState)
assert.Equal(t, 0, client.terminalFd)
client.restoreTerminal()
assert.Nil(t, client.terminalState)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := client.OpenTerminal(ctx)
// In test environment without a real terminal, this may complete quickly or timeout
// Both behaviors are acceptable for testing terminal state management
if err != nil {
if runtime.GOOS == "windows" {
assert.True(t,
strings.Contains(err.Error(), "context deadline exceeded") ||
strings.Contains(err.Error(), "console"),
"Should timeout or have console error on Windows")
} else {
// On Unix systems in test environment, we may get various errors
// including timeouts or terminal-related errors
assert.True(t,
strings.Contains(err.Error(), "context deadline exceeded") ||
strings.Contains(err.Error(), "terminal") ||
strings.Contains(err.Error(), "pty"),
"Expected timeout or terminal-related error, got: %v", err)
}
}
}
func setupTestSSHServerAndClient(t *testing.T) (*sshserver.Server, string, *Client) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
server := sshserver.New(hostKey)
server.SetAllowRootLogin(true) // Allow root/admin login for tests
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := sshserver.StartTestServer(t, server)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
currentUser := getCurrentUsername()
client, err := DialInsecure(ctx, serverAddr, currentUser)
require.NoError(t, err)
return server, serverAddr, client
}
func TestSSHClient_PortForwarding(t *testing.T) {
server, _, client := setupTestSSHServerAndClient(t)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
defer func() {
err := client.Close()
assert.NoError(t, err)
}()
t.Run("local forwarding times out gracefully", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := client.LocalPortForward(ctx, "127.0.0.1:0", "127.0.0.1:8080")
assert.Error(t, err)
assert.True(t,
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) ||
strings.Contains(err.Error(), "connection"),
"Expected context or connection error")
})
t.Run("remote forwarding denied", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
err := client.RemotePortForward(ctx, "127.0.0.1:0", "127.0.0.1:8080")
assert.Error(t, err)
assert.True(t,
strings.Contains(err.Error(), "denied") ||
strings.Contains(err.Error(), "disabled"),
"Should be denied by default")
})
t.Run("invalid addresses fail", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
err := client.LocalPortForward(ctx, "invalid:address", "127.0.0.1:8080")
assert.Error(t, err)
err = client.LocalPortForward(ctx, "127.0.0.1:0", "invalid:address")
assert.Error(t, err)
})
}
func TestSSHClient_PortForwardingDataTransfer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping data transfer test in short mode")
}
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
server := sshserver.New(hostKey)
server.SetAllowLocalPortForwarding(true)
server.SetAllowRootLogin(true) // Allow root/admin login for tests
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := sshserver.StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
currentUser := getCurrentUsername()
client, err := DialInsecure(ctx, serverAddr, currentUser)
require.NoError(t, err)
defer func() {
if err := client.Close(); err != nil {
t.Logf("client close error: %v", err)
}
}()
testServer, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() {
if err := testServer.Close(); err != nil {
t.Logf("test server close error: %v", err)
}
}()
testServerAddr := testServer.Addr().String()
expectedResponse := "Hello, World!"
go func() {
for {
conn, err := testServer.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer func() {
if err := c.Close(); err != nil {
t.Logf("connection close error: %v", err)
}
}()
buf := make([]byte, 1024)
if _, err := c.Read(buf); err != nil {
t.Logf("connection read error: %v", err)
return
}
if _, err := c.Write([]byte(expectedResponse)); err != nil {
t.Logf("connection write error: %v", err)
}
}(conn)
}
}()
localListener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
localAddr := localListener.Addr().String()
if err := localListener.Close(); err != nil {
t.Logf("local listener close error: %v", err)
}
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
err := client.LocalPortForward(ctx, localAddr, testServerAddr)
if err != nil && !errors.Is(err, context.Canceled) {
t.Logf("Port forward error: %v", err)
}
}()
time.Sleep(100 * time.Millisecond)
conn, err := net.DialTimeout("tcp", localAddr, 2*time.Second)
require.NoError(t, err)
defer func() {
if err := conn.Close(); err != nil {
t.Logf("connection close error: %v", err)
}
}()
_, err = conn.Write([]byte("test"))
require.NoError(t, err)
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Logf("set read deadline error: %v", err)
}
response := make([]byte, len(expectedResponse))
n, err := io.ReadFull(conn, response)
require.NoError(t, err)
assert.Equal(t, len(expectedResponse), n)
assert.Equal(t, expectedResponse, string(response))
}
// getCurrentUsername returns the current username for SSH connections
func getCurrentUsername() string {
if runtime.GOOS == "windows" {
if currentUser, err := user.Current(); err == nil {
username := currentUser.Username
if idx := strings.LastIndex(username, "\\"); idx != -1 {
username = username[idx+1:]
}
return strings.ToLower(username)
}
}
if username := os.Getenv("USER"); username != "" {
return username
}
if currentUser, err := user.Current(); err == nil {
return currentUser.Username
}
return "test-user"
}

View File

@@ -1,6 +1,6 @@
//go:build !windows
package ssh
package client
import (
"context"
@@ -9,6 +9,7 @@ import (
"os/signal"
"syscall"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
)
@@ -35,11 +36,22 @@ func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) er
defer signal.Stop(sigChan)
select {
case <-ctx.Done():
_ = term.Restore(fd, state)
if err := term.Restore(fd, state); err != nil {
log.Debugf("restore terminal state: %v", err)
}
case sig := <-sigChan:
_ = term.Restore(fd, state)
if err := term.Restore(fd, state); err != nil {
log.Debugf("restore terminal state: %v", err)
}
signal.Reset(sig)
_ = syscall.Kill(syscall.Getpid(), sig.(syscall.Signal))
s, ok := sig.(syscall.Signal)
if !ok {
log.Debugf("signal %v is not a syscall.Signal: %T", sig, sig)
return
}
if err := syscall.Kill(syscall.Getpid(), s); err != nil {
log.Debugf("kill process with signal %v: %v", s, err)
}
}
}()
@@ -68,8 +80,8 @@ func (c *Client) setupNonTerminalMode(_ context.Context, session *ssh.Session) e
}
// restoreWindowsConsoleState is a no-op on Unix systems
func (c *Client) restoreWindowsConsoleState() {
// No-op on Unix systems
func (c *Client) restoreWindowsConsoleState() error {
return nil
}
func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
@@ -82,20 +94,32 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
1: 3, // VINTR - Ctrl+C
2: 28, // VQUIT - Ctrl+\
3: 127, // VERASE - Backspace
4: 21, // VKILL - Ctrl+U
5: 4, // VEOF - Ctrl+D
6: 0, // VEOL
7: 0, // VEOL2
8: 17, // VSTART - Ctrl+Q
9: 19, // VSTOP - Ctrl+S
10: 26, // VSUSP - Ctrl+Z
18: 18, // VREPRINT - Ctrl+R
19: 23, // VWERASE - Ctrl+W
20: 22, // VLNEXT - Ctrl+V
21: 15, // VDISCARD - Ctrl+O
// Ctrl+C
ssh.VINTR: 3,
// Ctrl+\
ssh.VQUIT: 28,
// Backspace
ssh.VERASE: 127,
// Ctrl+U
ssh.VKILL: 21,
// Ctrl+D
ssh.VEOF: 4,
ssh.VEOL: 0,
ssh.VEOL2: 0,
// Ctrl+Q
ssh.VSTART: 17,
// Ctrl+S
ssh.VSTOP: 19,
// Ctrl+Z
ssh.VSUSP: 26,
// Ctrl+O
ssh.VDISCARD: 15,
// Ctrl+R
ssh.VREPRINT: 18,
// Ctrl+W
ssh.VWERASE: 23,
// Ctrl+V
ssh.VLNEXT: 22,
}
terminal := os.Getenv("TERM")

View File

@@ -1,6 +1,6 @@
//go:build windows
package ssh
package client
import (
"context"
@@ -64,7 +64,6 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
if err := c.saveWindowsConsoleState(); err != nil {
var consoleErr *ConsoleUnavailableError
if errors.As(err, &consoleErr) {
// Console is unavailable (e.g., CI environment), continue with defaults
log.Debugf("console unavailable, continuing with defaults: %v", err)
c.terminalFd = 0
} else {
@@ -75,10 +74,9 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
if err := c.enableWindowsVirtualTerminal(); err != nil {
var consoleErr *ConsoleUnavailableError
if errors.As(err, &consoleErr) {
// Console is unavailable, this is expected in CI environments
log.Debugf("virtual terminal unavailable: %v", err)
} else {
log.Debugf("failed to enable virtual terminal: %v", err)
return fmt.Errorf("failed to enable virtual terminal: %w", err)
}
}
@@ -100,13 +98,13 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro
ssh.VEOF: 4, // Ctrl+D
ssh.VEOL: 0,
ssh.VEOL2: 0,
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
ssh.VREPRINT: 18, // Ctrl+R
ssh.VSTART: 17, // Ctrl+Q
ssh.VSTOP: 19, // Ctrl+S
ssh.VSUSP: 26, // Ctrl+Z
ssh.VDISCARD: 15, // Ctrl+O
ssh.VWERASE: 23, // Ctrl+W
ssh.VLNEXT: 22, // Ctrl+V
ssh.VREPRINT: 18, // Ctrl+R
}
return session.RequestPty("xterm-256color", h, w, modes)
@@ -150,10 +148,10 @@ func (c *Client) saveWindowsConsoleState() error {
return nil
}
func (c *Client) enableWindowsVirtualTerminal() error {
func (c *Client) enableWindowsVirtualTerminal() (err error) {
defer func() {
if r := recover(); r != nil {
log.Debugf("panic in enableWindowsVirtualTerminal: %v", r)
err = fmt.Errorf("panic in enableWindowsVirtualTerminal: %v", r)
}
}()
@@ -161,42 +159,38 @@ func (c *Client) enableWindowsVirtualTerminal() error {
stdin := syscall.Handle(os.Stdin.Fd())
var mode uint32
ret, _, err := procGetConsoleMode.Call(uintptr(stdout), uintptr(unsafe.Pointer(&mode)))
ret, _, winErr := procGetConsoleMode.Call(uintptr(stdout), uintptr(unsafe.Pointer(&mode)))
if ret == 0 {
log.Debugf("failed to get stdout console mode for VT setup: %v", err)
return &ConsoleUnavailableError{
Operation: "get stdout console mode for VT",
Err: err,
Err: winErr,
}
}
mode |= enableVirtualTerminalProcessing
ret, _, err = procSetConsoleMode.Call(uintptr(stdout), uintptr(mode))
ret, _, winErr = procSetConsoleMode.Call(uintptr(stdout), uintptr(mode))
if ret == 0 {
log.Debugf("failed to enable virtual terminal processing: %v", err)
return &ConsoleUnavailableError{
Operation: "enable virtual terminal processing",
Err: err,
Err: winErr,
}
}
ret, _, err = procGetConsoleMode.Call(uintptr(stdin), uintptr(unsafe.Pointer(&mode)))
ret, _, winErr = procGetConsoleMode.Call(uintptr(stdin), uintptr(unsafe.Pointer(&mode)))
if ret == 0 {
log.Debugf("failed to get stdin console mode for VT setup: %v", err)
return &ConsoleUnavailableError{
Operation: "get stdin console mode for VT",
Err: err,
Err: winErr,
}
}
mode &= ^uint32(enableLineInput | enableEchoInput | enableProcessedInput)
mode |= enableVirtualTerminalInput
ret, _, err = procSetConsoleMode.Call(uintptr(stdin), uintptr(mode))
ret, _, winErr = procSetConsoleMode.Call(uintptr(stdin), uintptr(mode))
if ret == 0 {
log.Debugf("failed to set stdin raw mode: %v", err)
return &ConsoleUnavailableError{
Operation: "set stdin raw mode",
Err: err,
Err: winErr,
}
}
@@ -227,28 +221,35 @@ func (c *Client) getWindowsConsoleSize() (int, int) {
return width, height
}
func (c *Client) restoreWindowsConsoleState() {
func (c *Client) restoreWindowsConsoleState() error {
var err error
defer func() {
if r := recover(); r != nil {
log.Debugf("panic in restoreWindowsConsoleState: %v", r)
err = fmt.Errorf("panic in restoreWindowsConsoleState: %v", r)
}
}()
if c.terminalFd != 1 {
return
return nil
}
stdout := syscall.Handle(os.Stdout.Fd())
stdin := syscall.Handle(os.Stdin.Fd())
ret, _, err := procSetConsoleMode.Call(uintptr(stdout), uintptr(c.windowsStdoutMode))
ret, _, winErr := procSetConsoleMode.Call(uintptr(stdout), uintptr(c.windowsStdoutMode))
if ret == 0 {
log.Debugf("failed to restore stdout console mode: %v", err)
log.Debugf("failed to restore stdout console mode: %v", winErr)
if err == nil {
err = fmt.Errorf("restore stdout console mode: %w", winErr)
}
}
ret, _, err = procSetConsoleMode.Call(uintptr(stdin), uintptr(c.windowsStdinMode))
ret, _, winErr = procSetConsoleMode.Call(uintptr(stdin), uintptr(c.windowsStdinMode))
if ret == 0 {
log.Debugf("failed to restore stdin console mode: %v", err)
log.Debugf("failed to restore stdin console mode: %v", winErr)
if err == nil {
err = fmt.Errorf("restore stdin console mode: %w", winErr)
}
}
c.terminalFd = 0
@@ -256,4 +257,5 @@ func (c *Client) restoreWindowsConsoleState() {
c.windowsStdinMode = 0
log.Debugf("restored Windows console state")
}
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,556 @@
package config
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
const (
// EnvDisableSSHConfig is the environment variable to disable SSH config management
EnvDisableSSHConfig = "NB_DISABLE_SSH_CONFIG"
// EnvForceSSHConfig is the environment variable to force SSH config generation even with many peers
EnvForceSSHConfig = "NB_FORCE_SSH_CONFIG"
// MaxPeersForSSHConfig is the default maximum number of peers before SSH config generation is disabled
MaxPeersForSSHConfig = 200
// fileWriteTimeout is the timeout for file write operations
fileWriteTimeout = 2 * time.Second
)
// isSSHConfigDisabled checks if SSH config management is disabled via environment variable
func isSSHConfigDisabled() bool {
value := os.Getenv(EnvDisableSSHConfig)
if value == "" {
return false
}
// Parse as boolean, default to true if non-empty but invalid
disabled, err := strconv.ParseBool(value)
if err != nil {
// If not a valid boolean, treat any non-empty value as true
return true
}
return disabled
}
// isSSHConfigForced checks if SSH config generation is forced via environment variable
func isSSHConfigForced() bool {
value := os.Getenv(EnvForceSSHConfig)
if value == "" {
return false
}
// Parse as boolean, default to true if non-empty but invalid
forced, err := strconv.ParseBool(value)
if err != nil {
// If not a valid boolean, treat any non-empty value as true
return true
}
return forced
}
// shouldGenerateSSHConfig checks if SSH config should be generated based on peer count
func shouldGenerateSSHConfig(peerCount int) bool {
if isSSHConfigDisabled() {
return false
}
if isSSHConfigForced() {
return true
}
return peerCount <= MaxPeersForSSHConfig
}
// writeFileWithTimeout writes data to a file with a timeout
func writeFileWithTimeout(filename string, data []byte, perm os.FileMode) error {
ctx, cancel := context.WithTimeout(context.Background(), fileWriteTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- os.WriteFile(filename, data, perm)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("file write timeout after %v: %s", fileWriteTimeout, filename)
}
}
// writeFileOperationWithTimeout performs a file operation with timeout
func writeFileOperationWithTimeout(filename string, operation func() error) error {
ctx, cancel := context.WithTimeout(context.Background(), fileWriteTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- operation()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("file write timeout after %v: %s", fileWriteTimeout, filename)
}
}
// Manager handles SSH client configuration for NetBird peers
type Manager struct {
sshConfigDir string
sshConfigFile string
knownHostsDir string
knownHostsFile string
userKnownHosts string
}
// PeerHostKey represents a peer's SSH host key information
type PeerHostKey struct {
Hostname string
IP string
FQDN string
HostKey ssh.PublicKey
}
// NewManager creates a new SSH config manager
func NewManager() *Manager {
sshConfigDir, knownHostsDir := getSystemSSHPaths()
return &Manager{
sshConfigDir: sshConfigDir,
sshConfigFile: "99-netbird.conf",
knownHostsDir: knownHostsDir,
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
}
// getSystemSSHPaths returns platform-specific SSH configuration paths
func getSystemSSHPaths() (configDir, knownHostsDir string) {
switch runtime.GOOS {
case "windows":
// Windows OpenSSH paths
programData := os.Getenv("PROGRAMDATA")
if programData == "" {
programData = `C:\ProgramData`
}
configDir = filepath.Join(programData, "ssh", "ssh_config.d")
knownHostsDir = filepath.Join(programData, "ssh", "ssh_known_hosts.d")
default:
// Unix-like systems (Linux, macOS, etc.)
configDir = "/etc/ssh/ssh_config.d"
knownHostsDir = "/etc/ssh/ssh_known_hosts.d"
}
return configDir, knownHostsDir
}
// SetupSSHClientConfig creates SSH client configuration for NetBird domains
func (m *Manager) SetupSSHClientConfig(domains []string) error {
return m.SetupSSHClientConfigWithPeers(domains, nil)
}
// SetupSSHClientConfigWithPeers creates SSH client configuration for peer hostnames
func (m *Manager) SetupSSHClientConfigWithPeers(domains []string, peerKeys []PeerHostKey) error {
peerCount := len(peerKeys)
// Check if SSH config should be generated
if !shouldGenerateSSHConfig(peerCount) {
if isSSHConfigDisabled() {
log.Debugf("SSH config management disabled via %s", EnvDisableSSHConfig)
} else {
log.Infof("SSH config generation skipped: too many peers (%d > %d). Use %s=true to force.",
peerCount, MaxPeersForSSHConfig, EnvForceSSHConfig)
}
return nil
}
// Try to set up known_hosts for host key verification
knownHostsPath, err := m.setupKnownHostsFile()
if err != nil {
log.Warnf("Failed to setup known_hosts file: %v", err)
// Continue with fallback to no verification
knownHostsPath = "/dev/null"
}
sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile)
// Build SSH client configuration
sshConfig := "# NetBird SSH client configuration\n"
sshConfig += "# Generated automatically - do not edit manually\n"
sshConfig += "#\n"
sshConfig += "# To disable SSH config management, use:\n"
sshConfig += "# netbird service reconfigure --service-env NB_DISABLE_SSH_CONFIG=true\n"
sshConfig += "#\n\n"
// Add specific peer entries with multiple hostnames in one Host line
for _, peer := range peerKeys {
var hostPatterns []string
// Add IP address
if peer.IP != "" {
hostPatterns = append(hostPatterns, peer.IP)
}
// Add FQDN
if peer.FQDN != "" {
hostPatterns = append(hostPatterns, peer.FQDN)
}
// Add short hostname if different from FQDN
if peer.Hostname != "" && peer.Hostname != peer.FQDN {
hostPatterns = append(hostPatterns, peer.Hostname)
}
if len(hostPatterns) > 0 {
hostLine := strings.Join(hostPatterns, " ")
sshConfig += fmt.Sprintf("Host %s\n", hostLine)
sshConfig += " # NetBird peer-specific configuration\n"
sshConfig += " PreferredAuthentications password,publickey,keyboard-interactive\n"
sshConfig += " PasswordAuthentication yes\n"
sshConfig += " PubkeyAuthentication yes\n"
sshConfig += " BatchMode no\n"
if knownHostsPath == "/dev/null" {
sshConfig += " StrictHostKeyChecking no\n"
sshConfig += " UserKnownHostsFile /dev/null\n"
} else {
sshConfig += " StrictHostKeyChecking yes\n"
sshConfig += fmt.Sprintf(" UserKnownHostsFile %s\n", knownHostsPath)
}
sshConfig += " LogLevel ERROR\n\n"
}
}
// Try to create system-wide SSH config
if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil {
log.Warnf("Failed to create SSH config directory %s: %v", m.sshConfigDir, err)
return m.setupUserConfig(sshConfig, domains)
}
if err := writeFileWithTimeout(sshConfigPath, []byte(sshConfig), 0644); err != nil {
log.Warnf("Failed to write SSH config file %s: %v", sshConfigPath, err)
return m.setupUserConfig(sshConfig, domains)
}
log.Infof("Created NetBird SSH client config: %s", sshConfigPath)
return nil
}
// setupUserConfig creates SSH config in user's directory as fallback
func (m *Manager) setupUserConfig(sshConfig string, domains []string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get user home directory: %w", err)
}
userSSHDir := filepath.Join(homeDir, ".ssh")
userConfigPath := filepath.Join(userSSHDir, "config")
if err := os.MkdirAll(userSSHDir, 0700); err != nil {
return fmt.Errorf("create user SSH directory: %w", err)
}
// Check if NetBird config already exists in user config
exists, err := m.configExists(userConfigPath)
if err != nil {
return fmt.Errorf("check existing config: %w", err)
}
if exists {
log.Debugf("NetBird SSH config already exists in %s", userConfigPath)
return nil
}
// Append NetBird config to user's SSH config with timeout
if err := writeFileOperationWithTimeout(userConfigPath, func() error {
file, err := os.OpenFile(userConfigPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("open user SSH config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
log.Debugf("user SSH config file close error: %v", err)
}
}()
if _, err := fmt.Fprintf(file, "\n%s", sshConfig); err != nil {
return fmt.Errorf("write to user SSH config: %w", err)
}
return nil
}); err != nil {
return err
}
log.Infof("Added NetBird SSH config to user config: %s", userConfigPath)
return nil
}
// configExists checks if NetBird SSH config already exists
func (m *Manager) configExists(configPath string) (bool, error) {
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("open SSH config file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "NetBird SSH client configuration") {
return true, nil
}
}
return false, scanner.Err()
}
// RemoveSSHClientConfig removes NetBird SSH configuration
func (m *Manager) RemoveSSHClientConfig() error {
sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile)
// Remove system-wide config if it exists
if err := os.Remove(sshConfigPath); err != nil && !os.IsNotExist(err) {
log.Warnf("Failed to remove system SSH config %s: %v", sshConfigPath, err)
} else if err == nil {
log.Infof("Removed NetBird SSH config: %s", sshConfigPath)
}
// Also try to clean up user config
homeDir, err := os.UserHomeDir()
if err != nil {
return nil // Not critical
}
userConfigPath := filepath.Join(homeDir, ".ssh", "config")
if err := m.removeFromUserConfig(userConfigPath); err != nil {
log.Warnf("Failed to clean user SSH config: %v", err)
}
return nil
}
// removeFromUserConfig removes NetBird section from user's SSH config
func (m *Manager) removeFromUserConfig(configPath string) error {
// This is complex to implement safely, so for now just log
// In practice, the system-wide config takes precedence anyway
log.Debugf("NetBird SSH config cleanup from user config not implemented")
return nil
}
// setupKnownHostsFile creates and returns the path to NetBird known_hosts file
func (m *Manager) setupKnownHostsFile() (string, error) {
// Try system-wide known_hosts first
knownHostsPath := filepath.Join(m.knownHostsDir, m.knownHostsFile)
if err := os.MkdirAll(m.knownHostsDir, 0755); err == nil {
// Create empty file if it doesn't exist
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
if err := writeFileWithTimeout(knownHostsPath, []byte("# NetBird SSH known hosts\n"), 0644); err == nil {
log.Debugf("Created NetBird known_hosts file: %s", knownHostsPath)
return knownHostsPath, nil
}
} else if err == nil {
return knownHostsPath, nil
}
}
// Fallback to user directory
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get user home directory: %w", err)
}
userSSHDir := filepath.Join(homeDir, ".ssh")
if err := os.MkdirAll(userSSHDir, 0700); err != nil {
return "", fmt.Errorf("create user SSH directory: %w", err)
}
userKnownHostsPath := filepath.Join(userSSHDir, m.userKnownHosts)
if _, err := os.Stat(userKnownHostsPath); os.IsNotExist(err) {
if err := writeFileWithTimeout(userKnownHostsPath, []byte("# NetBird SSH known hosts\n"), 0600); err != nil {
return "", fmt.Errorf("create user known_hosts file: %w", err)
}
log.Debugf("Created NetBird user known_hosts file: %s", userKnownHostsPath)
}
return userKnownHostsPath, nil
}
// UpdatePeerHostKeys updates the known_hosts file with peer host keys
func (m *Manager) UpdatePeerHostKeys(peerKeys []PeerHostKey) error {
peerCount := len(peerKeys)
// Check if SSH config should be generated
if !shouldGenerateSSHConfig(peerCount) {
if isSSHConfigDisabled() {
log.Debugf("SSH config management disabled via %s", EnvDisableSSHConfig)
} else {
log.Infof("SSH known_hosts update skipped: too many peers (%d > %d). Use %s=true to force.",
peerCount, MaxPeersForSSHConfig, EnvForceSSHConfig)
}
return nil
}
knownHostsPath, err := m.setupKnownHostsFile()
if err != nil {
return fmt.Errorf("setup known_hosts file: %w", err)
}
// Read existing entries
existingEntries, err := m.readKnownHosts(knownHostsPath)
if err != nil {
return fmt.Errorf("read existing known_hosts: %w", err)
}
// Build new entries map for efficient lookup
newEntries := make(map[string]string)
for _, peerKey := range peerKeys {
entry := m.formatKnownHostsEntry(peerKey)
// Use all possible hostnames as keys
hostnames := m.getHostnameVariants(peerKey)
for _, hostname := range hostnames {
newEntries[hostname] = entry
}
}
// Create updated known_hosts content
var updatedContent strings.Builder
updatedContent.WriteString("# NetBird SSH known hosts\n")
updatedContent.WriteString("# Generated automatically - do not edit manually\n\n")
// Add existing non-NetBird entries
for _, entry := range existingEntries {
if !m.isNetBirdEntry(entry) {
updatedContent.WriteString(entry)
updatedContent.WriteString("\n")
}
}
// Add new NetBird entries
for _, entry := range newEntries {
updatedContent.WriteString(entry)
updatedContent.WriteString("\n")
}
// Write updated content
if err := writeFileWithTimeout(knownHostsPath, []byte(updatedContent.String()), 0644); err != nil {
return fmt.Errorf("write known_hosts file: %w", err)
}
log.Debugf("Updated NetBird known_hosts with %d peer keys: %s", len(peerKeys), knownHostsPath)
return nil
}
// readKnownHosts reads and returns all entries from the known_hosts file
func (m *Manager) readKnownHosts(knownHostsPath string) ([]string, error) {
file, err := os.Open(knownHostsPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("open known_hosts file: %w", err)
}
defer file.Close()
var entries []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
entries = append(entries, line)
}
}
return entries, scanner.Err()
}
// formatKnownHostsEntry formats a peer host key as a known_hosts entry
func (m *Manager) formatKnownHostsEntry(peerKey PeerHostKey) string {
hostnames := m.getHostnameVariants(peerKey)
hostnameList := strings.Join(hostnames, ",")
keyString := string(ssh.MarshalAuthorizedKey(peerKey.HostKey))
keyString = strings.TrimSpace(keyString)
return fmt.Sprintf("%s %s", hostnameList, keyString)
}
// getHostnameVariants returns all possible hostname variants for a peer
func (m *Manager) getHostnameVariants(peerKey PeerHostKey) []string {
var hostnames []string
// Add IP address
if peerKey.IP != "" {
hostnames = append(hostnames, peerKey.IP)
}
// Add FQDN
if peerKey.FQDN != "" {
hostnames = append(hostnames, peerKey.FQDN)
}
// Add hostname if different from FQDN
if peerKey.Hostname != "" && peerKey.Hostname != peerKey.FQDN {
hostnames = append(hostnames, peerKey.Hostname)
}
// Add bracketed IP for non-standard ports (SSH standard)
if peerKey.IP != "" {
hostnames = append(hostnames, fmt.Sprintf("[%s]:22", peerKey.IP))
hostnames = append(hostnames, fmt.Sprintf("[%s]:22022", peerKey.IP))
}
return hostnames
}
// isNetBirdEntry checks if a known_hosts entry appears to be NetBird-managed
func (m *Manager) isNetBirdEntry(entry string) bool {
// Check if entry contains NetBird IP ranges or domains
return strings.Contains(entry, "100.125.") ||
strings.Contains(entry, ".nb.internal") ||
strings.Contains(entry, "netbird")
}
// GetKnownHostsPath returns the path to the NetBird known_hosts file
func (m *Manager) GetKnownHostsPath() (string, error) {
return m.setupKnownHostsFile()
}
// RemoveKnownHostsFile removes the NetBird known_hosts file
func (m *Manager) RemoveKnownHostsFile() error {
// Remove system-wide known_hosts if it exists
knownHostsPath := filepath.Join(m.knownHostsDir, m.knownHostsFile)
if err := os.Remove(knownHostsPath); err != nil && !os.IsNotExist(err) {
log.Warnf("Failed to remove system known_hosts %s: %v", knownHostsPath, err)
} else if err == nil {
log.Infof("Removed NetBird known_hosts: %s", knownHostsPath)
}
// Also try to clean up user known_hosts
homeDir, err := os.UserHomeDir()
if err != nil {
return nil // Not critical
}
userKnownHostsPath := filepath.Join(homeDir, ".ssh", m.userKnownHosts)
if err := os.Remove(userKnownHostsPath); err != nil && !os.IsNotExist(err) {
log.Warnf("Failed to remove user known_hosts %s: %v", userKnownHostsPath, err)
} else if err == nil {
log.Infof("Removed NetBird user known_hosts: %s", userKnownHostsPath)
}
return nil
}

View File

@@ -0,0 +1,364 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
func TestManager_UpdatePeerHostKeys(t *testing.T) {
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Override manager paths to use temp directory
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
knownHostsDir: filepath.Join(tempDir, "ssh_known_hosts.d"),
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
// Generate test host keys
hostKey1, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
pubKey1, err := ssh.ParsePrivateKey(hostKey1)
require.NoError(t, err)
hostKey2, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
pubKey2, err := ssh.ParsePrivateKey(hostKey2)
require.NoError(t, err)
// Create test peer host keys
peerKeys := []PeerHostKey{
{
Hostname: "peer1",
IP: "100.125.1.1",
FQDN: "peer1.nb.internal",
HostKey: pubKey1.PublicKey(),
},
{
Hostname: "peer2",
IP: "100.125.1.2",
FQDN: "peer2.nb.internal",
HostKey: pubKey2.PublicKey(),
},
}
// Test updating known_hosts
err = manager.UpdatePeerHostKeys(peerKeys)
require.NoError(t, err)
// Verify known_hosts file was created and contains entries
knownHostsPath, err := manager.GetKnownHostsPath()
require.NoError(t, err)
content, err := os.ReadFile(knownHostsPath)
require.NoError(t, err)
contentStr := string(content)
assert.Contains(t, contentStr, "100.125.1.1")
assert.Contains(t, contentStr, "100.125.1.2")
assert.Contains(t, contentStr, "peer1.nb.internal")
assert.Contains(t, contentStr, "peer2.nb.internal")
assert.Contains(t, contentStr, "[100.125.1.1]:22")
assert.Contains(t, contentStr, "[100.125.1.1]:22022")
// Test updating with empty list should preserve structure
err = manager.UpdatePeerHostKeys([]PeerHostKey{})
require.NoError(t, err)
content, err = os.ReadFile(knownHostsPath)
require.NoError(t, err)
assert.Contains(t, string(content), "# NetBird SSH known hosts")
}
func TestManager_SetupSSHClientConfig(t *testing.T) {
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Override manager paths to use temp directory
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
knownHostsDir: filepath.Join(tempDir, "ssh_known_hosts.d"),
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
// Test SSH config generation
domains := []string{"example.nb.internal", "test.nb.internal"}
err = manager.SetupSSHClientConfig(domains)
require.NoError(t, err)
// Read generated config
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
content, err := os.ReadFile(configPath)
require.NoError(t, err)
configStr := string(content)
// Since we now use per-peer configurations instead of domain patterns,
// we should verify the basic SSH config structure exists
assert.Contains(t, configStr, "# NetBird SSH client configuration")
assert.Contains(t, configStr, "Generated automatically - do not edit manually")
// Should not contain /dev/null since we have a proper known_hosts setup
assert.NotContains(t, configStr, "UserKnownHostsFile /dev/null")
}
func TestManager_GetHostnameVariants(t *testing.T) {
manager := NewManager()
peerKey := PeerHostKey{
Hostname: "testpeer",
IP: "100.125.1.10",
FQDN: "testpeer.nb.internal",
HostKey: nil, // Not needed for this test
}
variants := manager.getHostnameVariants(peerKey)
expectedVariants := []string{
"100.125.1.10",
"testpeer.nb.internal",
"testpeer",
"[100.125.1.10]:22",
"[100.125.1.10]:22022",
}
assert.ElementsMatch(t, expectedVariants, variants)
}
func TestManager_IsNetBirdEntry(t *testing.T) {
manager := NewManager()
tests := []struct {
entry string
expected bool
}{
{"100.125.1.1 ssh-ed25519 AAAAC3...", true},
{"peer.nb.internal ssh-rsa AAAAB3...", true},
{"test.netbird.com ssh-ed25519 AAAAC3...", true},
{"github.com ssh-rsa AAAAB3...", false},
{"192.168.1.1 ssh-ed25519 AAAAC3...", false},
{"example.com ssh-rsa AAAAB3...", false},
}
for _, test := range tests {
result := manager.isNetBirdEntry(test.entry)
assert.Equal(t, test.expected, result, "Entry: %s", test.entry)
}
}
func TestManager_FormatKnownHostsEntry(t *testing.T) {
manager := NewManager()
// Generate test key
hostKeyPEM, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
parsedKey, err := ssh.ParsePrivateKey(hostKeyPEM)
require.NoError(t, err)
peerKey := PeerHostKey{
Hostname: "testpeer",
IP: "100.125.1.10",
FQDN: "testpeer.nb.internal",
HostKey: parsedKey.PublicKey(),
}
entry := manager.formatKnownHostsEntry(peerKey)
// Should contain all hostname variants
assert.Contains(t, entry, "100.125.1.10")
assert.Contains(t, entry, "testpeer.nb.internal")
assert.Contains(t, entry, "testpeer")
assert.Contains(t, entry, "[100.125.1.10]:22")
assert.Contains(t, entry, "[100.125.1.10]:22022")
// Should contain the public key
keyString := string(ssh.MarshalAuthorizedKey(parsedKey.PublicKey()))
keyString = strings.TrimSpace(keyString)
assert.Contains(t, entry, keyString)
// Should be properly formatted (hostnames followed by key)
parts := strings.Fields(entry)
assert.GreaterOrEqual(t, len(parts), 2, "Entry should have hostnames and key parts")
}
func TestManager_DirectoryFallback(t *testing.T) {
// Create temporary directory for test where system dirs will fail
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Set HOME to temp directory to control user fallback
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
// Create manager with non-writable system directories
manager := &Manager{
sshConfigDir: "/root/nonexistent/ssh_config.d", // Should fail
sshConfigFile: "99-netbird.conf",
knownHostsDir: "/root/nonexistent/ssh_known_hosts.d", // Should fail
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
// Should fall back to user directory
knownHostsPath, err := manager.setupKnownHostsFile()
require.NoError(t, err)
expectedUserPath := filepath.Join(tempDir, ".ssh", "known_hosts_netbird")
assert.Equal(t, expectedUserPath, knownHostsPath)
// Verify file was created
_, err = os.Stat(knownHostsPath)
require.NoError(t, err)
}
func TestGetSystemSSHPaths(t *testing.T) {
configDir, knownHostsDir := getSystemSSHPaths()
// Paths should not be empty
assert.NotEmpty(t, configDir)
assert.NotEmpty(t, knownHostsDir)
// Should be absolute paths
assert.True(t, filepath.IsAbs(configDir))
assert.True(t, filepath.IsAbs(knownHostsDir))
// On Unix systems, should start with /etc
// On Windows, should contain ProgramData
if runtime.GOOS == "windows" {
assert.Contains(t, strings.ToLower(configDir), "programdata")
assert.Contains(t, strings.ToLower(knownHostsDir), "programdata")
} else {
assert.Contains(t, configDir, "/etc/ssh")
assert.Contains(t, knownHostsDir, "/etc/ssh")
}
}
func TestManager_PeerLimit(t *testing.T) {
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Override manager paths to use temp directory
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
knownHostsDir: filepath.Join(tempDir, "ssh_known_hosts.d"),
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
// Generate many peer keys (more than limit)
var peerKeys []PeerHostKey
for i := 0; i < MaxPeersForSSHConfig+10; i++ {
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
pubKey, err := ssh.ParsePrivateKey(hostKey)
require.NoError(t, err)
peerKeys = append(peerKeys, PeerHostKey{
Hostname: fmt.Sprintf("peer%d", i),
IP: fmt.Sprintf("100.125.1.%d", i%254+1),
FQDN: fmt.Sprintf("peer%d.nb.internal", i),
HostKey: pubKey.PublicKey(),
})
}
// Test that SSH config generation is skipped when too many peers
err = manager.SetupSSHClientConfigWithPeers([]string{"nb.internal"}, peerKeys)
require.NoError(t, err)
// Config should not be created due to peer limit
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
_, err = os.Stat(configPath)
assert.True(t, os.IsNotExist(err), "SSH config should not be created with too many peers")
// Test that known_hosts update is also skipped
err = manager.UpdatePeerHostKeys(peerKeys)
require.NoError(t, err)
// Known hosts should not be created due to peer limit
knownHostsPath := filepath.Join(manager.knownHostsDir, manager.knownHostsFile)
_, err = os.Stat(knownHostsPath)
assert.True(t, os.IsNotExist(err), "Known hosts should not be created with too many peers")
}
func TestManager_ForcedSSHConfig(t *testing.T) {
// Set force environment variable
originalForce := os.Getenv(EnvForceSSHConfig)
os.Setenv(EnvForceSSHConfig, "true")
defer func() {
if originalForce == "" {
os.Unsetenv(EnvForceSSHConfig)
} else {
os.Setenv(EnvForceSSHConfig, originalForce)
}
}()
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Override manager paths to use temp directory
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
knownHostsDir: filepath.Join(tempDir, "ssh_known_hosts.d"),
knownHostsFile: "99-netbird",
userKnownHosts: "known_hosts_netbird",
}
// Generate many peer keys (more than limit)
var peerKeys []PeerHostKey
for i := 0; i < MaxPeersForSSHConfig+10; i++ {
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
pubKey, err := ssh.ParsePrivateKey(hostKey)
require.NoError(t, err)
peerKeys = append(peerKeys, PeerHostKey{
Hostname: fmt.Sprintf("peer%d", i),
IP: fmt.Sprintf("100.125.1.%d", i%254+1),
FQDN: fmt.Sprintf("peer%d.nb.internal", i),
HostKey: pubKey.PublicKey(),
})
}
// Test that SSH config generation is forced despite many peers
err = manager.SetupSSHClientConfigWithPeers([]string{"nb.internal"}, peerKeys)
require.NoError(t, err)
// Config should be created despite peer limit due to force flag
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
_, err = os.Stat(configPath)
require.NoError(t, err, "SSH config should be created when forced")
// Verify config contains peer hostnames
content, err := os.ReadFile(configPath)
require.NoError(t, err)
configStr := string(content)
assert.Contains(t, configStr, "peer0.nb.internal")
assert.Contains(t, configStr, "peer1.nb.internal")
}

View File

@@ -1,107 +0,0 @@
package ssh
import (
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"os/user"
"runtime"
"github.com/netbirdio/netbird/util"
)
func isRoot() bool {
return os.Geteuid() == 0
}
func getLoginCmd(username string, remoteAddr net.Addr) (loginPath string, args []string, err error) {
// First, validate the user exists
if err := validateUser(username); err != nil {
return "", nil, err
}
if runtime.GOOS == "windows" {
return getWindowsLoginCmd(username)
}
if !isRoot() {
return getNonRootLoginCmd(username)
}
return getRootLoginCmd(username, remoteAddr)
}
// validateUser checks if the requested user exists and is valid
func validateUser(username string) error {
if username == "" {
return fmt.Errorf("username cannot be empty")
}
// Check if user exists
if _, err := userNameLookup(username); err != nil {
return fmt.Errorf("user %s not found: %w", username, err)
}
return nil
}
// getWindowsLoginCmd handles Windows login (currently limited)
func getWindowsLoginCmd(username string) (string, []string, error) {
currentUser, err := user.Current()
if err != nil {
return "", nil, fmt.Errorf("get current user: %w", err)
}
// Check if requesting a different user
if currentUser.Username != username {
// TODO: Implement Windows user impersonation using CreateProcessAsUser
return "", nil, fmt.Errorf("Windows user switching not implemented: cannot switch from %s to %s", currentUser.Username, username)
}
shell := getUserShell(currentUser.Uid)
return shell, []string{}, nil
}
// getNonRootLoginCmd handles non-root process login
func getNonRootLoginCmd(username string) (string, []string, error) {
// Non-root processes can only SSH as themselves
currentUser, err := user.Current()
if err != nil {
return "", nil, fmt.Errorf("get current user: %w", err)
}
if username != "" && currentUser.Username != username {
return "", nil, fmt.Errorf("non-root process cannot switch users: requested %s but running as %s", username, currentUser.Username)
}
shell := getUserShell(currentUser.Uid)
return shell, []string{"-l"}, nil
}
// getRootLoginCmd handles root-privileged login with user switching
func getRootLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) {
// Require login command to be available
loginPath, err := exec.LookPath("login")
if err != nil {
return "", nil, fmt.Errorf("login command not available: %w", err)
}
addrPort, err := netip.ParseAddrPort(remoteAddr.String())
if err != nil {
return "", nil, fmt.Errorf("parse remote address: %w", err)
}
switch runtime.GOOS {
case "linux":
if util.FileExists("/etc/arch-release") && !util.FileExists("/etc/pam.d/remote") {
return loginPath, []string{"-f", username, "-p"}, nil
}
return loginPath, []string{"-f", username, "-h", addrPort.Addr().String(), "-p"}, nil
case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil
default:
return "", nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
}

View File

@@ -1,808 +0,0 @@
package ssh
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty"
"github.com/gliderlabs/ssh"
"github.com/runletapp/go-console"
log "github.com/sirupsen/logrus"
)
// DefaultSSHPort is the default SSH port of the NetBird's embedded SSH server
const DefaultSSHPort = 22022
const (
errWriteSession = "write session error: %v"
errExitSession = "exit session error: %v"
defaultShell = "/bin/sh"
// Windows shell executables
cmdExe = "cmd.exe"
powershellExe = "powershell.exe"
pwshExe = "pwsh.exe" // nolint:gosec // G101: false positive for shell executable name
// Shell detection strings
powershellName = "powershell"
pwshName = "pwsh"
)
// safeLogCommand returns a safe representation of the command for logging
// Only logs the first argument to avoid leaking sensitive information
func safeLogCommand(cmd []string) string {
if len(cmd) == 0 {
return "<empty>"
}
if len(cmd) == 1 {
return cmd[0]
}
return fmt.Sprintf("%s [%d args]", cmd[0], len(cmd)-1)
}
// NewServer creates an SSH server
func NewServer(hostKeyPEM []byte) *Server {
return &Server{
mu: sync.RWMutex{},
hostKeyPEM: hostKeyPEM,
authorizedKeys: make(map[string]ssh.PublicKey),
sessions: make(map[string]ssh.Session),
}
}
// Server is the SSH server implementation
type Server struct {
listener net.Listener
// authorizedKeys maps peer IDs to their SSH public keys
authorizedKeys map[string]ssh.PublicKey
mu sync.RWMutex
hostKeyPEM []byte
sessions map[string]ssh.Session
running bool
cancel context.CancelFunc
}
// RemoveAuthorizedKey removes the SSH key for a peer
func (s *Server) RemoveAuthorizedKey(peer string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.authorizedKeys, peer)
}
// AddAuthorizedKey adds an SSH key for a peer
func (s *Server) AddAuthorizedKey(peer, newKey string) error {
s.mu.Lock()
defer s.mu.Unlock()
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(newKey))
if err != nil {
return fmt.Errorf("parse key: %w", err)
}
s.authorizedKeys[peer] = parsedKey
return nil
}
// Stop closes the SSH server
func (s *Server) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return nil
}
// Set running to false first to prevent new operations
s.running = false
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
var closeErr error
if s.listener != nil {
closeErr = s.listener.Close()
s.listener = nil
}
// Sessions will close themselves when context is cancelled
// Don't manually close sessions here to avoid double-close
if closeErr != nil {
return fmt.Errorf("close listener: %w", closeErr)
}
return nil
}
func (s *Server) publicKeyHandler(_ ssh.Context, key ssh.PublicKey) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, allowed := range s.authorizedKeys {
if ssh.KeysEqual(allowed, key) {
return true
}
}
return false
}
func prepareUserEnv(user *user.User, shell string) []string {
return []string{
fmt.Sprint("SHELL=" + shell),
fmt.Sprint("USER=" + user.Username),
fmt.Sprint("HOME=" + user.HomeDir),
}
}
func acceptEnv(s string) bool {
split := strings.Split(s, "=")
if len(split) != 2 {
return false
}
return split[0] == "TERM" || split[0] == "LANG" || strings.HasPrefix(split[0], "LC_")
}
// sessionHandler handles SSH sessions
func (s *Server) sessionHandler(session ssh.Session) {
sessionKey := s.registerSession(session)
sessionStart := time.Now()
defer s.unregisterSession(sessionKey, session)
defer func() {
duration := time.Since(sessionStart)
if err := session.Close(); err != nil {
log.WithField("session", sessionKey).Debugf("close session after %v: %v", duration, err)
} else {
log.WithField("session", sessionKey).Debugf("session closed after %v", duration)
}
}()
log.WithField("session", sessionKey).Infof("establishing SSH session for %s from %s", session.User(), session.RemoteAddr())
localUser, err := userNameLookup(session.User())
if err != nil {
s.handleUserLookupError(sessionKey, session, err)
return
}
ptyReq, winCh, isPty := session.Pty()
if !isPty {
s.handleNonPTYSession(sessionKey, session)
return
}
// Check if this is a command execution request with PTY
cmd := session.Command()
if len(cmd) > 0 {
s.handlePTYCommandExecution(sessionKey, session, localUser, ptyReq, winCh, cmd)
} else {
s.handlePTYSession(sessionKey, session, localUser, ptyReq, winCh)
}
log.WithField("session", sessionKey).Debugf("SSH session ended")
}
func (s *Server) registerSession(session ssh.Session) string {
// Get session ID for hashing
sessionID := session.Context().Value(ssh.ContextKeySessionID)
if sessionID == nil {
sessionID = fmt.Sprintf("%p", session)
}
// Create a short 4-byte identifier from the full session ID
hasher := sha256.New()
hasher.Write([]byte(fmt.Sprintf("%v", sessionID)))
hash := hasher.Sum(nil)
shortID := hex.EncodeToString(hash[:4]) // First 4 bytes = 8 hex chars
// Create human-readable session key: user@IP:port-shortID
remoteAddr := session.RemoteAddr().String()
username := session.User()
sessionKey := fmt.Sprintf("%s@%s-%s", username, remoteAddr, shortID)
s.mu.Lock()
s.sessions[sessionKey] = session
s.mu.Unlock()
log.WithField("session", sessionKey).Debugf("registered SSH session")
return sessionKey
}
func (s *Server) unregisterSession(sessionKey string, _ ssh.Session) {
s.mu.Lock()
delete(s.sessions, sessionKey)
s.mu.Unlock()
log.WithField("session", sessionKey).Debugf("unregistered SSH session")
}
func (s *Server) handleUserLookupError(sessionKey string, session ssh.Session, err error) {
logger := log.WithField("session", sessionKey)
if _, writeErr := fmt.Fprintf(session, "remote SSH server couldn't find local user %s\n", session.User()); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if exitErr := session.Exit(1); exitErr != nil {
logger.Debugf(errExitSession, exitErr)
}
logger.Warnf("user lookup failed: %v, user %s from %s", err, session.User(), session.RemoteAddr())
}
func (s *Server) handleNonPTYSession(sessionKey string, session ssh.Session) {
logger := log.WithField("session", sessionKey)
cmd := session.Command()
if len(cmd) == 0 {
// No command specified and no PTY - reject
if _, err := io.WriteString(session, "no command specified and PTY not requested\n"); err != nil {
logger.Debugf(errWriteSession, err)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
logger.Infof("rejected non-PTY session without command from %s", session.RemoteAddr())
return
}
s.handleCommandExecution(sessionKey, session, cmd)
}
func (s *Server) handleCommandExecution(sessionKey string, session ssh.Session, cmd []string) {
logger := log.WithField("session", sessionKey)
localUser, err := userNameLookup(session.User())
if err != nil {
s.handleUserLookupError(sessionKey, session, err)
return
}
logger.Infof("executing command for %s from %s: %s", session.User(), session.RemoteAddr(), safeLogCommand(cmd))
execCmd := s.createCommand(cmd, localUser, session)
if execCmd == nil {
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
if !s.executeCommand(sessionKey, session, execCmd) {
return
}
logger.Debugf("command execution completed")
}
// createCommand creates the exec.Cmd for the given command and user
func (s *Server) createCommand(cmd []string, localUser *user.User, session ssh.Session) *exec.Cmd {
shell := getUserShell(localUser.Uid)
cmdString := strings.Join(cmd, " ")
args := s.getShellCommandArgs(shell, cmdString)
execCmd := exec.Command(args[0], args[1:]...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.prepareCommandEnv(localUser, session)
return execCmd
}
// getShellCommandArgs returns the shell command and arguments for executing a command string
func (s *Server) getShellCommandArgs(shell, cmdString string) []string {
if runtime.GOOS == "windows" {
shellLower := strings.ToLower(shell)
if strings.Contains(shellLower, powershellName) || strings.Contains(shellLower, pwshName) {
return []string{shell, "-Command", cmdString}
} else {
return []string{shell, "/c", cmdString}
}
}
return []string{shell, "-c", cmdString}
}
// prepareCommandEnv prepares environment variables for command execution
func (s *Server) prepareCommandEnv(localUser *user.User, session ssh.Session) []string {
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
// executeCommand executes the command and handles I/O and exit codes
func (s *Server) executeCommand(sessionKey string, session ssh.Session, execCmd *exec.Cmd) bool {
logger := log.WithField("session", sessionKey)
stdinPipe, err := execCmd.StdinPipe()
if err != nil {
logger.Debugf("create stdin pipe failed: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
execCmd.Stdout = session
execCmd.Stderr = session
if err := execCmd.Start(); err != nil {
logger.Debugf("command start failed: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
s.handleCommandIO(sessionKey, stdinPipe, session)
return s.waitForCommandCompletion(sessionKey, session, execCmd)
}
// handleCommandIO manages stdin/stdout copying in a goroutine
func (s *Server) handleCommandIO(sessionKey string, stdinPipe io.WriteCloser, session ssh.Session) {
logger := log.WithField("session", sessionKey)
go func() {
defer func() {
if err := stdinPipe.Close(); err != nil {
logger.Debugf("stdin pipe close error: %v", err)
}
}()
if _, err := io.Copy(stdinPipe, session); err != nil {
logger.Debugf("stdin copy error: %v", err)
}
}()
}
// waitForCommandCompletion waits for command completion and handles exit codes
func (s *Server) waitForCommandCompletion(sessionKey string, session ssh.Session, execCmd *exec.Cmd) bool {
logger := log.WithField("session", sessionKey)
if err := execCmd.Wait(); err != nil {
logger.Debugf("command execution failed: %v", err)
var exitError *exec.ExitError
if errors.As(err, &exitError) {
if err := session.Exit(exitError.ExitCode()); err != nil {
logger.Debugf(errExitSession, err)
}
} else {
if _, writeErr := fmt.Fprintf(session.Stderr(), "failed to execute command: %v\n", err); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
}
return false
}
if err := session.Exit(0); err != nil {
logger.Debugf(errExitSession, err)
}
return true
}
func (s *Server) handlePTYCommandExecution(sessionKey string, session ssh.Session, localUser *user.User, ptyReq ssh.Pty, winCh <-chan ssh.Window, cmd []string) {
logger := log.WithField("session", sessionKey)
logger.Infof("executing PTY command for %s from %s: %s", session.User(), session.RemoteAddr(), safeLogCommand(cmd))
execCmd := s.createPTYCommand(cmd, localUser, ptyReq, session)
if execCmd == nil {
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
ptyFile, err := s.startPTYCommand(execCmd)
if err != nil {
logger.Errorf("PTY start failed: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
defer func() {
if err := ptyFile.Close(); err != nil {
logger.Debugf("PTY file close error: %v", err)
}
}()
s.handlePTYWindowResize(sessionKey, session, ptyFile, winCh)
s.handlePTYIO(sessionKey, session, ptyFile)
s.waitForPTYCompletion(sessionKey, session, execCmd)
}
// createPTYCommand creates the exec.Cmd for PTY execution
func (s *Server) createPTYCommand(cmd []string, localUser *user.User, ptyReq ssh.Pty, session ssh.Session) *exec.Cmd {
shell := getUserShell(localUser.Uid)
cmdString := strings.Join(cmd, " ")
args := s.getShellCommandArgs(shell, cmdString)
execCmd := exec.Command(args[0], args[1:]...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePTYEnv(localUser, ptyReq, session)
return execCmd
}
// preparePTYEnv prepares environment variables for PTY execution
func (s *Server) preparePTYEnv(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) []string {
termType := ptyReq.Term
if termType == "" {
termType = "xterm-256color"
}
env := []string{
fmt.Sprintf("TERM=%s", termType),
"LANG=en_US.UTF-8",
"LC_ALL=en_US.UTF-8",
}
env = append(env, prepareUserEnv(localUser, getUserShell(localUser.Uid))...)
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
// startPTYCommand starts the command with PTY
func (s *Server) startPTYCommand(execCmd *exec.Cmd) (*os.File, error) {
ptyFile, err := pty.Start(execCmd)
if err != nil {
return nil, err
}
// Set initial PTY size to reasonable defaults if not set
_ = pty.Setsize(ptyFile, &pty.Winsize{
Rows: 24,
Cols: 80,
})
return ptyFile, nil
}
// handlePTYWindowResize handles window resize events
func (s *Server) handlePTYWindowResize(sessionKey string, session ssh.Session, ptyFile *os.File, winCh <-chan ssh.Window) {
logger := log.WithField("session", sessionKey)
go func() {
for {
select {
case <-session.Context().Done():
return
case win, ok := <-winCh:
if !ok {
return
}
if err := pty.Setsize(ptyFile, &pty.Winsize{
Rows: uint16(win.Height),
Cols: uint16(win.Width),
}); err != nil {
logger.Warnf("failed to resize PTY to %dx%d: %v", win.Width, win.Height, err)
}
}
}
}()
}
// handlePTYIO handles PTY input/output copying
func (s *Server) handlePTYIO(sessionKey string, session ssh.Session, ptyFile *os.File) {
logger := log.WithField("session", sessionKey)
go func() {
defer func() {
if err := ptyFile.Close(); err != nil {
logger.Debugf("PTY file close error: %v", err)
}
}()
if _, err := io.Copy(ptyFile, session); err != nil {
logger.Debugf("PTY input copy error: %v", err)
}
}()
go func() {
defer func() {
if err := session.Close(); err != nil {
logger.Debugf("session close error: %v", err)
}
}()
if _, err := io.Copy(session, ptyFile); err != nil {
logger.Debugf("PTY output copy error: %v", err)
}
}()
}
// waitForPTYCompletion waits for PTY command completion and handles exit codes
func (s *Server) waitForPTYCompletion(sessionKey string, session ssh.Session, execCmd *exec.Cmd) {
logger := log.WithField("session", sessionKey)
if err := execCmd.Wait(); err != nil {
logger.Debugf("PTY command execution failed: %v", err)
var exitError *exec.ExitError
if errors.As(err, &exitError) {
if err := session.Exit(exitError.ExitCode()); err != nil {
logger.Debugf(errExitSession, err)
}
} else {
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
}
} else {
if err := session.Exit(0); err != nil {
logger.Debugf(errExitSession, err)
}
}
}
func (s *Server) handlePTYSession(sessionKey string, session ssh.Session, localUser *user.User, ptyReq ssh.Pty, winCh <-chan ssh.Window) {
logger := log.WithField("session", sessionKey)
loginCmd, loginArgs, err := getLoginCmd(localUser.Username, session.RemoteAddr())
if err != nil {
logger.Warnf("login command setup failed: %v for user %s from %s", err, localUser.Username, session.RemoteAddr())
return
}
proc, err := console.New(ptyReq.Window.Width, ptyReq.Window.Height)
if err != nil {
logger.Errorf("console creation failed: %v", err)
return
}
defer func() {
if err := proc.Close(); err != nil {
logger.Debugf("close console: %v", err)
}
}()
if err := s.setupConsoleProcess(sessionKey, proc, localUser, ptyReq, session); err != nil {
logger.Errorf("console setup failed: %v", err)
return
}
args := append([]string{loginCmd}, loginArgs...)
logger.Debugf("login command: %s", args)
if err := proc.Start(args); err != nil {
logger.Errorf("console start failed: %v", err)
return
}
// Setup window resizing and I/O
go s.handleWindowResize(sessionKey, session.Context(), winCh, proc)
go s.stdInOut(sessionKey, proc, session)
processState, err := proc.Wait()
if err != nil {
logger.Debugf("console wait: %v", err)
_ = session.Exit(1)
} else {
exitCode := processState.ExitCode()
_ = session.Exit(exitCode)
}
}
// setupConsoleProcess configures the console process environment
func (s *Server) setupConsoleProcess(sessionKey string, proc console.Console, localUser *user.User, ptyReq ssh.Pty, session ssh.Session) error {
logger := log.WithField("session", sessionKey)
// Set working directory
if err := proc.SetCWD(localUser.HomeDir); err != nil {
logger.Debugf("failed to set working directory: %v", err)
}
// Prepare environment variables
env := []string{fmt.Sprintf("TERM=%s", ptyReq.Term)}
env = append(env, prepareUserEnv(localUser, getUserShell(localUser.Uid))...)
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
// Set environment variables
if err := proc.SetENV(env); err != nil {
logger.Debugf("failed to set environment: %v", err)
return err
}
return nil
}
func (s *Server) handleWindowResize(sessionKey string, ctx context.Context, winCh <-chan ssh.Window, proc console.Console) {
logger := log.WithField("session", sessionKey)
for {
select {
case <-ctx.Done():
return
case win, ok := <-winCh:
if !ok {
return
}
if err := proc.SetSize(win.Width, win.Height); err != nil {
logger.Warnf("failed to resize terminal window to %dx%d: %v", win.Width, win.Height, err)
} else {
logger.Debugf("resized terminal window to %dx%d", win.Width, win.Height)
}
}
}
}
func (s *Server) stdInOut(sessionKey string, proc io.ReadWriter, session ssh.Session) {
logger := log.WithField("session", sessionKey)
// Copy stdin from session to process
go func() {
if _, err := io.Copy(proc, session); err != nil {
logger.Debugf("stdin copy error: %v", err)
}
}()
// Copy stdout from process to session
go func() {
if _, err := io.Copy(session, proc); err != nil {
logger.Debugf("stdout copy error: %v", err)
}
}()
// Wait for session to be done
<-session.Context().Done()
}
// Start runs the SSH server
func (s *Server) Start(addr string) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return fmt.Errorf("server already running")
}
ctx, cancel := context.WithCancel(context.Background())
lc := &net.ListenConfig{}
ln, err := lc.Listen(ctx, "tcp", addr)
if err != nil {
s.mu.Unlock()
cancel()
return fmt.Errorf("listen: %w", err)
}
s.running = true
s.cancel = cancel
s.listener = ln
listenerAddr := ln.Addr().String()
listenerCopy := ln
s.mu.Unlock()
log.Infof("starting SSH server on addr: %s", listenerAddr)
// Ensure cleanup happens when Start() exits
defer func() {
s.mu.Lock()
if s.running {
s.running = false
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
s.listener = nil
}
s.mu.Unlock()
}()
done := make(chan error, 1)
go func() {
publicKeyOption := ssh.PublicKeyAuth(s.publicKeyHandler)
hostKeyPEM := ssh.HostKeyPEM(s.hostKeyPEM)
done <- ssh.Serve(listenerCopy, s.sessionHandler, publicKeyOption, hostKeyPEM)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
if err != nil {
return fmt.Errorf("serve: %w", err)
}
return nil
}
}
// getUserShell returns the appropriate shell for the given user ID
// Handles all platform-specific logic and fallbacks consistently
func getUserShell(userID string) string {
switch runtime.GOOS {
case "windows":
return getWindowsUserShell()
default:
return getUnixUserShell(userID)
}
}
// getWindowsUserShell returns the best shell for Windows users
// Order: pwsh.exe -> powershell.exe -> COMSPEC -> cmd.exe
func getWindowsUserShell() string {
if _, err := exec.LookPath(pwshExe); err == nil {
return pwshExe
}
if _, err := exec.LookPath(powershellExe); err == nil {
return powershellExe
}
if comspec := os.Getenv("COMSPEC"); comspec != "" {
return comspec
}
return cmdExe
}
// getUnixUserShell returns the shell for Unix-like systems
func getUnixUserShell(userID string) string {
shell := getShellFromPasswd(userID)
if shell != "" {
return shell
}
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
return defaultShell
}
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
func getShellFromPasswd(userID string) string {
file, err := os.Open("/etc/passwd")
if err != nil {
return ""
}
defer func() {
if err := file.Close(); err != nil {
log.Warnf("close /etc/passwd file: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, userID+":") {
continue
}
fields := strings.Split(line, ":")
if len(fields) < 7 {
return ""
}
shell := strings.TrimSpace(fields[6])
return shell
}
return ""
}
func userNameLookup(username string) (*user.User, error) {
if username == "" || (username == "root" && !isRoot()) {
return user.Current()
}
u, err := user.Lookup(username)
if err != nil {
log.Warnf("user lookup failed for %s, falling back to current user: %v", username, err)
return user.Current()
}
return u, nil
}

View File

@@ -0,0 +1,298 @@
package server
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"runtime"
"time"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// handleCommand executes an SSH command with privilege validation
func (s *Server) handleCommand(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) {
localUser := privilegeResult.User
hasPty := winCh != nil
commandType := "command"
if hasPty {
commandType = "Pty command"
}
logger.Infof("executing %s for %s from %s: %s", commandType, localUser.Username, session.RemoteAddr(), safeLogCommand(session.Command()))
execCmd, err := s.createCommandWithPrivileges(privilegeResult, session, hasPty)
if err != nil {
logger.Errorf("%s creation failed: %v", commandType, err)
errorMsg := fmt.Sprintf("Cannot create %s - platform may not support user switching", commandType)
if hasPty {
errorMsg += " with Pty"
}
errorMsg += "\n"
if _, writeErr := fmt.Fprintf(session.Stderr(), errorMsg); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
var success bool
if hasPty {
success = s.handlePty(logger, session, privilegeResult, ptyReq, winCh)
} else {
success = s.executeCommand(logger, session, execCmd)
}
if !success {
return
}
logger.Debugf("%s execution completed", commandType)
}
func (s *Server) createCommandWithPrivileges(privilegeResult PrivilegeCheckResult, session ssh.Session, hasPty bool) (*exec.Cmd, error) {
localUser := privilegeResult.User
var cmd *exec.Cmd
var err error
// If we used fallback (unprivileged process), skip su and use direct execution
if privilegeResult.UsedFallback {
log.Debugf("using fallback - direct execution for current user")
cmd, err = s.createDirectCommand(session, localUser)
} else {
// Try su first for system integration (PAM/audit) when privileged
cmd, err = s.createSuCommand(session, localUser)
if err != nil {
// Always fall back to executor if su fails
log.Debugf("su command failed, falling back to executor: %v", err)
cmd, err = s.createExecutorCommand(session, localUser, hasPty)
}
}
if err != nil {
return nil, fmt.Errorf("create command with privileges: %w", err)
}
cmd.Env = s.prepareCommandEnv(localUser, session)
return cmd, nil
}
// getShellCommandArgs returns the shell command and arguments for executing a command string
func (s *Server) getShellCommandArgs(shell, cmdString string) []string {
if runtime.GOOS == "windows" {
if cmdString == "" {
return []string{shell, "-NoLogo"}
}
return []string{shell, "-Command", cmdString}
}
if cmdString == "" {
return []string{shell}
}
return []string{shell, "-c", cmdString}
}
// executeCommand executes the command and handles I/O and exit codes
func (s *Server) executeCommand(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd) bool {
s.setupProcessGroup(execCmd)
stdinPipe, err := execCmd.StdinPipe()
if err != nil {
logger.Errorf("create stdin pipe: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
execCmd.Stdout = session
execCmd.Stderr = session
if execCmd.Dir != "" {
if _, err := os.Stat(execCmd.Dir); err != nil {
logger.Warnf("working directory does not exist: %s (%v)", execCmd.Dir, err)
execCmd.Dir = "/"
}
}
if err := execCmd.Start(); err != nil {
logger.Errorf("command start failed: %v", err)
// no user message for exec failure, just exit
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
go s.handleCommandIO(logger, stdinPipe, session)
return s.waitForCommandCleanup(logger, session, execCmd)
}
// handleCommandIO manages stdin/stdout copying in a goroutine
func (s *Server) handleCommandIO(logger *log.Entry, stdinPipe io.WriteCloser, session ssh.Session) {
defer func() {
if err := stdinPipe.Close(); err != nil {
logger.Debugf("stdin pipe close error: %v", err)
}
}()
if _, err := io.Copy(stdinPipe, session); err != nil {
logger.Debugf("stdin copy error: %v", err)
}
}
// waitForCommandCompletion waits for command completion and handles exit codes
func (s *Server) waitForCommandCompletion(sessionKey SessionKey, session ssh.Session, execCmd *exec.Cmd) bool {
logger := log.WithField("session", sessionKey)
if err := execCmd.Wait(); err != nil {
logger.Debugf("command execution failed: %v", err)
var exitError *exec.ExitError
if errors.As(err, &exitError) {
if err := session.Exit(exitError.ExitCode()); err != nil {
logger.Debugf(errExitSession, err)
}
} else {
if _, writeErr := fmt.Fprintf(session.Stderr(), "failed to execute command: %v\n", err); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
}
return false
}
if err := session.Exit(0); err != nil {
logger.Debugf(errExitSession, err)
}
return true
}
// createPtyCommandWithPrivileges creates the exec.Cmd for Pty execution respecting privilege check results
func (s *Server) createPtyCommandWithPrivileges(cmd []string, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
localUser := privilegeResult.User
if privilegeResult.RequiresUserSwitching {
return s.createPtyUserSwitchCommand(cmd, localUser, ptyReq, session)
}
// No user switching needed - create direct Pty command
shell := getUserShell(localUser.Uid)
rawCmd := session.RawCommand()
args := s.getShellCommandArgs(shell, rawCmd)
execCmd := exec.CommandContext(session.Context(), args[0], args[1:]...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// preparePtyEnv prepares environment variables for Pty execution
func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) []string {
termType := ptyReq.Term
if termType == "" {
termType = "xterm-256color"
}
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
env = append(env, fmt.Sprintf("TERM=%s", termType))
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
// waitForCommandCleanup waits for command completion with session disconnect handling
func (s *Server) waitForCommandCleanup(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd) bool {
ctx := session.Context()
done := make(chan error, 1)
go func() {
done <- execCmd.Wait()
}()
select {
case <-ctx.Done():
logger.Debugf("session cancelled, terminating command")
s.killProcessGroup(execCmd)
select {
case err := <-done:
logger.Tracef("command terminated after session cancellation: %v", err)
case <-time.After(5 * time.Second):
logger.Warnf("command did not terminate within 5 seconds after session cancellation")
}
if err := session.Exit(130); err != nil {
logger.Debugf(errExitSession, err)
}
return false
case err := <-done:
return s.handleCommandCompletion(logger, session, err)
}
}
// handleCommandSessionCancellation handles command session cancellation
func (s *Server) handleCommandSessionCancellation(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, done <-chan error) {
logger.Debugf("session cancelled, terminating command")
s.killProcessGroup(execCmd)
select {
case err := <-done:
logger.Debugf("command terminated after session cancellation: %v", err)
case <-time.After(5 * time.Second):
logger.Warnf("command did not terminate within 5 seconds after session cancellation")
}
if err := session.Exit(130); err != nil {
logger.Debugf(errExitSession, err)
}
}
// handleCommandCompletion handles command completion
func (s *Server) handleCommandCompletion(logger *log.Entry, session ssh.Session, err error) bool {
if err != nil {
logger.Debugf("command execution failed: %v", err)
s.handleSessionExit(session, err, logger)
return false
}
s.handleSessionExit(session, nil, logger)
return true
}
// handleSessionExit handles command errors and sets appropriate exit codes
func (s *Server) handleSessionExit(session ssh.Session, err error, logger *log.Entry) {
if err == nil {
if err := session.Exit(0); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
if err := session.Exit(exitError.ExitCode()); err != nil {
logger.Debugf(errExitSession, err)
}
} else {
logger.Debugf("non-exit error in command execution: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
}
}

View File

@@ -0,0 +1,262 @@
//go:build unix
package server
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"sync"
"syscall"
"time"
"github.com/creack/pty"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// createSuCommand creates a command using su -l -c for privilege switching
func (s *Server) createSuCommand(session ssh.Session, localUser *user.User) (*exec.Cmd, error) {
suPath, err := exec.LookPath("su")
if err != nil {
return nil, fmt.Errorf("su command not available: %w", err)
}
command := session.RawCommand()
if command == "" {
return nil, fmt.Errorf("no command specified for su execution")
}
// Use su -l -c to execute the command as the target user with login environment
args := []string{"-l", localUser.Username, "-c", command}
cmd := exec.CommandContext(session.Context(), suPath, args...)
cmd.Dir = localUser.HomeDir
return cmd, nil
}
// prepareCommandEnv prepares environment variables for command execution on Unix
func (s *Server) prepareCommandEnv(localUser *user.User, session ssh.Session) []string {
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
// ptyManager manages Pty file operations with thread safety
type ptyManager struct {
file *os.File
mu sync.RWMutex
closed bool
closeErr error
once sync.Once
}
func newPtyManager(file *os.File) *ptyManager {
return &ptyManager{file: file}
}
func (pm *ptyManager) Close() error {
pm.once.Do(func() {
pm.mu.Lock()
pm.closed = true
pm.closeErr = pm.file.Close()
pm.mu.Unlock()
})
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.closeErr
}
func (pm *ptyManager) Setsize(ws *pty.Winsize) error {
pm.mu.RLock()
defer pm.mu.RUnlock()
if pm.closed {
return errors.New("Pty is closed")
}
return pty.Setsize(pm.file, ws)
}
func (pm *ptyManager) File() *os.File {
return pm.file
}
func (s *Server) handlePty(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool {
cmd := session.Command()
localUser := privilegeResult.User
logger.Infof("executing Pty command for %s from %s: %s", localUser.Username, session.RemoteAddr(), safeLogCommand(cmd))
execCmd, err := s.createPtyCommandWithPrivileges(cmd, privilegeResult, ptyReq, session)
if err != nil {
logger.Errorf("Pty command creation failed: %v", err)
errorMsg := "User switching failed - login command not available\r\n"
if _, writeErr := fmt.Fprintf(session.Stderr(), errorMsg); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
ptmx, err := s.startPtyCommandWithSize(execCmd, ptyReq)
if err != nil {
logger.Errorf("Pty start failed: %v", err)
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return false
}
ptyMgr := newPtyManager(ptmx)
defer func() {
if err := ptyMgr.Close(); err != nil {
logger.Debugf("Pty close error: %v", err)
}
}()
go s.handlePtyWindowResize(logger, session, ptyMgr, winCh)
s.handlePtyIO(logger, session, ptyMgr)
s.waitForPtyCompletion(logger, session, execCmd, ptyMgr)
return true
}
func (s *Server) startPtyCommandWithSize(execCmd *exec.Cmd, ptyReq ssh.Pty) (*os.File, error) {
winSize := &pty.Winsize{
Cols: uint16(ptyReq.Window.Width),
Rows: uint16(ptyReq.Window.Height),
}
if winSize.Cols == 0 {
winSize.Cols = 80
}
if winSize.Rows == 0 {
winSize.Rows = 24
}
ptmx, err := pty.StartWithSize(execCmd, winSize)
if err != nil {
return nil, fmt.Errorf("start Pty: %w", err)
}
return ptmx, nil
}
func (s *Server) handlePtyWindowResize(logger *log.Entry, session ssh.Session, ptyMgr *ptyManager, winCh <-chan ssh.Window) {
for {
select {
case <-session.Context().Done():
return
case win, ok := <-winCh:
if !ok {
return
}
if err := ptyMgr.Setsize(&pty.Winsize{Rows: uint16(win.Height), Cols: uint16(win.Width)}); err != nil {
logger.Debugf("Pty resize to %dx%d: %v", win.Width, win.Height, err)
}
}
}
}
func (s *Server) handlePtyIO(logger *log.Entry, session ssh.Session, ptyMgr *ptyManager) {
ptmx := ptyMgr.File()
go func() {
if _, err := io.Copy(ptmx, session); err != nil {
logger.Debugf("Pty input copy error: %v", err)
}
}()
go func() {
defer func() {
if err := session.Close(); err != nil {
logger.Debugf("session close error: %v", err)
}
}()
if _, err := io.Copy(session, ptmx); err != nil {
logger.Debugf("Pty output copy error: %v", err)
}
}()
}
func (s *Server) waitForPtyCompletion(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyMgr *ptyManager) {
ctx := session.Context()
done := make(chan error, 1)
go func() {
done <- execCmd.Wait()
}()
select {
case <-ctx.Done():
s.handlePtySessionCancellation(logger, session, execCmd, ptyMgr, done)
case err := <-done:
s.handlePtyCommandCompletion(logger, session, err)
}
}
func (s *Server) handlePtySessionCancellation(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyMgr *ptyManager, done <-chan error) {
logger.Debugf("Pty session cancelled, terminating command")
if err := ptyMgr.Close(); err != nil {
logger.Debugf("Pty close during session cancellation: %v", err)
}
s.killProcessGroup(execCmd)
select {
case err := <-done:
if err != nil {
logger.Debugf("Pty command terminated after session cancellation with error: %v", err)
} else {
logger.Debugf("Pty command terminated after session cancellation")
}
case <-time.After(5 * time.Second):
logger.Warnf("Pty command did not terminate within 5 seconds after session cancellation")
}
if err := session.Exit(130); err != nil {
logger.Debugf(errExitSession, err)
}
}
func (s *Server) handlePtyCommandCompletion(logger *log.Entry, session ssh.Session, err error) {
if err != nil {
logger.Debugf("Pty command execution failed: %v", err)
s.handleSessionExit(session, err, logger)
return
}
// Normal completion
logger.Debugf("Pty command completed successfully")
if err := session.Exit(0); err != nil {
logger.Debugf(errExitSession, err)
}
}
func (s *Server) setupProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}
func (s *Server) killProcessGroup(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
logger := log.WithField("pid", cmd.Process.Pid)
pgid := cmd.Process.Pid
if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil {
logger.Debugf("kill process group SIGTERM failed: %v", err)
if err := syscall.Kill(-pgid, syscall.SIGKILL); err != nil {
logger.Debugf("kill process group SIGKILL failed: %v", err)
}
}
}

View File

@@ -0,0 +1,403 @@
//go:build windows
package server
import (
"context"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"unsafe"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/netbirdio/netbird/client/ssh/server/winpty"
)
// createCommandWithUserSwitch creates a command with Windows user switching
func (s *Server) createCommandWithUserSwitch(_ []string, localUser *user.User, session ssh.Session) (*exec.Cmd, error) {
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
rawCmd := session.RawCommand()
privilegeDropper := NewPrivilegeDropper()
cmd, err := privilegeDropper.CreateWindowsShellAsUser(
session.Context(), shell, rawCmd, username, domain, localUser.HomeDir)
if err != nil {
return nil, err
}
log.Infof("Created Windows command with user switching for %s", localUser.Username)
return cmd, nil
}
// getUserEnvironment retrieves the Windows environment for the target user.
// Follows OpenSSH's resilient approach with graceful degradation on failures.
func (s *Server) getUserEnvironment(username, domain string) ([]string, error) {
userToken, err := s.getUserToken(username, domain)
if err != nil {
return nil, fmt.Errorf("get user token: %w", err)
}
defer func() {
if err := windows.CloseHandle(userToken); err != nil {
log.Debugf("close user token: %v", err)
}
}()
userProfile, err := s.loadUserProfile(userToken, username, domain)
if err != nil {
log.Debugf("failed to load user profile for %s\\%s: %v", domain, username, err)
userProfile = fmt.Sprintf("C:\\Users\\%s", username)
}
envMap := make(map[string]string)
if err := s.loadSystemEnvironment(envMap); err != nil {
log.Debugf("failed to load system environment from registry: %v", err)
}
s.setUserEnvironmentVariables(envMap, userProfile, username, domain)
var env []string
for key, value := range envMap {
env = append(env, key+"="+value)
}
return env, nil
}
// getUserToken creates a user token for the specified user.
func (s *Server) getUserToken(username, domain string) (windows.Handle, error) {
privilegeDropper := NewPrivilegeDropper()
token, err := privilegeDropper.createToken(username, domain)
if err != nil {
return 0, fmt.Errorf("generate S4U user token: %w", err)
}
return token, nil
}
// loadUserProfile loads the Windows user profile and returns the profile path.
func (s *Server) loadUserProfile(userToken windows.Handle, username, domain string) (string, error) {
usernamePtr, err := windows.UTF16PtrFromString(username)
if err != nil {
return "", fmt.Errorf("convert username to UTF-16: %w", err)
}
var domainUTF16 *uint16
if domain != "" && domain != "." {
domainUTF16, err = windows.UTF16PtrFromString(domain)
if err != nil {
return "", fmt.Errorf("convert domain to UTF-16: %w", err)
}
}
type profileInfo struct {
dwSize uint32
dwFlags uint32
lpUserName *uint16
lpProfilePath *uint16
lpDefaultPath *uint16
lpServerName *uint16
lpPolicyPath *uint16
hProfile windows.Handle
}
const PI_NOUI = 0x00000001
profile := profileInfo{
dwSize: uint32(unsafe.Sizeof(profileInfo{})),
dwFlags: PI_NOUI,
lpUserName: usernamePtr,
lpServerName: domainUTF16,
}
userenv := windows.NewLazySystemDLL("userenv.dll")
loadUserProfileW := userenv.NewProc("LoadUserProfileW")
ret, _, err := loadUserProfileW.Call(
uintptr(userToken),
uintptr(unsafe.Pointer(&profile)),
)
if ret == 0 {
return "", fmt.Errorf("LoadUserProfileW: %w", err)
}
if profile.lpProfilePath == nil {
return "", fmt.Errorf("LoadUserProfileW returned null profile path")
}
profilePath := windows.UTF16PtrToString(profile.lpProfilePath)
return profilePath, nil
}
// loadSystemEnvironment loads system-wide environment variables from registry.
func (s *Server) loadSystemEnvironment(envMap map[string]string) error {
key, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SYSTEM\CurrentControlSet\Control\Session Manager\Environment`,
registry.QUERY_VALUE)
if err != nil {
return fmt.Errorf("open system environment registry key: %w", err)
}
defer func() {
if err := key.Close(); err != nil {
log.Debugf("close registry key: %v", err)
}
}()
return s.readRegistryEnvironment(key, envMap)
}
// readRegistryEnvironment reads environment variables from a registry key.
func (s *Server) readRegistryEnvironment(key registry.Key, envMap map[string]string) error {
names, err := key.ReadValueNames(0)
if err != nil {
return fmt.Errorf("read registry value names: %w", err)
}
for _, name := range names {
value, valueType, err := key.GetStringValue(name)
if err != nil {
log.Debugf("failed to read registry value %s: %v", name, err)
continue
}
finalValue := s.expandRegistryValue(value, valueType, name)
s.setEnvironmentVariable(envMap, name, finalValue)
}
return nil
}
// expandRegistryValue expands registry values if they contain environment variables.
func (s *Server) expandRegistryValue(value string, valueType uint32, name string) string {
if valueType != registry.EXPAND_SZ {
return value
}
sourcePtr := windows.StringToUTF16Ptr(value)
expandedBuffer := make([]uint16, 1024)
expandedLen, err := windows.ExpandEnvironmentStrings(sourcePtr, &expandedBuffer[0], uint32(len(expandedBuffer)))
if err != nil {
log.Debugf("failed to expand environment string for %s: %v", name, err)
return value
}
if expandedLen > 0 {
return windows.UTF16ToString(expandedBuffer[:expandedLen-1])
}
return value
}
// setEnvironmentVariable sets an environment variable with special handling for PATH.
func (s *Server) setEnvironmentVariable(envMap map[string]string, name, value string) {
upperName := strings.ToUpper(name)
if upperName == "PATH" {
if existing, exists := envMap["PATH"]; exists && existing != value {
envMap["PATH"] = existing + ";" + value
} else {
envMap["PATH"] = value
}
} else {
envMap[upperName] = value
}
}
// setUserEnvironmentVariables sets critical user-specific environment variables.
func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfile, username, domain string) {
envMap["USERPROFILE"] = userProfile
if len(userProfile) >= 2 && userProfile[1] == ':' {
envMap["HOMEDRIVE"] = userProfile[:2]
envMap["HOMEPATH"] = userProfile[2:]
}
envMap["APPDATA"] = filepath.Join(userProfile, "AppData", "Roaming")
envMap["LOCALAPPDATA"] = filepath.Join(userProfile, "AppData", "Local")
tempDir := filepath.Join(userProfile, "AppData", "Local", "Temp")
envMap["TEMP"] = tempDir
envMap["TMP"] = tempDir
envMap["USERNAME"] = username
if domain != "" && domain != "." {
envMap["USERDOMAIN"] = domain
envMap["USERDNSDOMAIN"] = domain
}
systemVars := []string{
"PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER", "PROCESSOR_LEVEL", "PROCESSOR_REVISION",
"SYSTEMDRIVE", "SYSTEMROOT", "WINDIR", "COMPUTERNAME", "OS", "PATHEXT",
"PROGRAMFILES", "PROGRAMDATA", "ALLUSERSPROFILE", "COMSPEC",
}
for _, sysVar := range systemVars {
if sysValue := os.Getenv(sysVar); sysValue != "" {
envMap[sysVar] = sysValue
}
}
}
// prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(localUser *user.User, session ssh.Session) []string {
username, domain := s.parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
env := userEnv
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
env = append(env, v)
}
}
return env
}
func (s *Server) handlePty(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool {
localUser := privilegeResult.User
cmd := session.Command()
logger.Infof("executing Pty command for %s from %s: %s", localUser.Username, session.RemoteAddr(), safeLogCommand(cmd))
// Always use user switching on Windows - no direct execution
s.handlePtyWithUserSwitching(logger, session, privilegeResult, ptyReq, winCh, cmd)
return true
}
func (s *Server) handlePtyWithUserSwitching(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window, _ []string) {
localUser := privilegeResult.User
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
var command string
rawCmd := session.RawCommand()
if rawCmd != "" {
command = rawCmd
}
req := PtyExecutionRequest{
Shell: shell,
Command: command,
Width: ptyReq.Window.Width,
Height: ptyReq.Window.Height,
Username: username,
Domain: domain,
}
err := executePtyCommandWithUserToken(session.Context(), session, req)
if err != nil {
logger.Errorf("Windows ConPty with user switching failed: %v", err)
var errorMsg string
if runtime.GOOS == "windows" {
errorMsg = "Windows user switching failed - NetBird must run as a Windows service or with elevated privileges for user switching\r\n"
} else {
errorMsg = "User switching failed - login command not available\r\n"
}
if _, writeErr := fmt.Fprintf(session.Stderr(), errorMsg); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
return
}
logger.Debugf("Windows ConPty command execution with user switching completed")
}
type PtyExecutionRequest struct {
Shell string
Command string
Width int
Height int
Username string
Domain string
}
func executePtyCommandWithUserToken(ctx context.Context, session ssh.Session, req PtyExecutionRequest) error {
log.Tracef("executing Windows ConPty command with user switching: shell=%s, command=%s, user=%s\\%s, size=%dx%d",
req.Shell, req.Command, req.Domain, req.Username, req.Width, req.Height)
privilegeDropper := NewPrivilegeDropper()
userToken, err := privilegeDropper.createToken(req.Username, req.Domain)
if err != nil {
return fmt.Errorf("create user token: %w", err)
}
defer func() {
if err := windows.CloseHandle(userToken); err != nil {
log.Debugf("close user token: %v", err)
}
}()
server := &Server{}
userEnv, err := server.getUserEnvironment(req.Username, req.Domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using system environment: %v", req.Domain, req.Username, err)
userEnv = os.Environ()
}
workingDir := getUserHomeFromEnv(userEnv)
if workingDir == "" {
workingDir = fmt.Sprintf(`C:\Users\%s`, req.Username)
}
ptyConfig := winpty.PtyConfig{
Shell: req.Shell,
Command: req.Command,
Width: req.Width,
Height: req.Height,
WorkingDir: workingDir,
}
userConfig := winpty.UserConfig{
Token: userToken,
Environment: userEnv,
}
log.Debugf("executePtyCommandWithUserToken: calling winpty execution with working dir: %s", workingDir)
return winpty.ExecutePtyWithUserToken(ctx, session, ptyConfig, userConfig)
}
func getUserHomeFromEnv(env []string) string {
for _, envVar := range env {
if len(envVar) > 12 && envVar[:12] == "USERPROFILE=" {
return envVar[12:]
}
}
return ""
}
func (s *Server) setupProcessGroup(_ *exec.Cmd) {
// Windows doesn't support process groups in the same way as Unix
// Process creation groups are handled differently
}
func (s *Server) killProcessGroup(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
logger := log.WithField("pid", cmd.Process.Pid)
if err := cmd.Process.Kill(); err != nil {
logger.Debugf("kill process failed: %v", err)
}
}

View File

@@ -0,0 +1,691 @@
package server
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
// TestSSHServerCompatibility tests that our SSH server is compatible with the system SSH client
func TestSSHServerCompatibility(t *testing.T) {
if testing.Short() {
t.Skip("Skipping SSH compatibility tests in short mode")
}
// Check if ssh binary is available
if !isSSHClientAvailable() {
t.Skip("SSH client not available on this system")
}
// Set up SSH server - use our existing key generation for server
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
// Generate OpenSSH-compatible keys for client
clientPrivKeyOpenSSH, clientPubKeyOpenSSH, err := generateOpenSSHKey()
require.NoError(t, err)
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKeyOpenSSH))
require.NoError(t, err)
serverAddr := StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Create temporary key files for SSH client
clientKeyFile, cleanupKey := createTempKeyFileFromBytes(t, clientPrivKeyOpenSSH)
defer cleanupKey()
// Extract host and port from server address
host, portStr, err := net.SplitHostPort(serverAddr)
require.NoError(t, err)
// Get current user for SSH connection instead of hardcoded test-user
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for compatibility test")
t.Run("basic command execution", func(t *testing.T) {
testSSHCommandExecutionWithUser(t, host, portStr, clientKeyFile, currentUser.Username)
})
t.Run("interactive command", func(t *testing.T) {
testSSHInteractiveCommand(t, host, portStr, clientKeyFile)
})
t.Run("port forwarding", func(t *testing.T) {
testSSHPortForwarding(t, host, portStr, clientKeyFile)
})
}
// testSSHCommandExecutionWithUser tests basic command execution with system SSH client using specified user.
func testSSHCommandExecutionWithUser(t *testing.T, host, port, keyFile, username string) {
cmd := exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("%s@%s", username, host),
"echo", "hello_world")
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("SSH command failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
assert.Contains(t, string(output), "hello_world", "SSH command should execute successfully")
}
// testSSHCommandExecution tests basic command execution with system SSH client.
func testSSHCommandExecution(t *testing.T, host, port, keyFile string) {
cmd := exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
"echo", "hello_world")
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("SSH command failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
assert.Contains(t, string(output), "hello_world", "SSH command should execute successfully")
}
// testSSHInteractiveCommand tests interactive shell session.
func testSSHInteractiveCommand(t *testing.T, host, port, keyFile string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host))
stdin, err := cmd.StdinPipe()
if err != nil {
t.Skipf("Cannot create stdin pipe: %v", err)
return
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Skipf("Cannot create stdout pipe: %v", err)
return
}
err = cmd.Start()
if err != nil {
t.Logf("Cannot start SSH session: %v", err)
return
}
go func() {
defer func() {
if err := stdin.Close(); err != nil {
t.Logf("stdin close error: %v", err)
}
}()
time.Sleep(100 * time.Millisecond)
if _, err := stdin.Write([]byte("echo interactive_test\n")); err != nil {
t.Logf("stdin write error: %v", err)
}
time.Sleep(100 * time.Millisecond)
if _, err := stdin.Write([]byte("exit\n")); err != nil {
t.Logf("stdin write error: %v", err)
}
}()
output, err := io.ReadAll(stdout)
if err != nil {
t.Logf("Cannot read SSH output: %v", err)
}
err = cmd.Wait()
if err != nil {
t.Logf("SSH interactive session error: %v", err)
t.Logf("Output: %s", string(output))
return
}
assert.Contains(t, string(output), "interactive_test", "Interactive SSH session should work")
}
// testSSHPortForwarding tests port forwarding compatibility.
func testSSHPortForwarding(t *testing.T, host, port, keyFile string) {
testServer, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer testServer.Close()
testServerAddr := testServer.Addr().String()
expectedResponse := "HTTP/1.1 200 OK\r\nContent-Length: 21\r\n\r\nCompatibility Test OK"
go func() {
for {
conn, err := testServer.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer func() {
if err := c.Close(); err != nil {
t.Logf("test server connection close error: %v", err)
}
}()
buf := make([]byte, 1024)
if _, err := c.Read(buf); err != nil {
t.Logf("Test server read error: %v", err)
}
if _, err := c.Write([]byte(expectedResponse)); err != nil {
t.Logf("Test server write error: %v", err)
}
}(conn)
}
}()
localListener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
localAddr := localListener.Addr().String()
localListener.Close()
_, localPort, err := net.SplitHostPort(localAddr)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
forwardSpec := fmt.Sprintf("%s:%s", localPort, testServerAddr)
cmd := exec.CommandContext(ctx, "ssh",
"-i", keyFile,
"-p", port,
"-L", forwardSpec,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-N",
fmt.Sprintf("test-user@%s", host))
err = cmd.Start()
if err != nil {
t.Logf("Cannot start SSH port forwarding: %v", err)
return
}
defer func() {
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
t.Logf("process kill error: %v", err)
}
}
if err := cmd.Wait(); err != nil {
t.Logf("process wait after kill: %v", err)
}
}()
time.Sleep(500 * time.Millisecond)
conn, err := net.DialTimeout("tcp", localAddr, 3*time.Second)
if err != nil {
t.Logf("Cannot connect to forwarded port: %v", err)
return
}
defer func() {
if err := conn.Close(); err != nil {
t.Logf("forwarded connection close error: %v", err)
}
}()
request := "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
_, err = conn.Write([]byte(request))
require.NoError(t, err)
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
response := make([]byte, len(expectedResponse))
n, err := io.ReadFull(conn, response)
if err != nil {
t.Logf("Cannot read forwarded response: %v", err)
return
}
assert.Equal(t, len(expectedResponse), n, "Should read expected number of bytes")
assert.Equal(t, expectedResponse, string(response), "Should get correct HTTP response through SSH port forwarding")
}
// isSSHClientAvailable checks if the ssh binary is available
func isSSHClientAvailable() bool {
_, err := exec.LookPath("ssh")
return err == nil
}
// generateOpenSSHKey generates an ED25519 key in OpenSSH format that the system SSH client can use.
func generateOpenSSHKey() ([]byte, []byte, error) {
// Check if ssh-keygen is available
if _, err := exec.LookPath("ssh-keygen"); err != nil {
// Fall back to our existing key generation and try to convert
return generateOpenSSHKeyFallback()
}
// Create temporary file for ssh-keygen
tempFile, err := os.CreateTemp("", "ssh_keygen_*")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
keyPath := tempFile.Name()
tempFile.Close()
// Remove the temp file so ssh-keygen can create it
if err := os.Remove(keyPath); err != nil {
// Ignore if file doesn't exist, we just need it gone
}
// Clean up temp files
defer func() {
if err := os.Remove(keyPath); err != nil {
// Ignore cleanup errors but could log them in debug mode
}
if err := os.Remove(keyPath + ".pub"); err != nil {
// Ignore cleanup errors but could log them in debug mode
}
}()
// Generate key using ssh-keygen
cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q")
output, err := cmd.CombinedOutput()
if err != nil {
return nil, nil, fmt.Errorf("ssh-keygen failed: %w, output: %s", err, string(output))
}
// Read private key
privKeyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, fmt.Errorf("read private key: %w", err)
}
// Read public key
pubKeyBytes, err := os.ReadFile(keyPath + ".pub")
if err != nil {
return nil, nil, fmt.Errorf("read public key: %w", err)
}
return privKeyBytes, pubKeyBytes, nil
}
// generateOpenSSHKeyFallback falls back to generating keys using our existing method
func generateOpenSSHKeyFallback() ([]byte, []byte, error) {
// Generate shared.ED25519 key pair using our existing method
_, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
// Convert to SSH format
sshPrivKey, err := ssh.NewSignerFromKey(privKey)
if err != nil {
return nil, nil, fmt.Errorf("create signer: %w", err)
}
// For the fallback, just use our PKCS#8 format and hope it works
// This won't be in OpenSSH format but might still work with some SSH clients
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
if err != nil {
return nil, nil, fmt.Errorf("generate fallback key: %w", err)
}
// Get public key in SSH format
sshPubKey := ssh.MarshalAuthorizedKey(sshPrivKey.PublicKey())
return hostKey, sshPubKey, nil
}
// createTempKeyFileFromBytes creates a temporary SSH private key file from raw bytes
func createTempKeyFileFromBytes(t *testing.T, keyBytes []byte) (string, func()) {
t.Helper()
tempFile, err := os.CreateTemp("", "ssh_test_key_*")
require.NoError(t, err)
_, err = tempFile.Write(keyBytes)
require.NoError(t, err)
err = tempFile.Close()
require.NoError(t, err)
// Set proper permissions for SSH key (readable by owner only)
err = os.Chmod(tempFile.Name(), 0600)
require.NoError(t, err)
cleanup := func() {
_ = os.Remove(tempFile.Name())
}
return tempFile.Name(), cleanup
}
// createTempKeyFile creates a temporary SSH private key file (for backward compatibility)
func createTempKeyFile(t *testing.T, privateKey []byte) (string, func()) {
return createTempKeyFileFromBytes(t, privateKey)
}
// TestSSHServerFeatureCompatibility tests specific SSH features for compatibility
func TestSSHServerFeatureCompatibility(t *testing.T) {
if testing.Short() {
t.Skip("Skipping SSH feature compatibility tests in short mode")
}
if !isSSHClientAvailable() {
t.Skip("SSH client not available on this system")
}
// Test various SSH features
testCases := []struct {
name string
testFunc func(t *testing.T, host, port, keyFile string)
description string
}{
{
name: "command_with_flags",
testFunc: testCommandWithFlags,
description: "Commands with flags should work like standard SSH",
},
{
name: "environment_variables",
testFunc: testEnvironmentVariables,
description: "Environment variables should be available",
},
{
name: "exit_codes",
testFunc: testExitCodes,
description: "Exit codes should be properly handled",
},
}
// Set up SSH server
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPubKey, err := nbssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey)
defer cleanupKey()
host, portStr, err := net.SplitHostPort(serverAddr)
require.NoError(t, err)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.testFunc(t, host, portStr, clientKeyFile)
})
}
}
// testCommandWithFlags tests that commands with flags work properly
func testCommandWithFlags(t *testing.T, host, port, keyFile string) {
// Test ls with flags
cmd := exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
"ls", "-la", "/tmp")
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Command with flags failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
// Should not be empty and should not contain error messages
assert.NotEmpty(t, string(output), "ls -la should produce output")
assert.NotContains(t, strings.ToLower(string(output)), "command not found", "Command should be executed")
}
// testEnvironmentVariables tests that environment is properly set up
func testEnvironmentVariables(t *testing.T, host, port, keyFile string) {
cmd := exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
"echo", "$HOME")
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Environment test failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
// HOME environment variable should be available
homeOutput := strings.TrimSpace(string(output))
assert.NotEmpty(t, homeOutput, "HOME environment variable should be set")
assert.NotEqual(t, "$HOME", homeOutput, "Environment variable should be expanded")
}
// testExitCodes tests that exit codes are properly handled
func testExitCodes(t *testing.T, host, port, keyFile string) {
// Test successful command (exit code 0)
cmd := exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
"true") // always succeeds
err := cmd.Run()
assert.NoError(t, err, "Command with exit code 0 should succeed")
// Test failing command (exit code 1)
cmd = exec.Command("ssh",
"-i", keyFile,
"-p", port,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
"false") // always fails
err = cmd.Run()
assert.Error(t, err, "Command with exit code 1 should fail")
// Check if it's the right kind of error
if exitError, ok := err.(*exec.ExitError); ok {
assert.Equal(t, 1, exitError.ExitCode(), "Exit code should be preserved")
}
}
// TestSSHServerSecurityFeatures tests security-related SSH features
func TestSSHServerSecurityFeatures(t *testing.T) {
if testing.Short() {
t.Skip("Skipping SSH security tests in short mode")
}
if !isSSHClientAvailable() {
t.Skip("SSH client not available on this system")
}
// Set up SSH server with specific security settings
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPubKey, err := nbssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey)
defer cleanupKey()
host, portStr, err := net.SplitHostPort(serverAddr)
require.NoError(t, err)
t.Run("key_authentication", func(t *testing.T) {
// Test that key authentication works
cmd := exec.Command("ssh",
"-i", clientKeyFile,
"-p", portStr,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-o", "PasswordAuthentication=no",
fmt.Sprintf("test-user@%s", host),
"echo", "auth_success")
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Key authentication failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
assert.Contains(t, string(output), "auth_success", "Key authentication should work")
})
t.Run("any_key_accepted_in_no_auth_mode", func(t *testing.T) {
// Create a different key that shouldn't be accepted
wrongKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
wrongKeyFile, cleanupWrongKey := createTempKeyFile(t, wrongKey)
defer cleanupWrongKey()
// Test that wrong key is rejected
cmd := exec.Command("ssh",
"-i", wrongKeyFile,
"-p", portStr,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-o", "PasswordAuthentication=no",
fmt.Sprintf("test-user@%s", host),
"echo", "should_not_work")
err = cmd.Run()
assert.NoError(t, err, "Any key should work in no-auth mode")
})
}
// TestCrossPlatformCompatibility tests cross-platform behavior
func TestCrossPlatformCompatibility(t *testing.T) {
if testing.Short() {
t.Skip("Skipping cross-platform compatibility tests in short mode")
}
if !isSSHClientAvailable() {
t.Skip("SSH client not available on this system")
}
// Set up SSH server
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPubKey, err := nbssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey)
defer cleanupKey()
host, portStr, err := net.SplitHostPort(serverAddr)
require.NoError(t, err)
// Test platform-specific commands
var testCommand string
switch runtime.GOOS {
case "windows":
testCommand = "echo %OS%"
default:
testCommand = "uname"
}
cmd := exec.Command("ssh",
"-i", clientKeyFile,
"-p", portStr,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
fmt.Sprintf("test-user@%s", host),
testCommand)
output, err := cmd.CombinedOutput()
if err != nil {
t.Logf("Platform-specific command failed: %v", err)
t.Logf("Output: %s", string(output))
return
}
outputStr := strings.TrimSpace(string(output))
t.Logf("Platform command output: %s", outputStr)
assert.NotEmpty(t, outputStr, "Platform-specific command should produce output")
}

View File

@@ -0,0 +1,226 @@
//go:build unix
package server
import (
"context"
"os"
"os/exec"
"os/user"
"runtime"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
pd := NewPrivilegeDropper()
tests := []struct {
name string
uid uint32
gid uint32
wantErr bool
}{
{
name: "valid non-root user",
uid: 1000,
gid: 1000,
wantErr: false,
},
{
name: "root UID should be rejected",
uid: 0,
gid: 1000,
wantErr: true,
},
{
name: "root GID should be rejected",
uid: 1000,
gid: 0,
wantErr: true,
},
{
name: "both root should be rejected",
uid: 0,
gid: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := pd.validatePrivileges(tt.uid, tt.gid)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
pd := NewPrivilegeDropper()
config := ExecutorConfig{
UID: 1000,
GID: 1000,
Groups: []uint32{1000, 1001},
WorkingDir: "/home/testuser",
Shell: "/bin/bash",
Command: "ls -la",
}
cmd, err := pd.CreateExecutorCommand(context.Background(), config)
require.NoError(t, err)
require.NotNil(t, cmd)
// Verify the command is calling netbird ssh exec
assert.Contains(t, cmd.Args, "ssh")
assert.Contains(t, cmd.Args, "exec")
assert.Contains(t, cmd.Args, "--uid")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, "--gid")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, "--groups")
assert.Contains(t, cmd.Args, "1000")
assert.Contains(t, cmd.Args, "1001")
assert.Contains(t, cmd.Args, "--working-dir")
assert.Contains(t, cmd.Args, "/home/testuser")
assert.Contains(t, cmd.Args, "--shell")
assert.Contains(t, cmd.Args, "/bin/bash")
assert.Contains(t, cmd.Args, "--cmd")
assert.Contains(t, cmd.Args, "ls -la")
}
func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
pd := NewPrivilegeDropper()
config := ExecutorConfig{
UID: 1000,
GID: 1000,
Groups: []uint32{1000},
WorkingDir: "/home/testuser",
Shell: "/bin/bash",
Command: "",
}
cmd, err := pd.CreateExecutorCommand(context.Background(), config)
require.NoError(t, err)
require.NotNil(t, cmd)
// Verify no command mode (command is empty so no --cmd flag)
assert.NotContains(t, cmd.Args, "--cmd")
assert.NotContains(t, cmd.Args, "--interactive")
}
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Privilege dropping not supported on Windows")
}
if os.Geteuid() != 0 {
t.Skip("This test requires root privileges")
}
// Find a non-root user to test with
testUser, err := user.Lookup("nobody")
if err != nil {
// Try to find any non-root user
testUser, err = findNonRootUser()
if err != nil {
t.Skip("No suitable non-root user found for testing")
}
}
uid64, err := strconv.ParseUint(testUser.Uid, 10, 32)
require.NoError(t, err)
targetUID := uint32(uid64)
gid64, err := strconv.ParseUint(testUser.Gid, 10, 32)
require.NoError(t, err)
targetGID := uint32(gid64)
// Test in a child process to avoid affecting the test runner
if os.Getenv("TEST_PRIVILEGE_DROP") == "1" {
pd := NewPrivilegeDropper()
// This should succeed
err := pd.DropPrivileges(targetUID, targetGID, []uint32{targetGID})
require.NoError(t, err)
// Verify we are now running as the target user
currentUID := uint32(os.Geteuid())
currentGID := uint32(os.Getegid())
assert.Equal(t, targetUID, currentUID, "UID should match target")
assert.Equal(t, targetGID, currentGID, "GID should match target")
assert.NotEqual(t, uint32(0), currentUID, "Should not be running as root")
assert.NotEqual(t, uint32(0), currentGID, "Should not be running as root group")
return
}
// Fork a child process to test privilege dropping
cmd := os.Args[0]
args := []string{"-test.run=TestPrivilegeDropper_ActualPrivilegeDrop"}
env := append(os.Environ(), "TEST_PRIVILEGE_DROP=1")
execCmd := exec.Command(cmd, args...)
execCmd.Env = env
err = execCmd.Run()
require.NoError(t, err, "Child process should succeed")
}
// findNonRootUser finds any non-root user on the system for testing
func findNonRootUser() (*user.User, error) {
// Try common non-root users
commonUsers := []string{"nobody", "daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp", "proxy", "www-data", "backup", "list", "irc"}
for _, username := range commonUsers {
if u, err := user.Lookup(username); err == nil {
uid64, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
continue
}
if uid64 != 0 { // Not root
return u, nil
}
}
}
// If no common users found, create a minimal user info for testing
// This won't actually work for privilege dropping but allows the test structure
return &user.User{
Uid: "65534", // Standard nobody UID
Gid: "65534", // Standard nobody GID
Username: "nobody",
Name: "nobody",
HomeDir: "/nonexistent",
}, nil
}
func TestPrivilegeDropper_ExecuteWithPrivilegeDrop_Validation(t *testing.T) {
pd := NewPrivilegeDropper()
// Test validation of root privileges - this should be caught in CreateExecutorCommand
config := ExecutorConfig{
UID: 0, // Root UID should be rejected
GID: 1000,
Groups: []uint32{1000},
WorkingDir: "/tmp",
Shell: "/bin/sh",
Command: "echo test",
}
_, err := pd.CreateExecutorCommand(context.Background(), config)
assert.Error(t, err)
assert.Contains(t, err.Error(), "root user")
}

View File

@@ -0,0 +1,252 @@
//go:build unix
package server
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
)
// Exit codes for executor process communication
const (
ExitCodeSuccess = 0
ExitCodePrivilegeDropFail = 10
ExitCodeShellExecFail = 11
ExitCodeValidationFail = 12
)
// ExecutorConfig holds configuration for the executor process
type ExecutorConfig struct {
UID uint32
GID uint32
Groups []uint32
WorkingDir string
Shell string
Command string
PTY bool
}
// PrivilegeDropper handles secure privilege dropping in child processes
type PrivilegeDropper struct{}
// NewPrivilegeDropper creates a new privilege dropper
func NewPrivilegeDropper() *PrivilegeDropper {
return &PrivilegeDropper{}
}
// CreateExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping
func (pd *PrivilegeDropper) CreateExecutorCommand(ctx context.Context, config ExecutorConfig) (*exec.Cmd, error) {
netbirdPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("get netbird executable path: %w", err)
}
if err := pd.validatePrivileges(config.UID, config.GID); err != nil {
return nil, fmt.Errorf("invalid privileges: %w", err)
}
args := []string{
"ssh", "exec",
"--uid", fmt.Sprintf("%d", config.UID),
"--gid", fmt.Sprintf("%d", config.GID),
"--working-dir", config.WorkingDir,
"--shell", config.Shell,
}
for _, group := range config.Groups {
args = append(args, "--groups", fmt.Sprintf("%d", group))
}
if config.PTY {
args = append(args, "--pty")
}
if config.Command != "" {
args = append(args, "--cmd", config.Command)
}
// Log executor args safely - show all args except hide the command value
safeArgs := make([]string, len(args))
copy(safeArgs, args)
for i := 0; i < len(safeArgs)-1; i++ {
if safeArgs[i] == "--cmd" {
cmdParts := strings.Fields(safeArgs[i+1])
safeArgs[i+1] = safeLogCommand(cmdParts)
break
}
}
log.Tracef("creating executor command: %s %v", netbirdPath, safeArgs)
return exec.CommandContext(ctx, netbirdPath, args...), nil
}
// DropPrivileges performs privilege dropping with thread locking for security
func (pd *PrivilegeDropper) DropPrivileges(targetUID, targetGID uint32, supplementaryGroups []uint32) error {
if err := pd.validatePrivileges(targetUID, targetGID); err != nil {
return fmt.Errorf("invalid privileges: %w", err)
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
originalUID := os.Geteuid()
originalGID := os.Getegid()
if err := pd.setGroupsAndIDs(targetUID, targetGID, supplementaryGroups); err != nil {
return err
}
if err := pd.validatePrivilegeDropSuccess(targetUID, targetGID, originalUID, originalGID); err != nil {
return err
}
log.Tracef("successfully dropped privileges to UID=%d, GID=%d", targetUID, targetGID)
return nil
}
// setGroupsAndIDs sets the supplementary groups, GID, and UID
func (pd *PrivilegeDropper) setGroupsAndIDs(targetUID, targetGID uint32, supplementaryGroups []uint32) error {
groups := make([]int, len(supplementaryGroups))
for i, g := range supplementaryGroups {
groups[i] = int(g)
}
if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" {
if len(groups) == 0 || groups[0] != int(targetGID) {
groups = append([]int{int(targetGID)}, groups...)
}
}
if err := syscall.Setgroups(groups); err != nil {
return fmt.Errorf("setgroups to %v: %w", groups, err)
}
if err := syscall.Setgid(int(targetGID)); err != nil {
return fmt.Errorf("setgid to %d: %w", targetGID, err)
}
if err := syscall.Setuid(int(targetUID)); err != nil {
return fmt.Errorf("setuid to %d: %w", targetUID, err)
}
return nil
}
// validatePrivilegeDropSuccess validates that privilege dropping was successful
func (pd *PrivilegeDropper) validatePrivilegeDropSuccess(targetUID, targetGID uint32, originalUID, originalGID int) error {
if err := pd.validatePrivilegeDropReversibility(targetUID, targetGID, originalUID, originalGID); err != nil {
return err
}
if err := pd.validateCurrentPrivileges(targetUID, targetGID); err != nil {
return err
}
return nil
}
// validatePrivilegeDropReversibility ensures privileges cannot be restored
func (pd *PrivilegeDropper) validatePrivilegeDropReversibility(targetUID, targetGID uint32, originalUID, originalGID int) error {
if originalGID != int(targetGID) {
if err := syscall.Setegid(originalGID); err == nil {
return fmt.Errorf("privilege drop validation failed: able to restore original GID %d", originalGID)
}
}
if originalUID != int(targetUID) {
if err := syscall.Seteuid(originalUID); err == nil {
return fmt.Errorf("privilege drop validation failed: able to restore original UID %d", originalUID)
}
}
return nil
}
// validateCurrentPrivileges validates the current UID and GID match the target
func (pd *PrivilegeDropper) validateCurrentPrivileges(targetUID, targetGID uint32) error {
currentUID := os.Geteuid()
if currentUID != int(targetUID) {
return fmt.Errorf("privilege drop validation failed: current UID %d, expected %d", currentUID, targetUID)
}
currentGID := os.Getegid()
if currentGID != int(targetGID) {
return fmt.Errorf("privilege drop validation failed: current GID %d, expected %d", currentGID, targetGID)
}
return nil
}
// ExecuteWithPrivilegeDrop executes a command with privilege dropping, using exit codes to signal specific failures
func (pd *PrivilegeDropper) ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) {
log.Tracef("dropping privileges to UID=%d, GID=%d, groups=%v", config.UID, config.GID, config.Groups)
// TODO: Implement Pty support for executor path
if config.PTY {
log.Warnf("Pty requested but executor does not support Pty yet - continuing without Pty")
config.PTY = false // Disable Pty and continue
}
if err := pd.DropPrivileges(config.UID, config.GID, config.Groups); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "privilege drop failed: %v\n", err)
os.Exit(ExitCodePrivilegeDropFail)
}
if config.WorkingDir != "" {
if err := os.Chdir(config.WorkingDir); err != nil {
log.Debugf("failed to change to working directory %s, continuing with current directory: %v", config.WorkingDir, err)
}
}
var execCmd *exec.Cmd
if config.Command == "" {
os.Exit(ExitCodeSuccess)
}
execCmd = exec.CommandContext(ctx, config.Shell, "-c", config.Command)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
cmdParts := strings.Fields(config.Command)
safeCmd := safeLogCommand(cmdParts)
log.Tracef("executing %s -c %s", execCmd.Path, safeCmd)
if err := execCmd.Run(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
// Normal command exit with non-zero code - not an SSH execution error
log.Tracef("command exited with code %d", exitError.ExitCode())
os.Exit(exitError.ExitCode())
}
// Actual execution failure (command not found, permission denied, etc.)
log.Debugf("command execution failed: %v", err)
os.Exit(ExitCodeShellExecFail)
}
os.Exit(ExitCodeSuccess)
}
// validatePrivileges validates that privilege dropping to the target UID/GID is allowed
func (pd *PrivilegeDropper) validatePrivileges(uid, gid uint32) error {
currentUID := uint32(os.Geteuid())
currentGID := uint32(os.Getegid())
// Allow same-user operations (no privilege dropping needed)
if uid == currentUID && gid == currentGID {
return nil
}
// Only root can drop privileges to other users
if currentUID != 0 {
return fmt.Errorf("cannot drop privileges from non-root user (UID %d) to UID %d", currentUID, uid)
}
// Root can drop to any user (including root itself)
return nil
}

View File

@@ -0,0 +1,594 @@
//go:build windows
package server
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"strings"
"syscall"
"unsafe"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
ExitCodeSuccess = 0
ExitCodeLogonFail = 10
ExitCodeCreateProcessFail = 11
ExitCodeWorkingDirFail = 12
ExitCodeShellExecFail = 13
ExitCodeValidationFail = 14
)
type WindowsExecutorConfig struct {
Username string
Domain string
WorkingDir string
Shell string
Command string
Args []string
Interactive bool
Pty bool
PtyWidth int
PtyHeight int
}
type PrivilegeDropper struct{}
func NewPrivilegeDropper() *PrivilegeDropper {
return &PrivilegeDropper{}
}
var (
advapi32 = windows.NewLazyDLL("advapi32.dll")
procAllocateLocallyUniqueId = advapi32.NewProc("AllocateLocallyUniqueId")
)
const (
logon32LogonNetwork = 3 // Network logon - no password required for authenticated users
// Common error messages
commandFlag = "-Command"
closeTokenError = "close token error: %v"
convertUsernameError = "convert username to UTF16: %w"
convertDomainError = "convert domain to UTF16: %w"
)
func (pd *PrivilegeDropper) CreateWindowsExecutorCommand(ctx context.Context, config WindowsExecutorConfig) (*exec.Cmd, error) {
if config.Username == "" {
return nil, errors.New("username cannot be empty")
}
if config.Shell == "" {
return nil, errors.New("shell cannot be empty")
}
shell := config.Shell
var shellArgs []string
if config.Command != "" {
shellArgs = []string{shell, commandFlag, config.Command}
} else {
shellArgs = []string{shell}
}
log.Tracef("creating Windows direct shell command: %s %v", shellArgs[0], shellArgs)
cmd, err := pd.CreateWindowsProcessAsUserWithArgs(
ctx, shellArgs[0], shellArgs, config.Username, config.Domain, config.WorkingDir)
if err != nil {
return nil, fmt.Errorf("create Windows process as user: %w", err)
}
return cmd, nil
}
const (
// StatusSuccess represents successful LSA operation
StatusSuccess = 0
// KerbS4ULogonType message type for domain users with Kerberos
KerbS4ULogonType = 12
// Msv10s4ulogontype message type for local users with MSV1_0
Msv10s4ulogontype = 12
// MicrosoftKerberosNameA is the authentication package name for Kerberos
MicrosoftKerberosNameA = "Kerberos"
// Msv10packagename is the authentication package name for MSV1_0
Msv10packagename = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
)
// kerbS4ULogon structure for S4U authentication (domain users)
type kerbS4ULogon struct {
MessageType uint32
Flags uint32
ClientUpn unicodeString
ClientRealm unicodeString
}
// msv10s4ulogon structure for S4U authentication (local users)
type msv10s4ulogon struct {
MessageType uint32
Flags uint32
UserPrincipalName unicodeString
DomainName unicodeString
}
// unicodeString structure
type unicodeString struct {
Length uint16
MaximumLength uint16
Buffer *uint16
}
// lsaString structure
type lsaString struct {
Length uint16
MaximumLength uint16
Buffer *byte
}
// tokenSource structure
type tokenSource struct {
SourceName [8]byte
SourceIdentifier windows.LUID
}
// quotaLimits structure
type quotaLimits struct {
PagedPoolLimit uint32
NonPagedPoolLimit uint32
MinimumWorkingSetSize uint32
MaximumWorkingSetSize uint32
PagefileLimit uint32
TimeLimit int64
}
var (
secur32 = windows.NewLazyDLL("secur32.dll")
procLsaRegisterLogonProcess = secur32.NewProc("LsaRegisterLogonProcess")
procLsaLookupAuthenticationPackage = secur32.NewProc("LsaLookupAuthenticationPackage")
procLsaLogonUser = secur32.NewProc("LsaLogonUser")
procLsaFreeReturnBuffer = secur32.NewProc("LsaFreeReturnBuffer")
procLsaDeregisterLogonProcess = secur32.NewProc("LsaDeregisterLogonProcess")
)
// newLsaString creates an LsaString from a Go string
func newLsaString(s string) lsaString {
b := append([]byte(s), 0)
return lsaString{
Length: uint16(len(s)),
MaximumLength: uint16(len(b)),
Buffer: &b[0],
}
}
// generateS4UUserToken creates a Windows token using S4U authentication
// This is the exact approach OpenSSH for Windows uses for public key authentication
func generateS4UUserToken(username, domain string) (windows.Handle, error) {
userCpn := buildUserCpn(username, domain)
// Use proper domain detection logic instead of simple string check
pd := NewPrivilegeDropper()
isDomainUser := !pd.isLocalUser(domain)
lsaHandle, err := initializeLsaConnection()
if err != nil {
return 0, err
}
defer cleanupLsaConnection(lsaHandle)
authPackageId, err := lookupAuthenticationPackage(lsaHandle, isDomainUser)
if err != nil {
return 0, err
}
logonInfo, logonInfoSize, err := prepareS4ULogonStructure(username, userCpn, isDomainUser)
if err != nil {
return 0, err
}
return performS4ULogon(lsaHandle, authPackageId, logonInfo, logonInfoSize, userCpn, isDomainUser)
}
// buildUserCpn constructs the user principal name
func buildUserCpn(username, domain string) string {
if domain != "" && domain != "." {
return fmt.Sprintf(`%s\%s`, domain, username)
}
return username
}
// initializeLsaConnection establishes connection to LSA
func initializeLsaConnection() (windows.Handle, error) {
processName := newLsaString("NetBird")
var mode uint32
var lsaHandle windows.Handle
ret, _, _ := procLsaRegisterLogonProcess.Call(
uintptr(unsafe.Pointer(&processName)),
uintptr(unsafe.Pointer(&lsaHandle)),
uintptr(unsafe.Pointer(&mode)),
)
if ret != StatusSuccess {
return 0, fmt.Errorf("LsaRegisterLogonProcess: 0x%x", ret)
}
return lsaHandle, nil
}
// cleanupLsaConnection closes the LSA connection
func cleanupLsaConnection(lsaHandle windows.Handle) {
if ret, _, _ := procLsaDeregisterLogonProcess.Call(uintptr(lsaHandle)); ret != StatusSuccess {
log.Debugf("LsaDeregisterLogonProcess failed: 0x%x", ret)
}
}
// lookupAuthenticationPackage finds the correct authentication package
func lookupAuthenticationPackage(lsaHandle windows.Handle, isDomainUser bool) (uint32, error) {
var authPackageName lsaString
if isDomainUser {
authPackageName = newLsaString(MicrosoftKerberosNameA)
} else {
authPackageName = newLsaString(Msv10packagename)
}
var authPackageId uint32
ret, _, _ := procLsaLookupAuthenticationPackage.Call(
uintptr(lsaHandle),
uintptr(unsafe.Pointer(&authPackageName)),
uintptr(unsafe.Pointer(&authPackageId)),
)
if ret != StatusSuccess {
return 0, fmt.Errorf("LsaLookupAuthenticationPackage: 0x%x", ret)
}
return authPackageId, nil
}
// prepareS4ULogonStructure creates the appropriate S4U logon structure
func prepareS4ULogonStructure(username, userCpn string, isDomainUser bool) (unsafe.Pointer, uintptr, error) {
if isDomainUser {
return prepareDomainS4ULogon(userCpn)
}
return prepareLocalS4ULogon(username)
}
// prepareDomainS4ULogon creates S4U logon structure for domain users
func prepareDomainS4ULogon(userCpn string) (unsafe.Pointer, uintptr, error) {
log.Debugf("using KerbS4ULogon for domain user: %s", userCpn)
userCpnUtf16, err := windows.UTF16FromString(userCpn)
if err != nil {
return nil, 0, fmt.Errorf(convertUsernameError, err)
}
structSize := unsafe.Sizeof(kerbS4ULogon{})
usernameByteSize := len(userCpnUtf16) * 2
logonInfoSize := structSize + uintptr(usernameByteSize)
buffer := make([]byte, logonInfoSize)
logonInfo := unsafe.Pointer(&buffer[0])
s4uLogon := (*kerbS4ULogon)(logonInfo)
s4uLogon.MessageType = KerbS4ULogonType
s4uLogon.Flags = 0
usernameOffset := structSize
usernameBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + usernameOffset))
copy((*[512]uint16)(unsafe.Pointer(usernameBuffer))[:len(userCpnUtf16)], userCpnUtf16)
s4uLogon.ClientUpn = unicodeString{
Length: uint16((len(userCpnUtf16) - 1) * 2),
MaximumLength: uint16(len(userCpnUtf16) * 2),
Buffer: usernameBuffer,
}
s4uLogon.ClientRealm = unicodeString{}
return logonInfo, logonInfoSize, nil
}
// prepareLocalS4ULogon creates S4U logon structure for local users
func prepareLocalS4ULogon(username string) (unsafe.Pointer, uintptr, error) {
log.Debugf("using Msv1_0S4ULogon for local user: %s", username)
usernameUtf16, err := windows.UTF16FromString(username)
if err != nil {
return nil, 0, fmt.Errorf(convertUsernameError, err)
}
domainUtf16, err := windows.UTF16FromString(".")
if err != nil {
return nil, 0, fmt.Errorf(convertDomainError, err)
}
structSize := unsafe.Sizeof(msv10s4ulogon{})
usernameByteSize := len(usernameUtf16) * 2
domainByteSize := len(domainUtf16) * 2
logonInfoSize := structSize + uintptr(usernameByteSize) + uintptr(domainByteSize)
buffer := make([]byte, logonInfoSize)
logonInfo := unsafe.Pointer(&buffer[0])
s4uLogon := (*msv10s4ulogon)(logonInfo)
s4uLogon.MessageType = Msv10s4ulogontype
s4uLogon.Flags = 0x0
usernameOffset := structSize
usernameBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + usernameOffset))
copy((*[256]uint16)(unsafe.Pointer(usernameBuffer))[:len(usernameUtf16)], usernameUtf16)
s4uLogon.UserPrincipalName = unicodeString{
Length: uint16((len(usernameUtf16) - 1) * 2),
MaximumLength: uint16(len(usernameUtf16) * 2),
Buffer: usernameBuffer,
}
domainOffset := usernameOffset + uintptr(usernameByteSize)
domainBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + domainOffset))
copy((*[16]uint16)(unsafe.Pointer(domainBuffer))[:len(domainUtf16)], domainUtf16)
s4uLogon.DomainName = unicodeString{
Length: uint16((len(domainUtf16) - 1) * 2),
MaximumLength: uint16(len(domainUtf16) * 2),
Buffer: domainBuffer,
}
return logonInfo, logonInfoSize, nil
}
// performS4ULogon executes the S4U logon operation
func performS4ULogon(lsaHandle windows.Handle, authPackageId uint32, logonInfo unsafe.Pointer, logonInfoSize uintptr, userCpn string, isDomainUser bool) (windows.Handle, error) {
var tokenSource tokenSource
copy(tokenSource.SourceName[:], "netbird")
if ret, _, _ := procAllocateLocallyUniqueId.Call(uintptr(unsafe.Pointer(&tokenSource.SourceIdentifier))); ret == 0 {
log.Debugf("AllocateLocallyUniqueId failed")
}
originName := newLsaString("netbird")
var profile uintptr
var profileSize uint32
var logonId windows.LUID
var token windows.Handle
var quotas quotaLimits
var subStatus int32
ret, _, _ := procLsaLogonUser.Call(
uintptr(lsaHandle),
uintptr(unsafe.Pointer(&originName)),
logon32LogonNetwork,
uintptr(authPackageId),
uintptr(logonInfo),
logonInfoSize,
0,
uintptr(unsafe.Pointer(&tokenSource)),
uintptr(unsafe.Pointer(&profile)),
uintptr(unsafe.Pointer(&profileSize)),
uintptr(unsafe.Pointer(&logonId)),
uintptr(unsafe.Pointer(&token)),
uintptr(unsafe.Pointer(&quotas)),
uintptr(unsafe.Pointer(&subStatus)),
)
if profile != 0 {
if ret, _, _ := procLsaFreeReturnBuffer.Call(profile); ret != StatusSuccess {
log.Debugf("LsaFreeReturnBuffer failed: 0x%x", ret)
}
}
if ret != StatusSuccess {
return 0, fmt.Errorf("LsaLogonUser S4U for %s: NTSTATUS=0x%x, SubStatus=0x%x", userCpn, ret, subStatus)
}
log.Debugf("created S4U %s token for user %s",
map[bool]string{true: "domain", false: "local"}[isDomainUser], userCpn)
return token, nil
}
// createToken implements NetBird trust-based authentication using S4U
func (pd *PrivilegeDropper) createToken(username, domain string) (windows.Handle, error) {
fullUsername := buildUserCpn(username, domain)
if err := userExists(fullUsername, username, domain); err != nil {
return 0, err
}
isLocalUser := pd.isLocalUser(domain)
if isLocalUser {
return pd.authenticateLocalUser(username, fullUsername)
}
return pd.authenticateDomainUser(username, domain, fullUsername)
}
// userExists checks if the target useVerifier exists on the system
func userExists(fullUsername, username, domain string) error {
if _, err := lookupUser(fullUsername); err != nil {
log.Debugf("User %s not found: %v", fullUsername, err)
if domain != "" && domain != "." {
_, err = lookupUser(username)
}
if err != nil {
return fmt.Errorf("target user %s not found: %w", fullUsername, err)
}
}
return nil
}
// isLocalUser determines if this is a local user vs domain user
func (pd *PrivilegeDropper) isLocalUser(domain string) bool {
hostname, err := os.Hostname()
if err != nil {
hostname = "localhost"
}
return domain == "" || domain == "." ||
strings.EqualFold(domain, "localhost") ||
strings.EqualFold(domain, hostname)
}
// authenticateLocalUser handles authentication for local users
func (pd *PrivilegeDropper) authenticateLocalUser(username, fullUsername string) (windows.Handle, error) {
log.Debugf("using S4U authentication for local user %s", fullUsername)
token, err := generateS4UUserToken(username, ".")
if err != nil {
return 0, fmt.Errorf("S4U authentication for local user %s: %w", fullUsername, err)
}
return token, nil
}
// authenticateDomainUser handles authentication for domain users
func (pd *PrivilegeDropper) authenticateDomainUser(username, domain, fullUsername string) (windows.Handle, error) {
log.Debugf("using S4U authentication for domain user %s", fullUsername)
token, err := generateS4UUserToken(username, domain)
if err != nil {
return 0, fmt.Errorf("S4U authentication for domain user %s: %w", fullUsername, err)
}
log.Debugf("Successfully created S4U token for domain user %s", fullUsername)
return token, nil
}
// closeUserToken safely closes a Windows user token handle
func (pd *PrivilegeDropper) closeUserToken(token windows.Handle) {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close handle error: %v", err)
}
}
// buildCommandArgs constructs command arguments based on configuration
func (pd *PrivilegeDropper) buildCommandArgs(config WindowsExecutorConfig) []string {
shell := config.Shell
// Use structured args if provided
if len(config.Args) > 0 {
args := []string{shell}
args = append(args, config.Args...)
return args
}
// Use command string if provided
if config.Command != "" {
return []string{shell, commandFlag, config.Command}
}
if config.Interactive {
return []string{shell, "-NoExit"}
}
return []string{shell}
}
// CreateWindowsProcessAsUserWithArgs creates a process as user with safe argument passing (for SFTP and executables)
func (pd *PrivilegeDropper) CreateWindowsProcessAsUserWithArgs(ctx context.Context, executablePath string, args []string, username, domain, workingDir string) (*exec.Cmd, error) {
fullUsername := buildUserCpn(username, domain)
token, err := pd.createToken(username, domain)
if err != nil {
log.Debugf("S4U authentication failed for user %s: %v", fullUsername, err)
return nil, fmt.Errorf("user authentication failed: %w", err)
}
log.Debugf("using S4U authentication for user %s", fullUsername)
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close impersonation token error: %v", err)
}
}()
return pd.createProcessWithToken(ctx, windows.Token(token), executablePath, args, workingDir)
}
// CreateWindowsShellAsUser creates a shell process as user (for SSH commands/sessions)
func (pd *PrivilegeDropper) CreateWindowsShellAsUser(ctx context.Context, shell, command string, username, domain, workingDir string) (*exec.Cmd, error) {
fullUsername := buildUserCpn(username, domain)
token, err := pd.createToken(username, domain)
if err != nil {
return nil, fmt.Errorf("user authentication failed: %w", err)
}
log.Debugf("using S4U authentication for user %s", fullUsername)
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf(closeTokenError, err)
}
}()
shellArgs := buildShellArgs(shell, command)
return pd.createProcessWithToken(ctx, windows.Token(token), shell, shellArgs, workingDir)
}
// createProcessWithToken creates process with the specified token and executable path
func (pd *PrivilegeDropper) createProcessWithToken(ctx context.Context, sourceToken windows.Token, executablePath string, args []string, workingDir string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, executablePath, args[1:]...)
cmd.Dir = workingDir
// Duplicate the token to create a primary token that can be used to start a new process
var primaryToken windows.Token
err := windows.DuplicateTokenEx(
sourceToken,
windows.TOKEN_ALL_ACCESS,
nil,
windows.SecurityIdentification,
windows.TokenPrimary,
&primaryToken,
)
if err != nil {
return nil, fmt.Errorf("duplicate token to primary token: %w", err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(primaryToken),
}
return cmd, nil
}
func (pd *PrivilegeDropper) validateCurrentUser(config WindowsExecutorConfig) error {
currentUser, err := lookupUser("")
if err != nil {
log.Errorf("failed to get current user for SSH exec security verification: %v", err)
return fmt.Errorf("get current user: %w", err)
}
log.Debugf("SSH exec process running as: %s (UID: %s, Name: %s)", currentUser.Username, currentUser.Uid, currentUser.Name)
if config.Username == "" {
return nil
}
requestedUsername := config.Username
if config.Domain != "" {
requestedUsername = fmt.Sprintf(`%s\%s`, config.Domain, config.Username)
}
if !isWindowsSameUser(requestedUsername, currentUser.Username) {
return fmt.Errorf("username mismatch: requested user %s but running as %s",
requestedUsername, currentUser.Username)
}
log.Debugf("SSH exec process verified running as correct user: %s (UID: %s)", currentUser.Username, currentUser.Uid)
return nil
}
func (pd *PrivilegeDropper) changeWorkingDirectory(workingDir string) error {
if workingDir == "" {
return nil
}
return os.Chdir(workingDir)
}
// parseUserCredentials extracts Windows user information
func (s *Server) parseUserCredentials(_ *user.User) (uint32, uint32, []uint32, error) {
return 0, 0, []uint32{0}, nil
}
// createSuCommand creates a command using su -l -c for privilege switching (Windows stub)
func (s *Server) createSuCommand(ssh.Session, *user.User) (*exec.Cmd, error) {
return nil, fmt.Errorf("su command not available on Windows")
}

View File

@@ -0,0 +1,411 @@
package server
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
cryptossh "golang.org/x/crypto/ssh"
)
// SessionKey uniquely identifies an SSH session
type SessionKey string
// ConnectionKey uniquely identifies a port forwarding connection within a session
type ConnectionKey string
// ForwardKey uniquely identifies a port forwarding listener
type ForwardKey string
// tcpipForwardMsg represents the structure for tcpip-forward SSH requests
type tcpipForwardMsg struct {
Host string
Port uint32
}
// SetAllowLocalPortForwarding configures local port forwarding
func (s *Server) SetAllowLocalPortForwarding(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowLocalPortForwarding = allow
}
// SetAllowRemotePortForwarding configures remote port forwarding
func (s *Server) SetAllowRemotePortForwarding(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRemotePortForwarding = allow
}
// configurePortForwarding sets up port forwarding callbacks
func (s *Server) configurePortForwarding(server *ssh.Server) {
allowLocal := s.allowLocalPortForwarding
allowRemote := s.allowRemotePortForwarding
server.LocalPortForwardingCallback = func(ctx ssh.Context, dstHost string, dstPort uint32) bool {
if !allowLocal {
log.Debugf("local port forwarding denied: %s:%d (disabled by configuration)", dstHost, dstPort)
return false
}
if err := s.checkPortForwardingPrivileges(ctx, "local", dstPort); err != nil {
log.Infof("local port forwarding denied: %v", err)
return false
}
log.Debugf("local port forwarding allowed: %s:%d", dstHost, dstPort)
return true
}
server.ReversePortForwardingCallback = func(ctx ssh.Context, bindHost string, bindPort uint32) bool {
if !allowRemote {
log.Debugf("remote port forwarding denied: %s:%d (disabled by configuration)", bindHost, bindPort)
return false
}
if err := s.checkPortForwardingPrivileges(ctx, "remote", bindPort); err != nil {
log.Infof("remote port forwarding denied: %v", err)
return false
}
log.Debugf("remote port forwarding allowed: %s:%d", bindHost, bindPort)
return true
}
log.Debugf("SSH server configured with local_forwarding=%v, remote_forwarding=%v", allowLocal, allowRemote)
}
// checkPortForwardingPrivileges validates privilege requirements for port forwarding operations.
// Returns nil if allowed, error if denied.
func (s *Server) checkPortForwardingPrivileges(ctx ssh.Context, forwardType string, port uint32) error {
if ctx == nil {
return fmt.Errorf("%s port forwarding denied: no context", forwardType)
}
username := ctx.User()
remoteAddr := "unknown"
if ctx.RemoteAddr() != nil {
remoteAddr = ctx.RemoteAddr().String()
}
logger := log.WithFields(log.Fields{"user": username, "remote": remoteAddr, "port": port})
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: false,
FeatureName: forwardType + " port forwarding",
})
if !result.Allowed {
return result.Error
}
logger.Debugf("%s port forwarding allowed: user %s validated (port %d)",
forwardType, result.User.Username, port)
return nil
}
// tcpipForwardHandler handles tcpip-forward requests for remote port forwarding.
func (s *Server) tcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) {
logger := s.getRequestLogger(ctx)
if !s.isRemotePortForwardingAllowed() {
logger.Debugf("tcpip-forward request denied: remote port forwarding disabled")
return false, nil
}
payload, err := s.parseTcpipForwardRequest(req)
if err != nil {
logger.Errorf("tcpip-forward unmarshal error: %v", err)
return false, nil
}
if err := s.checkPortForwardingPrivileges(ctx, "tcpip-forward", payload.Port); err != nil {
logger.Infof("tcpip-forward denied: %v", err)
return false, nil
}
logger.Debugf("tcpip-forward request: %s:%d", payload.Host, payload.Port)
sshConn, err := s.getSSHConnection(ctx)
if err != nil {
logger.Debugf("tcpip-forward request denied: %v", err)
return false, nil
}
return s.setupDirectForward(ctx, logger, sshConn, payload)
}
// cancelTcpipForwardHandler handles cancel-tcpip-forward requests.
func (s *Server) cancelTcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) {
logger := s.getRequestLogger(ctx)
var payload tcpipForwardMsg
if err := cryptossh.Unmarshal(req.Payload, &payload); err != nil {
logger.Errorf("cancel-tcpip-forward unmarshal error: %v", err)
return false, nil
}
key := ForwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port))
if s.removeRemoteForwardListener(key) {
logger.Infof("cancelled remote port forwarding: %s:%d", payload.Host, payload.Port)
return true, nil
}
logger.Warnf("cancel-tcpip-forward failed: no listener found for %s:%d", payload.Host, payload.Port)
return false, nil
}
// handleRemoteForwardListener handles incoming connections for remote port forwarding.
func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, host string, port uint32) {
log.Debugf("starting remote forward listener handler for %s:%d", host, port)
defer func() {
log.Debugf("cleaning up remote forward listener for %s:%d", host, port)
if err := ln.Close(); err != nil {
log.Debugf("remote forward listener close error: %v", err)
} else {
log.Debugf("remote forward listener closed successfully for %s:%d", host, port)
}
}()
acceptChan := make(chan acceptResult, 1)
go func() {
for {
conn, err := ln.Accept()
select {
case acceptChan <- acceptResult{conn: conn, err: err}:
if err != nil {
return
}
case <-ctx.Done():
return
}
}
}()
for {
select {
case result := <-acceptChan:
if result.err != nil {
log.Debugf("remote forward accept error: %v", result.err)
return
}
go s.handleRemoteForwardConnection(ctx, result.conn, host, port)
case <-ctx.Done():
log.Debugf("remote forward listener shutting down due to context cancellation for %s:%d", host, port)
return
}
}
}
// getRequestLogger creates a logger with user and remote address context
func (s *Server) getRequestLogger(ctx ssh.Context) *log.Entry {
remoteAddr := "unknown"
username := "unknown"
if ctx != nil {
if ctx.RemoteAddr() != nil {
remoteAddr = ctx.RemoteAddr().String()
}
username = ctx.User()
}
return log.WithFields(log.Fields{"user": username, "remote": remoteAddr})
}
// isRemotePortForwardingAllowed checks if remote port forwarding is enabled
func (s *Server) isRemotePortForwardingAllowed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.allowRemotePortForwarding
}
// parseTcpipForwardRequest parses the SSH request payload
func (s *Server) parseTcpipForwardRequest(req *cryptossh.Request) (*tcpipForwardMsg, error) {
var payload tcpipForwardMsg
err := cryptossh.Unmarshal(req.Payload, &payload)
return &payload, err
}
// getSSHConnection extracts SSH connection from context
func (s *Server) getSSHConnection(ctx ssh.Context) (*cryptossh.ServerConn, error) {
if ctx == nil {
return nil, fmt.Errorf("no context")
}
sshConnValue := ctx.Value(ssh.ContextKeyConn)
if sshConnValue == nil {
return nil, fmt.Errorf("no SSH connection in context")
}
sshConn, ok := sshConnValue.(*cryptossh.ServerConn)
if !ok || sshConn == nil {
return nil, fmt.Errorf("invalid SSH connection in context")
}
return sshConn, nil
}
// setupDirectForward sets up a direct port forward
func (s *Server) setupDirectForward(ctx ssh.Context, logger *log.Entry, sshConn *cryptossh.ServerConn, payload *tcpipForwardMsg) (bool, []byte) {
bindAddr := net.JoinHostPort(payload.Host, strconv.FormatUint(uint64(payload.Port), 10))
ln, err := net.Listen("tcp", bindAddr)
if err != nil {
logger.Errorf("tcpip-forward listen failed on %s: %v", bindAddr, err)
return false, nil
}
actualPort := payload.Port
if payload.Port == 0 {
tcpAddr := ln.Addr().(*net.TCPAddr)
actualPort = uint32(tcpAddr.Port)
logger.Debugf("tcpip-forward allocated port %d for %s", actualPort, payload.Host)
}
key := ForwardKey(fmt.Sprintf("%s:%d", payload.Host, payload.Port))
s.storeRemoteForwardListener(key, ln)
s.markConnectionActivePortForward(sshConn, ctx.User(), ctx.RemoteAddr().String())
go s.handleRemoteForwardListener(ctx, ln, payload.Host, actualPort)
response := make([]byte, 4)
binary.BigEndian.PutUint32(response, actualPort)
logger.Infof("remote port forwarding established: %s:%d", payload.Host, actualPort)
return true, response
}
// acceptResult holds the result of a listener Accept() call
type acceptResult struct {
conn net.Conn
err error
}
// handleRemoteForwardConnection handles a single remote port forwarding connection
func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, host string, port uint32) {
sessionKey := s.findSessionKeyByContext(ctx)
remoteAddr := conn.RemoteAddr().(*net.TCPAddr)
connID := fmt.Sprintf("pf-%s->%s:%d", remoteAddr, host, port)
logger := log.WithFields(log.Fields{
"session": sessionKey,
"conn": connID,
})
defer func() {
if err := conn.Close(); err != nil {
logger.Debugf("connection close error: %v", err)
}
}()
sshConn := ctx.Value(ssh.ContextKeyConn).(*cryptossh.ServerConn)
if sshConn == nil {
logger.Debugf("remote forward: no SSH connection in context")
return
}
channel, err := s.openForwardChannel(sshConn, host, port, remoteAddr, logger)
if err != nil {
logger.Debugf("open forward channel: %v", err)
return
}
s.proxyForwardConnection(ctx, logger, conn, channel)
}
// openForwardChannel creates an SSH forwarded-tcpip channel
func (s *Server) openForwardChannel(sshConn *cryptossh.ServerConn, host string, port uint32, remoteAddr *net.TCPAddr, logger *log.Entry) (cryptossh.Channel, error) {
logger.Tracef("opening forwarded-tcpip channel for %s:%d", host, port)
payload := struct {
ConnectedAddress string
ConnectedPort uint32
OriginatorAddress string
OriginatorPort uint32
}{
ConnectedAddress: host,
ConnectedPort: port,
OriginatorAddress: remoteAddr.IP.String(),
OriginatorPort: uint32(remoteAddr.Port),
}
channel, reqs, err := sshConn.OpenChannel("forwarded-tcpip", cryptossh.Marshal(&payload))
if err != nil {
return nil, fmt.Errorf("open SSH channel: %w", err)
}
go cryptossh.DiscardRequests(reqs)
return channel, nil
}
// proxyForwardConnection handles bidirectional data transfer between connection and SSH channel
func (s *Server) proxyForwardConnection(ctx ssh.Context, logger *log.Entry, conn net.Conn, channel cryptossh.Channel) {
done := make(chan struct{}, 2)
closed := make(chan struct{})
var closeOnce bool
go s.monitorSessionContext(ctx, channel, conn, closed, &closeOnce, logger)
go func() {
defer func() { done <- struct{}{} }()
if _, err := io.Copy(channel, conn); err != nil {
logger.Debugf("copy error (conn->channel): %v", err)
}
}()
go func() {
defer func() { done <- struct{}{} }()
if _, err := io.Copy(conn, channel); err != nil {
logger.Debugf("copy error (channel->conn): %v", err)
}
}()
<-done
select {
case <-done:
case <-closed:
}
if !closeOnce {
if err := channel.Close(); err != nil {
logger.Debugf("channel close error: %v", err)
}
}
}
// registerConnectionCancel stores a cancel function for a connection
func (s *Server) registerConnectionCancel(key ConnectionKey, cancel context.CancelFunc) {
s.mu.Lock()
defer s.mu.Unlock()
if s.sessionCancels == nil {
s.sessionCancels = make(map[ConnectionKey]context.CancelFunc)
}
s.sessionCancels[key] = cancel
}
// unregisterConnectionCancel removes a connection's cancel function
func (s *Server) unregisterConnectionCancel(key ConnectionKey) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessionCancels, key)
}
// monitorSessionContext watches for session cancellation and closes connections
func (s *Server) monitorSessionContext(ctx context.Context, channel cryptossh.Channel, conn net.Conn, closed chan struct{}, closeOnce *bool, logger *log.Entry) {
<-ctx.Done()
logger.Debugf("session ended, closing connections")
if !*closeOnce {
*closeOnce = true
if err := channel.Close(); err != nil {
logger.Debugf("channel close error: %v", err)
}
if err := conn.Close(); err != nil {
logger.Debugf("connection close error: %v", err)
}
close(closed)
}
}

555
client/ssh/server/server.go Normal file
View File

@@ -0,0 +1,555 @@
package server
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"sync"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
cryptossh "golang.org/x/crypto/ssh"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface/wgaddr"
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
)
// DefaultSSHPort is the default SSH port of the NetBird's embedded SSH server
const DefaultSSHPort = 22
// InternalSSHPort is the port SSH server listens on and is redirected to
const InternalSSHPort = 22022
const (
errWriteSession = "write session error: %v"
errExitSession = "exit session error: %v"
msgPrivilegedUserDisabled = "privileged user login is disabled"
)
var (
ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled)
ErrUserNotFound = errors.New("user not found")
)
// PrivilegedUserError represents an error when privileged user login is disabled
type PrivilegedUserError struct {
Username string
}
func (e *PrivilegedUserError) Error() string {
return fmt.Sprintf("%s for user: %s", msgPrivilegedUserDisabled, e.Username)
}
func (e *PrivilegedUserError) Is(target error) bool {
return target == ErrPrivilegedUserDisabled
}
// UserNotFoundError represents an error when a user cannot be found
type UserNotFoundError struct {
Username string
Cause error
}
func (e *UserNotFoundError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("user %s not found: %v", e.Username, e.Cause)
}
return fmt.Sprintf("user %s not found", e.Username)
}
func (e *UserNotFoundError) Is(target error) bool {
return target == ErrUserNotFound
}
func (e *UserNotFoundError) Unwrap() error {
return e.Cause
}
// safeLogCommand returns a safe representation of the command for logging
// Only logs the first argument to avoid leaking sensitive information
func safeLogCommand(cmd []string) string {
if len(cmd) == 0 {
return "<empty>"
}
if len(cmd) == 1 {
return cmd[0]
}
return fmt.Sprintf("%s [%d args]", cmd[0], len(cmd)-1)
}
// sshConnectionState tracks the state of an SSH connection
type sshConnectionState struct {
hasActivePortForward bool
username string
remoteAddr string
}
// Server is the SSH server implementation
type Server struct {
listener net.Listener
sshServer *ssh.Server
authorizedKeys map[string]ssh.PublicKey
mu sync.RWMutex
hostKeyPEM []byte
sessions map[SessionKey]ssh.Session
sessionCancels map[ConnectionKey]context.CancelFunc
allowLocalPortForwarding bool
allowRemotePortForwarding bool
allowRootLogin bool
allowSFTP bool
netstackNet *netstack.Net
wgAddress wgaddr.Address
ifIdx int
remoteForwardListeners map[ForwardKey]net.Listener
sshConnections map[*cryptossh.ServerConn]*sshConnectionState
}
// New creates an SSH server instance with the provided host key
func New(hostKeyPEM []byte) *Server {
return &Server{
mu: sync.RWMutex{},
hostKeyPEM: hostKeyPEM,
authorizedKeys: make(map[string]ssh.PublicKey),
sessions: make(map[SessionKey]ssh.Session),
remoteForwardListeners: make(map[ForwardKey]net.Listener),
sshConnections: make(map[*cryptossh.ServerConn]*sshConnectionState),
}
}
// Start runs the SSH server, automatically detecting netstack vs standard networking
// Does all setup synchronously, then starts serving in a goroutine and returns immediately
func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.sshServer != nil {
return errors.New("SSH server is already running")
}
ln, addrDesc, err := s.createListener(ctx, addr)
if err != nil {
return fmt.Errorf("create listener: %w", err)
}
if err := s.setupSocketFilter(ln); err != nil {
s.closeListener(ln)
return fmt.Errorf("setup socket filter: %w", err)
}
sshServer, err := s.createSSHServer(ln)
if err != nil {
s.cleanupOnError(ln)
return fmt.Errorf("create SSH server: %w", err)
}
s.initializeServerState(ln, sshServer)
log.Infof("SSH server started on %s", addrDesc)
go s.serve(ln, sshServer)
return nil
}
// createListener creates a network listener based on netstack vs standard networking
func (s *Server) createListener(ctx context.Context, addr netip.AddrPort) (net.Listener, string, error) {
if s.netstackNet != nil {
ln, err := s.netstackNet.ListenTCPAddrPort(addr)
if err != nil {
return nil, "", fmt.Errorf("listen on netstack: %w", err)
}
return ln, fmt.Sprintf("netstack %s", addr), nil
}
tcpAddr := net.TCPAddrFromAddrPort(addr)
lc := net.ListenConfig{}
ln, err := lc.Listen(ctx, "tcp", tcpAddr.String())
if err != nil {
return nil, "", fmt.Errorf("listen: %w", err)
}
return ln, addr.String(), nil
}
// setupSocketFilter attaches socket filter if needed
func (s *Server) setupSocketFilter(ln net.Listener) error {
if s.ifIdx == 0 || ln == nil || s.netstackNet != nil {
return nil
}
return attachSocketFilter(ln, s.ifIdx)
}
// closeListener safely closes a listener
func (s *Server) closeListener(ln net.Listener) {
if err := ln.Close(); err != nil {
log.Debugf("listener close error: %v", err)
}
}
// cleanupOnError cleans up resources when SSH server creation fails
func (s *Server) cleanupOnError(ln net.Listener) {
if s.ifIdx == 0 || ln == nil {
return
}
if err := detachSocketFilter(ln); err != nil {
log.Errorf("failed to detach socket filter: %v", err)
}
s.closeListener(ln)
}
// initializeServerState sets up server state after successful setup
func (s *Server) initializeServerState(ln net.Listener, sshServer *ssh.Server) {
s.listener = ln
s.sshServer = sshServer
}
// Stop closes the SSH server
func (s *Server) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.sshServer == nil {
return nil
}
if s.ifIdx > 0 && s.listener != nil {
if err := detachSocketFilter(s.listener); err != nil {
// without detaching the filter, the listener will block on shutdown
return fmt.Errorf("detach socket filter: %w", err)
}
}
if err := s.sshServer.Close(); err != nil && !isShutdownError(err) {
return fmt.Errorf("shutdown SSH server: %w", err)
}
s.sshServer = nil
s.listener = nil
return nil
}
// RemoveAuthorizedKey removes the SSH key for a peer
func (s *Server) RemoveAuthorizedKey(peer string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.authorizedKeys, peer)
}
// AddAuthorizedKey adds an SSH key for a peer
func (s *Server) AddAuthorizedKey(peer, newKey string) error {
s.mu.Lock()
defer s.mu.Unlock()
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(newKey))
if err != nil {
return fmt.Errorf("parse key: %w", err)
}
s.authorizedKeys[peer] = parsedKey
return nil
}
// SetNetstackNet sets the netstack network for userspace networking
func (s *Server) SetNetstackNet(net *netstack.Net) {
s.mu.Lock()
defer s.mu.Unlock()
s.netstackNet = net
}
// SetNetworkValidation configures network-based connection filtering
func (s *Server) SetNetworkValidation(addr wgaddr.Address) {
s.mu.Lock()
defer s.mu.Unlock()
s.wgAddress = addr
}
// SetSocketFilter configures eBPF socket filtering for the SSH server
func (s *Server) SetSocketFilter(ifIdx int) {
s.mu.Lock()
defer s.mu.Unlock()
s.ifIdx = ifIdx
}
// SetupSSHClientConfig configures SSH client settings
func (s *Server) SetupSSHClientConfig() error {
return s.SetupSSHClientConfigWithPeers(nil)
}
// SetupSSHClientConfigWithPeers configures SSH client settings for peer hostnames
func (s *Server) SetupSSHClientConfigWithPeers(peerKeys []sshconfig.PeerHostKey) error {
configMgr := sshconfig.NewManager()
if err := configMgr.SetupSSHClientConfigWithPeers(nil, peerKeys); err != nil {
return fmt.Errorf("setup SSH client config: %w", err)
}
peerCount := len(peerKeys)
if peerCount > 0 {
log.Debugf("SSH client config setup completed for %d peer hostnames", peerCount)
} else {
log.Debugf("SSH client config setup completed with no peers")
}
return nil
}
func (s *Server) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, allowed := range s.authorizedKeys {
if ssh.KeysEqual(allowed, key) {
if ctx != nil {
log.Debugf("SSH key authentication successful for user %s from %s", ctx.User(), ctx.RemoteAddr())
}
return true
}
}
if ctx != nil {
log.Warnf("SSH key authentication failed for user %s from %s: key not authorized (type: %s, fingerprint: %s)",
ctx.User(), ctx.RemoteAddr(), key.Type(), cryptossh.FingerprintSHA256(key))
}
return false
}
// markConnectionActivePortForward marks an SSH connection as having an active port forward
func (s *Server) markConnectionActivePortForward(sshConn *cryptossh.ServerConn, username, remoteAddr string) {
s.mu.Lock()
defer s.mu.Unlock()
if state, exists := s.sshConnections[sshConn]; exists {
state.hasActivePortForward = true
} else {
s.sshConnections[sshConn] = &sshConnectionState{
hasActivePortForward: true,
username: username,
remoteAddr: remoteAddr,
}
}
}
// connectionCloseHandler cleans up connection state when SSH connections fail/close
func (s *Server) connectionCloseHandler(conn net.Conn, err error) {
// We can't extract the SSH connection from net.Conn directly
// Connection cleanup will happen during session cleanup or via timeout
log.Debugf("SSH connection failed for %s: %v", conn.RemoteAddr(), err)
}
// findSessionKeyByContext finds the session key by matching SSH connection context
func (s *Server) findSessionKeyByContext(ctx ssh.Context) SessionKey {
if ctx == nil {
return "unknown"
}
// Try to match by SSH connection
sshConn := ctx.Value(ssh.ContextKeyConn)
if sshConn == nil {
return "unknown"
}
s.mu.RLock()
defer s.mu.RUnlock()
// Look through sessions to find one with matching connection
for sessionKey, session := range s.sessions {
if session.Context().Value(ssh.ContextKeyConn) == sshConn {
return sessionKey
}
}
// If no session found, this might be during early connection setup
// Return a temporary key that we'll fix up later
if ctx.User() != "" && ctx.RemoteAddr() != nil {
tempKey := SessionKey(fmt.Sprintf("%s@%s", ctx.User(), ctx.RemoteAddr().String()))
log.Debugf("using temporary session key for port forward tracking: %s", tempKey)
return tempKey
}
return "unknown"
}
// cleanupConnectionPortForward removes port forward state from a connection
func (s *Server) cleanupConnectionPortForward(sshConn *cryptossh.ServerConn) {
s.mu.Lock()
defer s.mu.Unlock()
if state, exists := s.sshConnections[sshConn]; exists {
state.hasActivePortForward = false
}
}
// connectionValidator validates incoming connections based on source IP
func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn {
s.mu.RLock()
netbirdNetwork := s.wgAddress.Network
localIP := s.wgAddress.IP
s.mu.RUnlock()
if !netbirdNetwork.IsValid() || !localIP.IsValid() {
return conn
}
remoteAddr := conn.RemoteAddr()
tcpAddr, ok := remoteAddr.(*net.TCPAddr)
if !ok {
log.Debugf("SSH connection from non-TCP address %s allowed", remoteAddr)
return conn
}
remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP)
if !ok {
log.Warnf("SSH connection rejected: invalid remote IP %s", tcpAddr.IP)
return nil
}
// Block connections from our own IP (prevent local apps from connecting to ourselves)
if remoteIP == localIP {
log.Warnf("SSH connection rejected from own IP %s", remoteIP)
return nil
}
if !netbirdNetwork.Contains(remoteIP) {
log.Warnf("SSH connection rejected from non-NetBird IP %s (allowed range: %s)", remoteIP, netbirdNetwork)
return nil
}
log.Debugf("SSH connection from %s allowed", remoteIP)
return conn
}
// serve runs the SSH server in a goroutine
func (s *Server) serve(ln net.Listener, sshServer *ssh.Server) {
if ln == nil {
log.Debug("SSH server serve called with nil listener")
return
}
err := sshServer.Serve(ln)
if err == nil {
return
}
if isShutdownError(err) {
return
}
log.Errorf("SSH server error: %v", err)
}
// isShutdownError checks if the error is expected during normal shutdown
func isShutdownError(err error) bool {
if errors.Is(err, net.ErrClosed) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "accept" {
return true
}
return false
}
// createSSHServer creates and configures the SSH server
func (s *Server) createSSHServer(listener net.Listener) (*ssh.Server, error) {
if err := enableUserSwitching(); err != nil {
log.Warnf("failed to enable user switching: %v", err)
}
server := &ssh.Server{
Addr: listener.Addr().String(),
Handler: s.sessionHandler,
SubsystemHandlers: map[string]ssh.SubsystemHandler{
"sftp": s.sftpSubsystemHandler,
},
HostSigners: []ssh.Signer{},
ChannelHandlers: map[string]ssh.ChannelHandler{
"session": ssh.DefaultSessionHandler,
"direct-tcpip": s.directTCPIPHandler,
},
RequestHandlers: map[string]ssh.RequestHandler{
"tcpip-forward": s.tcpipForwardHandler,
"cancel-tcpip-forward": s.cancelTcpipForwardHandler,
},
ConnCallback: s.connectionValidator,
ConnectionFailedCallback: s.connectionCloseHandler,
}
hostKeyPEM := ssh.HostKeyPEM(s.hostKeyPEM)
if err := server.SetOption(hostKeyPEM); err != nil {
return nil, fmt.Errorf("set host key: %w", err)
}
s.configurePortForwarding(server)
return server, nil
}
// storeRemoteForwardListener stores a remote forward listener for cleanup
func (s *Server) storeRemoteForwardListener(key ForwardKey, ln net.Listener) {
s.mu.Lock()
defer s.mu.Unlock()
s.remoteForwardListeners[key] = ln
}
// removeRemoteForwardListener removes and closes a remote forward listener
func (s *Server) removeRemoteForwardListener(key ForwardKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
ln, exists := s.remoteForwardListeners[key]
if !exists {
return false
}
delete(s.remoteForwardListeners, key)
if err := ln.Close(); err != nil {
log.Debugf("remote forward listener close error: %v", err)
}
return true
}
// directTCPIPHandler handles direct-tcpip channel requests for local port forwarding with privilege validation
func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, newChan cryptossh.NewChannel, ctx ssh.Context) {
var payload struct {
Host string
Port uint32
OriginatorAddr string
OriginatorPort uint32
}
if err := cryptossh.Unmarshal(newChan.ExtraData(), &payload); err != nil {
if err := newChan.Reject(cryptossh.ConnectionFailed, "parse payload"); err != nil {
log.Debugf("channel reject error: %v", err)
}
return
}
s.mu.RLock()
allowLocal := s.allowLocalPortForwarding
s.mu.RUnlock()
if !allowLocal {
log.Debugf("direct-tcpip rejected: local port forwarding disabled")
_ = newChan.Reject(cryptossh.Prohibited, "local port forwarding disabled")
return
}
// Check privilege requirements for the destination port
if err := s.checkPortForwardingPrivileges(ctx, "local", payload.Port); err != nil {
log.Infof("direct-tcpip denied: %v", err)
_ = newChan.Reject(cryptossh.Prohibited, "insufficient privileges")
return
}
log.Debugf("direct-tcpip request: %s:%d", payload.Host, payload.Port)
ssh.DirectTCPIPHandler(srv, conn, newChan, ctx)
}

View File

@@ -0,0 +1,374 @@
package server
import (
"context"
"fmt"
"net"
"os/user"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/ssh"
sshclient "github.com/netbirdio/netbird/client/ssh/client"
)
func TestServer_RootLoginRestriction(t *testing.T) {
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
tests := []struct {
name string
allowRoot bool
username string
expectError bool
description string
}{
{
name: "root login allowed",
allowRoot: true,
username: "root",
expectError: false,
description: "Root login should succeed when allowed",
},
{
name: "root login denied",
allowRoot: false,
username: "root",
expectError: true,
description: "Root login should fail when disabled",
},
{
name: "regular user login always allowed",
allowRoot: false,
username: "testuser",
expectError: false,
description: "Regular user login should work regardless of root setting",
},
}
// Add Windows Administrator tests if on Windows
if runtime.GOOS == "windows" {
tests = append(tests, []struct {
name string
allowRoot bool
username string
expectError bool
description string
}{
{
name: "Administrator login allowed",
allowRoot: true,
username: "Administrator",
expectError: false,
description: "Administrator login should succeed when allowed",
},
{
name: "Administrator login denied",
allowRoot: false,
username: "Administrator",
expectError: true,
description: "Administrator login should fail when disabled",
},
{
name: "administrator login denied (lowercase)",
allowRoot: false,
username: "administrator",
expectError: true,
description: "administrator login should fail when disabled (case insensitive)",
},
}...)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mock privileged environment to test root access controls
cleanup := setupTestDependencies(
createTestUser("root", "0", "0", "/root"), // Running as root
nil,
runtime.GOOS,
0, // euid 0 (root)
map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
"testuser": createTestUser("testuser", "1000", "1000", "/home/testuser"),
},
nil,
)
defer cleanup()
// Create server with specific configuration
server := New(hostKey)
server.SetAllowRootLogin(tt.allowRoot)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
// Test the userNameLookup method directly
user, err := server.userNameLookup(tt.username)
if tt.expectError {
assert.Error(t, err, tt.description)
if tt.username == "root" || strings.ToLower(tt.username) == "administrator" {
// Check for appropriate error message based on platform capabilities
errorMsg := err.Error()
// Either privileged user restriction OR user switching limitation
hasPrivilegedError := strings.Contains(errorMsg, "privileged user")
hasSwitchingError := strings.Contains(errorMsg, "cannot switch") || strings.Contains(errorMsg, "user switching not supported")
assert.True(t, hasPrivilegedError || hasSwitchingError,
"Expected privileged user or user switching error, got: %s", errorMsg)
}
} else {
if tt.username == "root" || strings.ToLower(tt.username) == "administrator" {
// For privileged users, we expect either success or a different error
// (like user not found), but not the "login disabled" error
if err != nil {
assert.NotContains(t, err.Error(), "privileged user login is disabled")
}
} else {
// For regular users, lookup should generally succeed or fall back gracefully
// Note: may return current user as fallback
assert.NotNil(t, user)
}
}
})
}
}
func TestServer_PortForwardingRestriction(t *testing.T) {
// Test that the port forwarding callbacks properly respect configuration flags
// This is a unit test of the callback logic, not a full integration test
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
tests := []struct {
name string
allowLocalForwarding bool
allowRemoteForwarding bool
description string
}{
{
name: "all forwarding allowed",
allowLocalForwarding: true,
allowRemoteForwarding: true,
description: "Both local and remote forwarding should be allowed",
},
{
name: "local forwarding disabled",
allowLocalForwarding: false,
allowRemoteForwarding: true,
description: "Local forwarding should be denied when disabled",
},
{
name: "remote forwarding disabled",
allowLocalForwarding: true,
allowRemoteForwarding: false,
description: "Remote forwarding should be denied when disabled",
},
{
name: "all forwarding disabled",
allowLocalForwarding: false,
allowRemoteForwarding: false,
description: "Both forwarding types should be denied when disabled",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create server with specific configuration
server := New(hostKey)
server.SetAllowLocalPortForwarding(tt.allowLocalForwarding)
server.SetAllowRemotePortForwarding(tt.allowRemoteForwarding)
// We need to access the internal configuration to simulate the callback tests
// Since the callbacks are created inside the Start method, we'll test the logic directly
// Test the configuration values are set correctly
server.mu.RLock()
allowLocal := server.allowLocalPortForwarding
allowRemote := server.allowRemotePortForwarding
server.mu.RUnlock()
assert.Equal(t, tt.allowLocalForwarding, allowLocal, "Local forwarding configuration should be set correctly")
assert.Equal(t, tt.allowRemoteForwarding, allowRemote, "Remote forwarding configuration should be set correctly")
// Simulate the callback logic
localResult := allowLocal // This would be the callback return value
remoteResult := allowRemote // This would be the callback return value
assert.Equal(t, tt.allowLocalForwarding, localResult,
"Local port forwarding callback should return correct value")
assert.Equal(t, tt.allowRemoteForwarding, remoteResult,
"Remote port forwarding callback should return correct value")
})
}
}
func TestServer_PortConflictHandling(t *testing.T) {
// Test that multiple sessions requesting the same local port are handled naturally by the OS
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create server
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
serverAddr := StartTestServer(t, server)
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Get a free port for testing
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
testPort := ln.Addr().(*net.TCPAddr).Port
err = ln.Close()
require.NoError(t, err)
// Connect first client
ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel1()
client1, err := sshclient.DialInsecure(ctx1, serverAddr, "test-user")
require.NoError(t, err)
defer func() {
err := client1.Close()
assert.NoError(t, err)
}()
// Connect second client
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
client2, err := sshclient.DialInsecure(ctx2, serverAddr, "test-user")
require.NoError(t, err)
defer func() {
err := client2.Close()
assert.NoError(t, err)
}()
// First client binds to the test port
localAddr1 := fmt.Sprintf("127.0.0.1:%d", testPort)
remoteAddr := "127.0.0.1:80"
// Start first client's port forwarding
done1 := make(chan error, 1)
go func() {
// This should succeed and hold the port
err := client1.LocalPortForward(ctx1, localAddr1, remoteAddr)
done1 <- err
}()
// Give first client time to bind
time.Sleep(200 * time.Millisecond)
// Second client tries to bind to same port
localAddr2 := fmt.Sprintf("127.0.0.1:%d", testPort)
shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Second)
defer shortCancel()
err = client2.LocalPortForward(shortCtx, localAddr2, remoteAddr)
// Second client should fail due to "address already in use"
assert.Error(t, err, "Second client should fail to bind to same port")
if err != nil {
// The error should indicate the address is already in use
assert.Contains(t, strings.ToLower(err.Error()), "address already in use",
"Error should indicate port conflict")
}
// Cancel first client's context and wait for it to finish
cancel1()
select {
case err1 := <-done1:
// Should get context cancelled or deadline exceeded
assert.Error(t, err1, "First client should exit when context cancelled")
case <-time.After(2 * time.Second):
t.Error("First client did not exit within timeout")
}
}
func TestServer_IsPrivilegedUser(t *testing.T) {
tests := []struct {
username string
expected bool
description string
}{
{
username: "root",
expected: true,
description: "root should be considered privileged",
},
{
username: "regular",
expected: false,
description: "regular user should not be privileged",
},
{
username: "",
expected: false,
description: "empty username should not be privileged",
},
}
// Add Windows-specific tests
if runtime.GOOS == "windows" {
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: true,
description: "Administrator should be considered privileged on Windows",
},
{
username: "administrator",
expected: true,
description: "administrator should be considered privileged on Windows (case insensitive)",
},
}...)
} else {
// On non-Windows systems, Administrator should not be privileged
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}...)
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.expected, result, tt.description)
})
}
}

View File

@@ -1,74 +1,61 @@
package ssh
package server
import (
"context"
"fmt"
"net"
"net/netip"
"os/user"
"runtime"
"strings"
"testing"
"time"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
cryptossh "golang.org/x/crypto/ssh"
nbssh "github.com/netbirdio/netbird/client/ssh"
)
func TestServer_AddAuthorizedKey(t *testing.T) {
key, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
server := NewServer(key)
key, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server := New(key)
// add multiple keys
keys := map[string][]byte{}
for i := 0; i < 10; i++ {
peer := fmt.Sprintf("%s-%d", "remotePeer", i)
remotePrivKey, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
remotePubKey, err := GeneratePublicKey(remotePrivKey)
if err != nil {
t.Fatal(err)
}
remotePrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
remotePubKey, err := nbssh.GeneratePublicKey(remotePrivKey)
require.NoError(t, err)
err = server.AddAuthorizedKey(peer, string(remotePubKey))
if err != nil {
t.Error(err)
}
require.NoError(t, err)
keys[peer] = remotePubKey
}
// make sure that all keys have been added
for peer, remotePubKey := range keys {
k, ok := server.authorizedKeys[peer]
assert.True(t, ok, "expecting remotePeer key to be found in authorizedKeys")
assert.Equal(t, string(remotePubKey), strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))))
assert.Equal(t, string(remotePubKey), strings.TrimSpace(string(cryptossh.MarshalAuthorizedKey(k))))
}
}
func TestServer_RemoveAuthorizedKey(t *testing.T) {
key, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
server := NewServer(key)
key, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server := New(key)
remotePrivKey, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
remotePubKey, err := GeneratePublicKey(remotePrivKey)
if err != nil {
t.Fatal(err)
}
remotePrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
remotePubKey, err := nbssh.GeneratePublicKey(remotePrivKey)
require.NoError(t, err)
err = server.AddAuthorizedKey("remotePeer", string(remotePubKey))
if err != nil {
t.Error(err)
}
require.NoError(t, err)
server.RemoveAuthorizedKey("remotePeer")
@@ -77,69 +64,55 @@ func TestServer_RemoveAuthorizedKey(t *testing.T) {
}
func TestServer_PubKeyHandler(t *testing.T) {
key, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
server := NewServer(key)
key, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server := New(key)
var keys []ssh.PublicKey
for i := 0; i < 10; i++ {
peer := fmt.Sprintf("%s-%d", "remotePeer", i)
remotePrivKey, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
remotePubKey, err := GeneratePublicKey(remotePrivKey)
if err != nil {
t.Fatal(err)
}
remotePrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
remotePubKey, err := nbssh.GeneratePublicKey(remotePrivKey)
require.NoError(t, err)
remoteParsedPubKey, _, _, _, err := ssh.ParseAuthorizedKey(remotePubKey)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
err = server.AddAuthorizedKey(peer, string(remotePubKey))
if err != nil {
t.Error(err)
}
require.NoError(t, err)
keys = append(keys, remoteParsedPubKey)
}
for _, key := range keys {
accepted := server.publicKeyHandler(nil, key)
assert.True(t, accepted, "SSH key should be accepted")
}
}
func TestServer_StartStop(t *testing.T) {
key, err := GeneratePrivateKey(ED25519)
if err != nil {
t.Fatal(err)
}
key, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server := NewServer(key)
server := New(key)
// Test stopping when not started
err = server.Stop()
assert.NoError(t, err)
}
func TestSSHServerIntegration(t *testing.T) {
// Generate host key for server
hostKey, err := GeneratePrivateKey(ED25519)
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := GeneratePrivateKey(ED25519)
clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPubKey, err := GeneratePublicKey(clientPrivKey)
clientPubKey, err := nbssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create server with random port
server := NewServer(hostKey)
server := New(hostKey)
// Add client's public key as authorized
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
@@ -164,7 +137,8 @@ func TestSSHServerIntegration(t *testing.T) {
}
started <- actualAddr
errChan <- server.Start(actualAddr)
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
@@ -184,26 +158,30 @@ func TestSSHServerIntegration(t *testing.T) {
}()
// Parse client private key
signer, err := ssh.ParsePrivateKey(clientPrivKey)
signer, err := cryptossh.ParsePrivateKey(clientPrivKey)
require.NoError(t, err)
// Parse server host key for verification
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey)
require.NoError(t, err)
hostPubKey := hostPrivParsed.PublicKey()
// Get current user for SSH connection
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for test")
// Create SSH client config
config := &ssh.ClientConfig{
User: "test-user",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
config := &cryptossh.ClientConfig{
User: currentUser.Username,
Auth: []cryptossh.AuthMethod{
cryptossh.PublicKeys(signer),
},
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
HostKeyCallback: cryptossh.FixedHostKey(hostPubKey),
Timeout: 3 * time.Second,
}
// Connect to SSH server
client, err := ssh.Dial("tcp", serverAddr, config)
client, err := cryptossh.Dial("tcp", serverAddr, config)
require.NoError(t, err)
defer func() {
if err := client.Close(); err != nil {
@@ -228,17 +206,17 @@ func TestSSHServerIntegration(t *testing.T) {
func TestSSHServerMultipleConnections(t *testing.T) {
// Generate host key for server
hostKey, err := GeneratePrivateKey(ED25519)
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := GeneratePrivateKey(ED25519)
clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
clientPubKey, err := GeneratePublicKey(clientPrivKey)
clientPubKey, err := nbssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create server
server := NewServer(hostKey)
server := New(hostKey)
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
@@ -260,7 +238,8 @@ func TestSSHServerMultipleConnections(t *testing.T) {
}
started <- actualAddr
errChan <- server.Start(actualAddr)
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
@@ -280,20 +259,24 @@ func TestSSHServerMultipleConnections(t *testing.T) {
}()
// Parse client private key
signer, err := ssh.ParsePrivateKey(clientPrivKey)
signer, err := cryptossh.ParsePrivateKey(clientPrivKey)
require.NoError(t, err)
// Parse server host key
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey)
require.NoError(t, err)
hostPubKey := hostPrivParsed.PublicKey()
config := &ssh.ClientConfig{
User: "test-user",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
// Get current user for SSH connection
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for test")
config := &cryptossh.ClientConfig{
User: currentUser.Username,
Auth: []cryptossh.AuthMethod{
cryptossh.PublicKeys(signer),
},
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
HostKeyCallback: cryptossh.FixedHostKey(hostPubKey),
Timeout: 3 * time.Second,
}
@@ -303,7 +286,7 @@ func TestSSHServerMultipleConnections(t *testing.T) {
for i := 0; i < numConnections; i++ {
go func(id int) {
client, err := ssh.Dial("tcp", serverAddr, config)
client, err := cryptossh.Dial("tcp", serverAddr, config)
if err != nil {
results <- fmt.Errorf("connection %d failed: %w", id, err)
return
@@ -336,23 +319,23 @@ func TestSSHServerMultipleConnections(t *testing.T) {
}
}
func TestSSHServerAuthenticationFailure(t *testing.T) {
func TestSSHServerNoAuthMode(t *testing.T) {
// Generate host key for server
hostKey, err := GeneratePrivateKey(ED25519)
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
// Generate authorized key
authorizedPrivKey, err := GeneratePrivateKey(ED25519)
authorizedPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
authorizedPubKey, err := GeneratePublicKey(authorizedPrivKey)
authorizedPubKey, err := nbssh.GeneratePublicKey(authorizedPrivKey)
require.NoError(t, err)
// Generate unauthorized key (different from authorized)
unauthorizedPrivKey, err := GeneratePrivateKey(ED25519)
unauthorizedPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
// Create server with only one authorized key
server := NewServer(hostKey)
server := New(hostKey)
err = server.AddAuthorizedKey("authorized-peer", string(authorizedPubKey))
require.NoError(t, err)
@@ -374,7 +357,8 @@ func TestSSHServerAuthenticationFailure(t *testing.T) {
}
started <- actualAddr
errChan <- server.Start(actualAddr)
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
@@ -394,35 +378,41 @@ func TestSSHServerAuthenticationFailure(t *testing.T) {
}()
// Parse unauthorized private key
unauthorizedSigner, err := ssh.ParsePrivateKey(unauthorizedPrivKey)
unauthorizedSigner, err := cryptossh.ParsePrivateKey(unauthorizedPrivKey)
require.NoError(t, err)
// Parse server host key
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey)
require.NoError(t, err)
hostPubKey := hostPrivParsed.PublicKey()
// Get current user for SSH connection
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for test")
// Try to connect with unauthorized key
config := &ssh.ClientConfig{
User: "test-user",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(unauthorizedSigner),
config := &cryptossh.ClientConfig{
User: currentUser.Username,
Auth: []cryptossh.AuthMethod{
cryptossh.PublicKeys(unauthorizedSigner),
},
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
HostKeyCallback: cryptossh.FixedHostKey(hostPubKey),
Timeout: 3 * time.Second,
}
// This should fail
_, err = ssh.Dial("tcp", serverAddr, config)
assert.Error(t, err, "Connection should fail with unauthorized key")
assert.Contains(t, err.Error(), "unable to authenticate")
// This should succeed in no-auth mode
conn, err := cryptossh.Dial("tcp", serverAddr, config)
assert.NoError(t, err, "Connection should succeed in no-auth mode")
if conn != nil {
assert.NoError(t, conn.Close())
}
}
func TestSSHServerStartStopCycle(t *testing.T) {
hostKey, err := GeneratePrivateKey(ED25519)
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server := NewServer(hostKey)
server := New(hostKey)
serverAddr := "127.0.0.1:0"
// Test multiple start/stop cycles
@@ -445,7 +435,8 @@ func TestSSHServerStartStopCycle(t *testing.T) {
}
started <- actualAddr
errChan <- server.Start(actualAddr)
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
@@ -460,3 +451,48 @@ func TestSSHServerStartStopCycle(t *testing.T) {
require.NoError(t, err, "Cycle %d: Stop should succeed", i+1)
}
}
func TestSSHServer_WindowsShellHandling(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Windows shell test in short mode")
}
server := &Server{}
if runtime.GOOS == "windows" {
// Test Windows cmd.exe shell behavior
args := server.getShellCommandArgs("cmd.exe", "echo test")
assert.Equal(t, "cmd.exe", args[0])
assert.Equal(t, "/c", args[1])
assert.Equal(t, "echo test", args[2])
// Test PowerShell behavior
args = server.getShellCommandArgs("powershell.exe", "echo test")
assert.Equal(t, "powershell.exe", args[0])
assert.Equal(t, "-Command", args[1])
assert.Equal(t, "echo test", args[2])
} else {
// Test Unix shell behavior
args := server.getShellCommandArgs("/bin/sh", "echo test")
assert.Equal(t, "/bin/sh", args[0])
assert.Equal(t, "-c", args[1])
assert.Equal(t, "echo test", args[2])
}
}
func TestSSHServer_PortForwardingConfiguration(t *testing.T) {
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
require.NoError(t, err)
server1 := New(hostKey)
server2 := New(hostKey)
assert.False(t, server1.allowLocalPortForwarding, "Local port forwarding should be disabled by default for security")
assert.False(t, server1.allowRemotePortForwarding, "Remote port forwarding should be disabled by default for security")
server2.SetAllowLocalPortForwarding(true)
server2.SetAllowRemotePortForwarding(true)
assert.True(t, server2.allowLocalPortForwarding, "Local port forwarding should be enabled when explicitly set")
assert.True(t, server2.allowRemotePortForwarding, "Remote port forwarding should be enabled when explicitly set")
}

View File

@@ -0,0 +1,145 @@
package server
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// sessionHandler handles SSH sessions
func (s *Server) sessionHandler(session ssh.Session) {
sessionKey := s.registerSession(session)
sessionStart := time.Now()
logger := log.WithField("session", sessionKey)
defer s.unregisterSession(sessionKey, session)
defer func() {
duration := time.Since(sessionStart)
if err := session.Close(); err != nil {
logger.Debugf("close session after %v: %v", duration, err)
return
}
logger.Debugf("session closed after %v", duration)
}()
logger.Infof("establishing SSH session for %s from %s", session.User(), session.RemoteAddr())
privilegeResult, err := s.userPrivilegeCheck(session.User())
if err != nil {
s.handlePrivError(logger, session, err)
return
}
ptyReq, winCh, isPty := session.Pty()
hasCommand := len(session.Command()) > 0
switch {
case isPty && hasCommand:
// ssh -t <host> <cmd> - Pty command execution
s.handleCommand(logger, session, privilegeResult, ptyReq, winCh)
case isPty:
// ssh <host> - Pty interactive session (login)
s.handlePty(logger, session, privilegeResult, ptyReq, winCh)
case hasCommand:
// ssh <host> <cmd> - non-Pty command execution
s.handleCommand(logger, session, privilegeResult, ssh.Pty{}, nil)
default:
// ssh <host> - no Pty, no command (invalid)
if _, err := io.WriteString(session, "no command specified and Pty not requested\n"); err != nil {
logger.Debugf(errWriteSession, err)
}
if err := session.Exit(1); err != nil {
logger.Debugf(errExitSession, err)
}
logger.Infof("rejected non-Pty session without command from %s", session.RemoteAddr())
}
}
func (s *Server) registerSession(session ssh.Session) SessionKey {
sessionID := session.Context().Value(ssh.ContextKeySessionID)
if sessionID == nil {
sessionID = fmt.Sprintf("%p", session)
}
// Create a short 4-byte identifier from the full session ID
hasher := sha256.New()
hasher.Write([]byte(fmt.Sprintf("%v", sessionID)))
hash := hasher.Sum(nil)
shortID := hex.EncodeToString(hash[:4])
remoteAddr := session.RemoteAddr().String()
username := session.User()
sessionKey := SessionKey(fmt.Sprintf("%s@%s-%s", username, remoteAddr, shortID))
s.mu.Lock()
s.sessions[sessionKey] = session
s.mu.Unlock()
log.WithField("session", sessionKey).Debugf("registered SSH session")
return sessionKey
}
func (s *Server) unregisterSession(sessionKey SessionKey, _ ssh.Session) {
s.mu.Lock()
delete(s.sessions, sessionKey)
// Cancel all port forwarding connections for this session
var connectionsToCancel []ConnectionKey
for key := range s.sessionCancels {
if strings.HasPrefix(string(key), string(sessionKey)+"-") {
connectionsToCancel = append(connectionsToCancel, key)
}
}
for _, key := range connectionsToCancel {
if cancelFunc, exists := s.sessionCancels[key]; exists {
log.WithField("session", sessionKey).Debugf("cancelling port forwarding context: %s", key)
cancelFunc()
delete(s.sessionCancels, key)
}
}
s.mu.Unlock()
log.WithField("session", sessionKey).Debugf("unregistered SSH session")
}
func (s *Server) handlePrivError(logger *log.Entry, session ssh.Session, err error) {
errorMsg := s.buildUserLookupErrorMessage(err)
if _, writeErr := fmt.Fprintf(session, errorMsg); writeErr != nil {
logger.Debugf(errWriteSession, writeErr)
}
if exitErr := session.Exit(1); exitErr != nil {
logger.Debugf(errExitSession, exitErr)
}
}
// buildUserLookupErrorMessage creates appropriate user-facing error messages based on error type
func (s *Server) buildUserLookupErrorMessage(err error) string {
var privilegedErr *PrivilegedUserError
switch {
case errors.As(err, &privilegedErr):
if privilegedErr.Username == "root" {
return fmt.Sprintf("root login is disabled on this SSH server\n")
}
return fmt.Sprintf("privileged user access is disabled on this SSH server\n")
case errors.Is(err, ErrPrivilegeRequired):
return fmt.Sprintf("Windows user switching failed - NetBird must run with elevated privileges for user switching\n")
case errors.Is(err, ErrPrivilegedUserSwitch):
return fmt.Sprintf("Cannot switch to privileged user - current user lacks required privileges\n")
default:
return fmt.Sprintf("User authentication failed\n")
}
}

81
client/ssh/server/sftp.go Normal file
View File

@@ -0,0 +1,81 @@
package server
import (
"fmt"
"io"
"github.com/gliderlabs/ssh"
"github.com/pkg/sftp"
log "github.com/sirupsen/logrus"
)
// SetAllowSFTP enables or disables SFTP support
func (s *Server) SetAllowSFTP(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowSFTP = allow
}
// sftpSubsystemHandler handles SFTP subsystem requests
func (s *Server) sftpSubsystemHandler(sess ssh.Session) {
s.mu.RLock()
allowSFTP := s.allowSFTP
s.mu.RUnlock()
if !allowSFTP {
log.Debugf("SFTP subsystem request denied: SFTP disabled")
if err := sess.Exit(1); err != nil {
log.Debugf("SFTP session exit failed: %v", err)
}
return
}
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: sess.User(),
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSFTP,
})
if !result.Allowed {
log.Warnf("SFTP access denied for user %s from %s: %v", sess.User(), sess.RemoteAddr(), result.Error)
if err := sess.Exit(1); err != nil {
log.Debugf("exit SFTP session: %v", err)
}
return
}
log.Debugf("SFTP subsystem request from user %s (effective user %s)", sess.User(), result.User.Username)
if result.UsedFallback {
if err := s.executeSftpDirect(sess); err != nil {
log.Errorf("SFTP direct execution: %v", err)
}
return
}
if err := s.executeSftpWithPrivilegeDrop(sess, result.User); err != nil {
log.Errorf("SFTP privilege drop execution: %v", err)
}
}
// executeSftpDirect executes SFTP directly without privilege dropping
func (s *Server) executeSftpDirect(sess ssh.Session) error {
log.Debugf("starting SFTP session for user %s (no privilege dropping)", sess.User())
sftpServer, err := sftp.NewServer(sess)
if err != nil {
return fmt.Errorf("SFTP server creation: %w", err)
}
defer func() {
if err := sftpServer.Close(); err != nil {
log.Debugf("failed to close sftp server: %v", err)
}
}()
if err := sftpServer.Serve(); err != nil && err != io.EOF {
return fmt.Errorf("serve: %w", err)
}
return nil
}

View File

@@ -0,0 +1,215 @@
package server
import (
"context"
"fmt"
"net"
"net/netip"
"os/user"
"testing"
"time"
"github.com/pkg/sftp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
)
func TestSSHServer_SFTPSubsystem(t *testing.T) {
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create server with SFTP enabled
server := New(hostKey)
server.SetAllowSFTP(true)
// Add client's public key as authorized
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
// Start server
serverAddr := "127.0.0.1:0"
started := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
ln, err := net.Listen("tcp", serverAddr)
if err != nil {
errChan <- err
return
}
actualAddr := ln.Addr().String()
if err := ln.Close(); err != nil {
errChan <- fmt.Errorf("close temp listener: %w", err)
return
}
started <- actualAddr
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
case actualAddr := <-started:
serverAddr = actualAddr
case err := <-errChan:
t.Fatalf("Server failed to start: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("Server start timeout")
}
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Parse client private key
signer, err := cryptossh.ParsePrivateKey(clientPrivKey)
require.NoError(t, err)
// Parse server host key
hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey)
require.NoError(t, err)
hostPubKey := hostPrivParsed.PublicKey()
// Get current user for SSH connection
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for test")
// Create SSH client connection
clientConfig := &cryptossh.ClientConfig{
User: currentUser.Username,
Auth: []cryptossh.AuthMethod{
cryptossh.PublicKeys(signer),
},
HostKeyCallback: cryptossh.FixedHostKey(hostPubKey),
Timeout: 5 * time.Second,
}
conn, err := cryptossh.Dial("tcp", serverAddr, clientConfig)
require.NoError(t, err, "SSH connection should succeed")
defer func() {
if err := conn.Close(); err != nil {
t.Logf("connection close error: %v", err)
}
}()
// Create SFTP client
sftpClient, err := sftp.NewClient(conn)
require.NoError(t, err, "SFTP client creation should succeed")
defer func() {
if err := sftpClient.Close(); err != nil {
t.Logf("SFTP client close error: %v", err)
}
}()
// Test basic SFTP operations
workingDir, err := sftpClient.Getwd()
assert.NoError(t, err, "Should be able to get working directory")
assert.NotEmpty(t, workingDir, "Working directory should not be empty")
// Test directory listing
files, err := sftpClient.ReadDir(".")
assert.NoError(t, err, "Should be able to list current directory")
assert.NotNil(t, files, "File list should not be nil")
}
func TestSSHServer_SFTPDisabled(t *testing.T) {
// Generate host key for server
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
// Generate client key pair
clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
clientPubKey, err := ssh.GeneratePublicKey(clientPrivKey)
require.NoError(t, err)
// Create server with SFTP disabled
server := New(hostKey)
server.SetAllowSFTP(false)
// Add client's public key as authorized
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
require.NoError(t, err)
// Start server
serverAddr := "127.0.0.1:0"
started := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
ln, err := net.Listen("tcp", serverAddr)
if err != nil {
errChan <- err
return
}
actualAddr := ln.Addr().String()
if err := ln.Close(); err != nil {
errChan <- fmt.Errorf("close temp listener: %w", err)
return
}
started <- actualAddr
addrPort, _ := netip.ParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
case actualAddr := <-started:
serverAddr = actualAddr
case err := <-errChan:
t.Fatalf("Server failed to start: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("Server start timeout")
}
defer func() {
err := server.Stop()
require.NoError(t, err)
}()
// Parse client private key
signer, err := cryptossh.ParsePrivateKey(clientPrivKey)
require.NoError(t, err)
// Parse server host key
hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey)
require.NoError(t, err)
hostPubKey := hostPrivParsed.PublicKey()
// Get current user for SSH connection
currentUser, err := user.Current()
require.NoError(t, err, "Should be able to get current user for test")
// Create SSH client connection
clientConfig := &cryptossh.ClientConfig{
User: currentUser.Username,
Auth: []cryptossh.AuthMethod{
cryptossh.PublicKeys(signer),
},
HostKeyCallback: cryptossh.FixedHostKey(hostPubKey),
Timeout: 5 * time.Second,
}
conn, err := cryptossh.Dial("tcp", serverAddr, clientConfig)
require.NoError(t, err, "SSH connection should succeed")
defer func() {
if err := conn.Close(); err != nil {
t.Logf("connection close error: %v", err)
}
}()
// Try to create SFTP client - should fail when SFTP is disabled
_, err = sftp.NewClient(conn)
assert.Error(t, err, "SFTP client creation should fail when SFTP is disabled")
}

View File

@@ -0,0 +1,71 @@
//go:build !windows
package server
import (
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"strconv"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// executeSftpWithPrivilegeDrop executes SFTP using Unix privilege dropping
func (s *Server) executeSftpWithPrivilegeDrop(sess ssh.Session, targetUser *user.User) error {
uid, gid, groups, err := s.parseUserCredentials(targetUser)
if err != nil {
return fmt.Errorf("parse user credentials: %w", err)
}
sftpCmd, err := s.createSftpExecutorCommand(sess, uid, gid, groups, targetUser.HomeDir)
if err != nil {
return fmt.Errorf("create executor: %w", err)
}
sftpCmd.Stdin = sess
sftpCmd.Stdout = sess
sftpCmd.Stderr = sess.Stderr()
log.Tracef("starting SFTP with privilege dropping to user %s (UID=%d, GID=%d)", targetUser.Username, uid, gid)
if err := sftpCmd.Start(); err != nil {
return fmt.Errorf("starting SFTP executor: %w", err)
}
if err := sftpCmd.Wait(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
log.Tracef("SFTP process exited with code %d", exitError.ExitCode())
return nil
}
return fmt.Errorf("exec: %w", err)
}
return nil
}
// createSftpExecutorCommand creates a command that spawns netbird ssh sftp for privilege dropping
func (s *Server) createSftpExecutorCommand(sess ssh.Session, uid, gid uint32, groups []uint32, workingDir string) (*exec.Cmd, error) {
netbirdPath, err := os.Executable()
if err != nil {
return nil, err
}
args := []string{
"ssh", "sftp",
"--uid", strconv.FormatUint(uint64(uid), 10),
"--gid", strconv.FormatUint(uint64(gid), 10),
"--working-dir", workingDir,
}
for _, group := range groups {
args = append(args, "--groups", strconv.FormatUint(uint64(group), 10))
}
log.Tracef("creating SFTP executor command: %s %v", netbirdPath, args)
return exec.CommandContext(sess.Context(), netbirdPath, args...), nil
}

View File

@@ -0,0 +1,85 @@
//go:build windows
package server
import (
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// createSftpCommand creates a Windows SFTP command with user switching
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, error) {
username, domain := s.parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("get netbird executable path: %w", err)
}
args := []string{
"ssh", "sftp",
"--working-dir", targetUser.HomeDir,
"--windows-username", username,
"--windows-domain", domain,
}
pd := NewPrivilegeDropper()
token, err := pd.createToken(username, domain)
if err != nil {
return nil, fmt.Errorf("create token: %w", err)
}
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Warnf("failed to close Windows token handle: %v", err)
}
}()
cmd, err := pd.createProcessWithToken(sess.Context(), windows.Token(token), netbirdPath, append([]string{netbirdPath}, args...), targetUser.HomeDir)
if err != nil {
return nil, fmt.Errorf("create SFTP command: %w", err)
}
log.Debugf("Created Windows SFTP command with user switching for %s", targetUser.Username)
return cmd, nil
}
// executeSftpCommand executes a Windows SFTP command with proper I/O handling
func (s *Server) executeSftpCommand(sess ssh.Session, sftpCmd *exec.Cmd) error {
sftpCmd.Stdin = sess
sftpCmd.Stdout = sess
sftpCmd.Stderr = sess.Stderr()
if err := sftpCmd.Start(); err != nil {
return fmt.Errorf("starting sftp executor: %w", err)
}
if err := sftpCmd.Wait(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
log.Tracef("sftp process exited with code %d", exitError.ExitCode())
return nil
}
return fmt.Errorf("exec sftp: %w", err)
}
return nil
}
// executeSftpWithPrivilegeDrop executes SFTP using Windows privilege dropping
func (s *Server) executeSftpWithPrivilegeDrop(sess ssh.Session, targetUser *user.User) error {
sftpCmd, err := s.createSftpCommand(targetUser, sess)
if err != nil {
return fmt.Errorf("create sftp: %w", err)
}
return s.executeSftpCommand(sess, sftpCmd)
}

175
client/ssh/server/shell.go Normal file
View File

@@ -0,0 +1,175 @@
package server
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
const (
defaultUnixShell = "/bin/sh"
pwshExe = "pwsh.exe"
powershellExe = "powershell.exe"
)
// getUserShell returns the appropriate shell for the given user ID
// Handles all platform-specific logic and fallbacks consistently
func getUserShell(userID string) string {
switch runtime.GOOS {
case "windows":
return getWindowsUserShell()
default:
return getUnixUserShell(userID)
}
}
// getWindowsUserShell returns the best shell for Windows users.
// We intentionally do not support cmd.exe or COMSPEC fallbacks to avoid command injection
// vulnerabilities that arise from cmd.exe's complex command line parsing and special characters.
// PowerShell provides safer argument handling and is available on all modern Windows systems.
// Order: pwsh.exe -> powershell.exe
func getWindowsUserShell() string {
if path, err := exec.LookPath(pwshExe); err == nil {
return path
}
if path, err := exec.LookPath(powershellExe); err == nil {
return path
}
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
}
// getUnixUserShell returns the shell for Unix-like systems
func getUnixUserShell(userID string) string {
shell := getShellFromPasswd(userID)
if shell != "" {
return shell
}
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
return defaultUnixShell
}
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
func getShellFromPasswd(userID string) string {
file, err := os.Open("/etc/passwd")
if err != nil {
return ""
}
defer func() {
if err := file.Close(); err != nil {
log.Warnf("close /etc/passwd file: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Split(line, ":")
if len(fields) < 7 {
continue
}
// field 2 is UID
if fields[2] == userID {
shell := strings.TrimSpace(fields[6])
return shell
}
}
if err := scanner.Err(); err != nil {
log.Warnf("error reading /etc/passwd: %v", err)
}
return ""
}
// prepareUserEnv prepares environment variables for user execution
func prepareUserEnv(user *user.User, shell string) []string {
return []string{
fmt.Sprint("SHELL=" + shell),
fmt.Sprint("USER=" + user.Username),
fmt.Sprint("LOGNAME=" + user.Username),
fmt.Sprint("HOME=" + user.HomeDir),
fmt.Sprint("PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"),
}
}
// acceptEnv checks if environment variable from SSH client should be accepted
// This is a whitelist of variables that SSH clients can send to the server
func acceptEnv(envVar string) bool {
varName := envVar
if idx := strings.Index(envVar, "="); idx != -1 {
varName = envVar[:idx]
}
exactMatches := []string{
"LANG",
"LANGUAGE",
"TERM",
"COLORTERM",
"EDITOR",
"VISUAL",
"PAGER",
"LESS",
"LESSCHARSET",
"TZ",
}
prefixMatches := []string{
"LC_",
}
for _, exact := range exactMatches {
if varName == exact {
return true
}
}
for _, prefix := range prefixMatches {
if strings.HasPrefix(varName, prefix) {
return true
}
}
return false
}
// prepareSSHEnv prepares SSH protocol-specific environment variables
// These variables provide information about the SSH connection itself
func prepareSSHEnv(session ssh.Session) []string {
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
if err != nil {
remoteHost = remoteAddr.String()
remotePort = "0"
}
localHost, localPort, err := net.SplitHostPort(localAddr.String())
if err != nil {
localHost = localAddr.String()
localPort = strconv.Itoa(InternalSSHPort)
}
return []string{
// SSH_CLIENT format: "client_ip client_port server_port"
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
}
}

View File

@@ -0,0 +1,168 @@
//go:build linux
package server
import (
"fmt"
"net"
"os"
"sync"
"syscall"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/net/bpf"
"golang.org/x/sys/unix"
)
// SockFprog represents a BPF program for socket filtering
type SockFprog struct {
Len uint16
Filter *unix.SockFilter
}
// filterInfo stores the file descriptor and filter state for each listener
type filterInfo struct {
fd int
file *os.File
}
var (
listenerFilters = make(map[*net.TCPListener]*filterInfo)
filterMutex sync.RWMutex
)
// attachSocketFilter attaches a BPF socket filter to restrict SSH connections
// to only the specified WireGuard interface index
func attachSocketFilter(listener net.Listener, wgIfIndex int) error {
tcpListener, ok := listener.(*net.TCPListener)
if !ok {
return fmt.Errorf("listener is not a TCP listener")
}
file, err := tcpListener.File()
if err != nil {
return fmt.Errorf("get listener file descriptor: %w", err)
}
// Don't close the file here - we need it for detaching the filter
// Set the duplicated FD to non-blocking to match the mode of the
// FD used by the Go runtime's network poller
if err := syscall.SetNonblock(int(file.Fd()), true); err != nil {
file.Close()
return fmt.Errorf("set non-blocking on duplicated FD: %w", err)
}
// Create BPF program that filters by interface index
prog, err := createInterfaceFilterProgram(uint32(wgIfIndex))
if err != nil {
file.Close()
return fmt.Errorf("create BPF program: %w", err)
}
assembled, err := bpf.Assemble(prog)
if err != nil {
file.Close()
return fmt.Errorf("assemble BPF program: %w", err)
}
// Convert to unix.SockFilter format
sockFilters := make([]unix.SockFilter, len(assembled))
for i, raw := range assembled {
sockFilters[i] = unix.SockFilter{
Code: raw.Op,
Jt: raw.Jt,
Jf: raw.Jf,
K: raw.K,
}
}
// Attach socket filter to the TCP listener
sockFprog := &SockFprog{
Len: uint16(len(sockFilters)),
Filter: &sockFilters[0],
}
fd := int(file.Fd())
_, _, errno := syscall.Syscall6(
syscall.SYS_SETSOCKOPT,
uintptr(fd),
uintptr(unix.SOL_SOCKET),
uintptr(unix.SO_ATTACH_FILTER),
uintptr(unsafe.Pointer(sockFprog)),
unsafe.Sizeof(*sockFprog),
0,
)
if errno != 0 {
file.Close()
return fmt.Errorf("attach socket filter: %v", errno)
}
// Store the file descriptor and file for later detach
filterMutex.Lock()
listenerFilters[tcpListener] = &filterInfo{
fd: fd,
file: file,
}
filterMutex.Unlock()
log.Debugf("SSH socket filter attached: restricting to interface index %d", wgIfIndex)
return nil
}
// createInterfaceFilterProgram creates a BPF program that accepts packets
// only from the specified interface index
func createInterfaceFilterProgram(wgIfIndex uint32) ([]bpf.Instruction, error) {
return []bpf.Instruction{
// Load interface index from socket metadata
// ExtInterfaceIndex is a special BPF extension for interface index
bpf.LoadExtension{Num: bpf.ExtInterfaceIndex},
// Compare with WireGuard interface index
bpf.JumpIf{
Cond: bpf.JumpEqual,
Val: wgIfIndex,
SkipTrue: 1,
},
// Reject if not matching (return 0)
bpf.RetConstant{Val: 0},
// Accept if matching (return maximum packet size)
bpf.RetConstant{Val: 0xFFFFFFFF},
}, nil
}
// detachSocketFilter removes the socket filter from a TCP listener
func detachSocketFilter(listener net.Listener) error {
tcpListener, ok := listener.(*net.TCPListener)
if !ok {
return fmt.Errorf("listener is not a TCP listener")
}
filterMutex.Lock()
info, exists := listenerFilters[tcpListener]
if exists {
delete(listenerFilters, tcpListener)
}
filterMutex.Unlock()
if !exists {
log.Debugf("No socket filter attached to detach")
return nil
}
defer func() {
if closeErr := info.file.Close(); closeErr != nil {
log.Debugf("listener file close error: %v", closeErr)
}
}()
// Use the same file descriptor that was used for attach
if err := unix.SetsockoptInt(info.fd, unix.SOL_SOCKET, unix.SO_DETACH_FILTER, 0); err != nil {
return fmt.Errorf("detach socket filter: %w", err)
}
log.Debugf("SSH socket filter detached")
return nil
}

View File

@@ -0,0 +1,19 @@
//go:build !linux
package server
import (
"net"
)
// attachSocketFilter is not supported on non-Linux platforms
func attachSocketFilter(listener net.Listener, wgIfIndex int) error {
// Socket filtering is not available on non-Linux platforms - no-op
return nil
}
// detachSocketFilter is not supported on non-Linux platforms
func detachSocketFilter(listener net.Listener) error {
// Socket filtering is not available on non-Linux platforms - no-op
return nil
}

View File

@@ -0,0 +1,48 @@
//go:build !linux
package server
import (
"net"
"testing"
"github.com/stretchr/testify/require"
)
func TestAttachSocketFilter_NonLinux(t *testing.T) {
// Create a test TCP listener
tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
require.NoError(t, err, "Should resolve TCP address")
tcpListener, err := net.ListenTCP("tcp", tcpAddr)
require.NoError(t, err, "Should create TCP listener")
defer func() {
if closeErr := tcpListener.Close(); closeErr != nil {
t.Logf("TCP listener close error: %v", closeErr)
}
}()
// Test that socket filter attachment returns an error on non-Linux platforms
err = attachSocketFilter(tcpListener, 1)
require.Error(t, err, "Should return error on non-Linux platforms")
require.Contains(t, err.Error(), "only supported on Linux", "Error should indicate platform limitation")
}
func TestDetachSocketFilter_NonLinux(t *testing.T) {
// Create a test TCP listener
tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
require.NoError(t, err, "Should resolve TCP address")
tcpListener, err := net.ListenTCP("tcp", tcpAddr)
require.NoError(t, err, "Should create TCP listener")
defer func() {
if closeErr := tcpListener.Close(); closeErr != nil {
t.Logf("TCP listener close error: %v", closeErr)
}
}()
// Test that socket filter detachment returns an error on non-Linux platforms
err = detachSocketFilter(tcpListener)
require.Error(t, err, "Should return error on non-Linux platforms")
require.Contains(t, err.Error(), "only supported on Linux", "Error should indicate platform limitation")
}

View File

@@ -0,0 +1,160 @@
//go:build linux
package server
import (
"net"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/net/bpf"
)
func TestCreateInterfaceFilterProgram(t *testing.T) {
wgIfIndex := uint32(42)
prog, err := createInterfaceFilterProgram(wgIfIndex)
require.NoError(t, err, "Should create BPF program without error")
require.NotEmpty(t, prog, "BPF program should not be empty")
// Verify program structure
require.Len(t, prog, 4, "BPF program should have 4 instructions")
// Check first instruction - load interface index
loadExt, ok := prog[0].(bpf.LoadExtension)
require.True(t, ok, "First instruction should be LoadExtension")
require.Equal(t, bpf.ExtInterfaceIndex, loadExt.Num, "Should load interface index extension")
// Check second instruction - compare with target interface
jumpIf, ok := prog[1].(bpf.JumpIf)
require.True(t, ok, "Second instruction should be JumpIf")
require.Equal(t, bpf.JumpEqual, jumpIf.Cond, "Should compare for equality")
require.Equal(t, wgIfIndex, jumpIf.Val, "Should compare with correct interface index")
require.Equal(t, uint8(1), jumpIf.SkipTrue, "Should skip next instruction if match")
// Check third instruction - reject if not matching
rejectRet, ok := prog[2].(bpf.RetConstant)
require.True(t, ok, "Third instruction should be RetConstant")
require.Equal(t, uint32(0), rejectRet.Val, "Should return 0 to reject packet")
// Check fourth instruction - accept if matching
acceptRet, ok := prog[3].(bpf.RetConstant)
require.True(t, ok, "Fourth instruction should be RetConstant")
require.Equal(t, uint32(0xFFFFFFFF), acceptRet.Val, "Should return max value to accept packet")
}
func TestCreateInterfaceFilterProgram_Assembly(t *testing.T) {
wgIfIndex := uint32(10)
prog, err := createInterfaceFilterProgram(wgIfIndex)
require.NoError(t, err, "Should create BPF program without error")
// Test that the program can be assembled
assembled, err := bpf.Assemble(prog)
require.NoError(t, err, "BPF program should assemble without error")
require.NotEmpty(t, assembled, "Assembled program should not be empty")
require.True(t, len(assembled) > 0, "Should produce non-empty assembled instructions")
}
func TestAttachSocketFilter_NonTCPListener(t *testing.T) {
// Create a mock listener that's not a TCP listener
mockListener := &mockFilterListener{}
defer mockListener.Close()
err := attachSocketFilter(mockListener, 1)
require.Error(t, err, "Should return error for non-TCP listener")
require.Contains(t, err.Error(), "not a TCP listener", "Error should indicate listener type issue")
}
// mockFilterListener implements net.Listener but is not a TCP listener
type mockFilterListener struct{}
func (m *mockFilterListener) Accept() (net.Conn, error) {
return nil, net.ErrClosed
}
func (m *mockFilterListener) Close() error {
return nil
}
func (m *mockFilterListener) Addr() net.Addr {
addr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
return addr
}
func TestAttachSocketFilter_Integration(t *testing.T) {
// Create a test TCP listener
tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
require.NoError(t, err, "Should resolve TCP address")
tcpListener, err := net.ListenTCP("tcp", tcpAddr)
require.NoError(t, err, "Should create TCP listener")
defer func() {
if closeErr := tcpListener.Close(); closeErr != nil {
t.Logf("TCP listener close error: %v", closeErr)
}
}()
// Get a real interface for testing
interfaces, err := net.Interfaces()
require.NoError(t, err, "Should get network interfaces")
require.NotEmpty(t, interfaces, "Should have at least one network interface")
// Use the first non-loopback interface
var testIfIndex int
for _, iface := range interfaces {
if iface.Flags&net.FlagLoopback == 0 && iface.Index > 0 {
testIfIndex = iface.Index
break
}
}
if testIfIndex == 0 {
t.Skip("No suitable network interface found for testing")
}
// Test socket filter attachment
err = attachSocketFilter(tcpListener, testIfIndex)
if err != nil {
// Socket filter attachment may fail in test environments due to permissions
// This is expected and acceptable
t.Logf("Socket filter attachment failed (expected in test environment): %v", err)
return
}
// If attachment succeeded, test detachment
err = detachSocketFilter(tcpListener)
if err != nil {
// Detachment may fail in test environments due to socket state changes
t.Logf("Socket filter detachment failed (expected in test environment): %v", err)
}
}
func TestSetSocketFilter_Integration(t *testing.T) {
testKey := []byte(`-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEA2Z3QY0EfAFU+wU1M7FH+6QCPfZhL1H5ZbG5QZ4oP+H8Y7QJYbY
rNYmY+x2G5nU1J5T1x6QaKv8Y5Yx8gKQBz5vBV7V3X9UY1QY0EfAFU+wU1M7FH+6QCP
fZhL1H5ZbG5QZ4oP+H8Y7QJYbYrNYmY+x2G5nU1J5T1x6QaKv8Y5Yx8gKQBz5vBV7V3X
9UY1QY0EfAFU+wU1M7FH+6QCPfZhL1H5ZbG5QZ4oP+H8Y7QJYbYrNYmY+x2G5nU1J5T
1x6QaKv8Y5Yx8gKQBz5vBV7V3X9UY1QY0EfAFU+wU1M7FH+6QCPfZhL1H5ZbG5QZ4oP
+H8Y7QJYbYrNYmY+x2G5nU1J5T1x6QaKv8Y5Yx8gKQBz5vBV7V3X9UAAAA8g+QKV7Ps
ClezwAAAAAABBAAAAdwdwdF9rZXlfc2VjcmV0AAAAAQAAAQEA2Z3QY0EfAFU+wU1M7FH+
6QCPfZhL1H5ZbG5QZ4oP+H8Y7QJYbYrNYmY+x2G5nU1J5T1x6QaKv8Y5Yx8gKQBz5vBV
7V3X9UY1QY0EfAFU+wU1M7FH+6QCPfZhL1H5ZbG5QZ4oP+H8Y7QJYbYrNYmY+x2G5nU
1J5T1x6QaKv8Y5Yx8gKQBz5vBV7V3X9UY1QY0EfAFU+wU1M7FH+6QCPfZhL1H5ZbG5Q
Z4oP+H8Y7QJYbYrNYmY+x2G5nU1J5T1x6QaKv8Y5Yx8gKQBz5vBV7V3X9UY1QY0EfAF
U+wU1M7FH+6QCPfZhL1H5ZbG5QZ4oP+H8Y7QJYbYrNYmY+x2G5nU1J5T1x6QaKv8Y5Y
x8gKQBz5vBV7V3X9UAAAA8g+QKV7PsClezwAAA=
-----END OPENSSH PRIVATE KEY-----`)
server := New(testKey)
require.NotNil(t, server, "Should create SSH server")
// Test SetSocketFilter method
testIfIndex := 42
server.SetSocketFilter(testIfIndex)
// Verify the socket filter configuration was stored
require.Equal(t, testIfIndex, server.ifIdx, "Should store correct interface index")
}

43
client/ssh/server/test.go Normal file
View File

@@ -0,0 +1,43 @@
package server
import (
"context"
"fmt"
"net"
"net/netip"
"testing"
"time"
)
func StartTestServer(t *testing.T, server *Server) string {
started := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
// Get a free port
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
errChan <- err
return
}
actualAddr := ln.Addr().String()
if err := ln.Close(); err != nil {
errChan <- fmt.Errorf("close temp listener: %w", err)
return
}
started <- actualAddr
addrPort := netip.MustParseAddrPort(actualAddr)
errChan <- server.Start(context.Background(), addrPort)
}()
select {
case actualAddr := <-started:
return actualAddr
case err := <-errChan:
t.Fatalf("Server failed to start: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("Server start timeout")
}
return ""
}

View File

@@ -0,0 +1,430 @@
package server
import (
"errors"
"fmt"
"os"
"os/user"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
)
var (
ErrPrivilegeRequired = errors.New("SeAssignPrimaryTokenPrivilege required for user switching - NetBird must run with elevated privileges")
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
)
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// Dependency injection variables for testing - allows mocking dynamic runtime checks
var (
getCurrentUser = user.Current
lookupUser = user.Lookup
getCurrentOS = func() string { return runtime.GOOS }
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
)
const (
// FeatureSSHLogin represents SSH login operations for privilege checking
FeatureSSHLogin = "SSH login"
// FeatureSFTP represents SFTP operations for privilege checking
FeatureSFTP = "SFTP"
)
// PrivilegeCheckRequest represents a privilege check request
type PrivilegeCheckRequest struct {
// Username being requested (empty = current user)
RequestedUsername string
FeatureSupportsUserSwitch bool // Does this feature/operation support user switching?
FeatureName string
}
// PrivilegeCheckResult represents the result of a privilege check
type PrivilegeCheckResult struct {
// Allowed indicates whether the privilege check passed
Allowed bool
// User is the effective user to use for the operation (nil if not allowed)
User *user.User
// Error contains the reason for denial (nil if allowed)
Error error
// UsedFallback indicates we fell back to current user instead of requested user.
// This happens on Unix when running as an unprivileged user (e.g., in containers)
// where there's no point in user switching since we lack privileges anyway.
// When true, all privilege checks have already been performed and no additional
// privilege dropping or root checks are needed - the current user is the target.
UsedFallback bool
// RequiresUserSwitching indicates whether user switching will actually occur
// (false for fallback cases where no actual switching happens)
RequiresUserSwitching bool
}
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
// This is the single source of truth for privilege decisions across the SSH server.
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
context, err := s.buildPrivilegeCheckContext(req.FeatureName)
if err != nil {
return PrivilegeCheckResult{Allowed: false, Error: err}
}
// Handle empty username case - but still check root access controls
if req.RequestedUsername == "" {
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: context.currentUser.Username},
}
}
return PrivilegeCheckResult{
Allowed: true,
User: context.currentUser,
RequiresUserSwitching: false,
}
}
return s.checkUserRequest(context, req)
}
// buildPrivilegeCheckContext gathers all the context needed for privilege checking
func (s *Server) buildPrivilegeCheckContext(featureName string) (*privilegeCheckContext, error) {
currentUser, err := getCurrentUser()
if err != nil {
return nil, fmt.Errorf("get current user for %s: %w", featureName, err)
}
s.mu.RLock()
allowRoot := s.allowRootLogin
s.mu.RUnlock()
return &privilegeCheckContext{
currentUser: currentUser,
currentUserPrivileged: getIsProcessPrivileged(),
allowRoot: allowRoot,
}, nil
}
// checkUserRequest handles normal privilege checking flow for specific usernames
func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheckRequest) PrivilegeCheckResult {
if !ctx.currentUserPrivileged && isPlatformUnix() {
log.Debugf("Unix non-privileged shortcut: falling back to current user %s for %s (requested: %s)",
ctx.currentUser.Username, req.FeatureName, req.RequestedUsername)
return PrivilegeCheckResult{
Allowed: true,
User: ctx.currentUser,
UsedFallback: true,
RequiresUserSwitching: false,
}
}
resolvedUser, err := s.resolveRequestedUser(req.RequestedUsername)
if err != nil {
// Calculate if user switching would be required even if lookup failed
needsUserSwitching := !isSameUser(req.RequestedUsername, ctx.currentUser.Username)
return PrivilegeCheckResult{
Allowed: false,
Error: err,
RequiresUserSwitching: needsUserSwitching,
}
}
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: resolvedUser.Username},
RequiresUserSwitching: needsUserSwitching,
}
}
if needsUserSwitching && !req.FeatureSupportsUserSwitch {
return PrivilegeCheckResult{
Allowed: false,
Error: fmt.Errorf("%s: user switching not supported by this feature", req.FeatureName),
RequiresUserSwitching: needsUserSwitching,
}
}
return PrivilegeCheckResult{
Allowed: true,
User: resolvedUser,
RequiresUserSwitching: needsUserSwitching,
}
}
// resolveRequestedUser resolves a username to its canonical user identity
func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, error) {
if requestedUsername == "" {
return getCurrentUser()
}
if err := validateUsername(requestedUsername); err != nil {
return nil, fmt.Errorf("invalid username: %w", err)
}
u, err := lookupUser(requestedUsername)
if err != nil {
return nil, &UserNotFoundError{Username: requestedUsername, Cause: err}
}
return u, nil
}
// isSameResolvedUser compares two resolved user identities
func isSameResolvedUser(user1, user2 *user.User) bool {
if user1 == nil || user2 == nil {
return user1 == user2
}
return user1.Uid == user2.Uid
}
// logPrivilegeCheckResult logs the final result of privilege checking
func (s *Server) logPrivilegeCheckResult(req PrivilegeCheckRequest, result PrivilegeCheckResult) {
if !result.Allowed {
log.Debugf("Privilege check denied for %s (user: %s, feature: %s): %v",
req.FeatureName, req.RequestedUsername, req.FeatureName, result.Error)
} else {
log.Debugf("Privilege check allowed for %s (user: %s, requires_switching: %v)",
req.FeatureName, req.RequestedUsername, result.RequiresUserSwitching)
}
}
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// isSameUser checks if two usernames refer to the same user
// SECURITY: This function must be conservative - it should only return true
// when we're certain both usernames refer to the exact same user identity
func isSameUser(requestedUsername, currentUsername string) bool {
// Empty requested username means current user
if requestedUsername == "" {
return true
}
// Exact match (most common case)
if getCurrentOS() == "windows" {
if strings.EqualFold(requestedUsername, currentUsername) {
return true
}
} else {
if requestedUsername == currentUsername {
return true
}
}
// Windows domain resolution: only allow domain stripping when comparing
// a bare username against the current user's domain-qualified name
if getCurrentOS() == "windows" {
return isWindowsSameUser(requestedUsername, currentUsername)
}
return false
}
// isWindowsSameUser handles Windows-specific user comparison with domain logic
func isWindowsSameUser(requestedUsername, currentUsername string) bool {
// Extract domain and username parts
extractParts := func(name string) (domain, user string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(name, `\`); idx != -1 {
return name[:idx], name[idx+1:]
}
// Handle user@domain.com format
if idx := strings.Index(name, "@"); idx != -1 {
return name[idx+1:], name[:idx]
}
// No domain specified - local machine
return "", name
}
reqDomain, reqUser := extractParts(requestedUsername)
curDomain, curUser := extractParts(currentUsername)
// Case-insensitive username comparison
if !strings.EqualFold(reqUser, curUser) {
return false
}
// If requested username has no domain, it refers to local machine user
// Allow this to match the current user regardless of current user's domain
if reqDomain == "" {
return true
}
// If both have domains, they must match exactly (case-insensitive)
return strings.EqualFold(reqDomain, curDomain)
}
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return nil, result.Error
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
// On Unix: root
// On Windows: Administrator, SYSTEM (case-insensitive)
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
func isPrivilegedUsername(username string) bool {
if getCurrentOS() != "windows" {
return username == "root"
}
bareUsername := username
// Handle Windows domain format: DOMAIN\username
if idx := strings.LastIndex(username, `\`); idx != -1 {
bareUsername = username[idx+1:]
}
// Handle email-style format: username@domain.com
if idx := strings.Index(bareUsername, "@"); idx != -1 {
bareUsername = bareUsername[:idx]
}
return isWindowsPrivilegedUser(bareUsername)
}
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
func isWindowsPrivilegedUser(bareUsername string) bool {
// common privileged usernames (case insensitive)
privilegedNames := []string{
"administrator",
"admin",
"root",
"system",
"localsystem",
"networkservice",
"localservice",
}
usernameLower := strings.ToLower(bareUsername)
for _, privilegedName := range privilegedNames {
if usernameLower == privilegedName {
return true
}
}
// computer accounts (ending with $) are not privileged by themselves
// They only gain privileges through group membership or specific SIDs
if targetUser, err := lookupUser(bareUsername); err == nil {
return isWindowsPrivilegedSID(targetUser.Uid)
}
return false
}
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
func isWindowsPrivilegedSID(sid string) bool {
privilegedSIDs := []string{
"S-1-5-18", // Local System (SYSTEM)
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
}
for _, privilegedSID := range privilegedSIDs {
if sid == privilegedSID {
return true
}
}
// Check for domain administrator accounts (RID 500 in any domain)
// Format: S-1-5-21-domain-domain-domain-500
// This is reliable as RID 500 is reserved for the domain Administrator account
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
return true
}
// Check for other well-known privileged RIDs in domain contexts
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
if strings.HasPrefix(sid, "S-1-5-21-") {
if strings.HasSuffix(sid, "-512") || // Domain Admins group
strings.HasSuffix(sid, "-516") || // Domain Controllers group
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
return true
}
}
return false
}
// buildShellArgs builds shell arguments for executing commands.
func buildShellArgs(shell, command string) []string {
if command != "" {
return []string{shell, "-Command", command}
}
return []string{shell}
}
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
// On Unix systems, this means running as root (UID 0).
// On Windows, this means running as Administrator or SYSTEM.
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
return isWindowsElevated()
}
return getEuid() == 0
}
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
func isWindowsElevated() bool {
currentUser, err := getCurrentUser()
if err != nil {
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
return false
}
if isWindowsPrivilegedSID(currentUser.Uid) {
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
return true
}
if isPrivilegedUsername(currentUser.Username) {
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
return true
}
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false
}

View File

@@ -0,0 +1,836 @@
package server
import (
"errors"
"os/user"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
)
// Test helper functions
func createTestUser(username, uid, gid, homeDir string) *user.User {
return &user.User{
Uid: uid,
Gid: gid,
Username: username,
Name: username,
HomeDir: homeDir,
}
}
// Test dependency injection setup - injects platform dependencies to test real logic
func setupTestDependencies(currentUser *user.User, currentUserErr error, os string, euid int, lookupUsers map[string]*user.User, lookupErrors map[string]error) func() {
// Store originals
originalGetCurrentUser := getCurrentUser
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
// Reset caches to ensure clean test state
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
return currentUser, currentUserErr
}
lookupUser = func(username string) (*user.User, error) {
if err, exists := lookupErrors[username]; exists {
return nil, err
}
if userObj, exists := lookupUsers[username]; exists {
return userObj, nil
}
return nil, errors.New("user: unknown user " + username)
}
getCurrentOS = func() string {
return os
}
getEuid = func() int {
return euid
}
// Mock privilege detection based on the test user
getIsProcessPrivileged = func() bool {
if currentUser == nil {
return false
}
// Check both username and SID for Windows systems
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
return true
}
return isPrivilegedUsername(currentUser.Username)
}
// Return cleanup function
return func() {
getCurrentUser = originalGetCurrentUser
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
}
}
func TestCheckPrivileges_ComprehensiveMatrix(t *testing.T) {
tests := []struct {
name string
os string
euid int
currentUser *user.User
requestedUsername string
featureSupportsUserSwitch bool
allowRoot bool
lookupUsers map[string]*user.User
expectedAllowed bool
expectedRequiresSwitch bool
}{
{
name: "linux_root_can_switch_to_alice",
os: "linux",
euid: 0, // Root process
currentUser: createTestUser("root", "0", "0", "/root"),
requestedUsername: "alice",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"alice": createTestUser("alice", "1000", "1000", "/home/alice"),
},
expectedAllowed: true,
expectedRequiresSwitch: true,
},
{
name: "linux_non_root_fallback_to_current_user",
os: "linux",
euid: 1000, // Non-root process
currentUser: createTestUser("alice", "1000", "1000", "/home/alice"),
requestedUsername: "bob",
featureSupportsUserSwitch: true,
allowRoot: true,
expectedAllowed: true, // Should fallback to current user (alice)
expectedRequiresSwitch: false, // Fallback means no actual switching
},
{
name: "windows_admin_can_switch_to_alice",
os: "windows",
euid: 1000, // Irrelevant on Windows
currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"),
requestedUsername: "alice",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
},
expectedAllowed: true,
expectedRequiresSwitch: true,
},
{
name: "windows_non_admin_no_fallback_hard_failure",
os: "windows",
euid: 1000, // Irrelevant on Windows
currentUser: createTestUser("alice", "1001", "1001", "C:\\Users\\alice"),
requestedUsername: "bob",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"bob": createTestUser("bob", "S-1-5-21-123456789-123456789-123456789-1002", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\bob"),
},
expectedAllowed: true, // Let OS decide - deferred security check
expectedRequiresSwitch: true, // Different user was requested
},
// Comprehensive test matrix: non-root linux with different allowRoot settings
{
name: "linux_non_root_request_root_allowRoot_false",
os: "linux",
euid: 1000,
currentUser: createTestUser("alice", "1000", "1000", "/home/alice"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: true, // Fallback allows access regardless of root setting
expectedRequiresSwitch: false, // Fallback case, no switching
},
{
name: "linux_non_root_request_root_allowRoot_true",
os: "linux",
euid: 1000,
currentUser: createTestUser("alice", "1000", "1000", "/home/alice"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: true,
expectedAllowed: true, // Should fallback to alice (non-privileged process)
expectedRequiresSwitch: false, // Fallback means no actual switching
},
// Windows admin test matrix
{
name: "windows_admin_request_root_allowRoot_false",
os: "windows",
euid: 1000,
currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: false, // Root not allowed
expectedRequiresSwitch: true,
},
{
name: "windows_admin_request_root_allowRoot_true",
os: "windows",
euid: 1000,
currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
},
expectedAllowed: true, // Windows user switching should work like Unix
expectedRequiresSwitch: true,
},
// Windows non-admin test matrix
{
name: "windows_non_admin_request_root_allowRoot_false",
os: "windows",
euid: 1000,
currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: false, // Root not allowed (allowRoot=false takes precedence)
expectedRequiresSwitch: true,
},
{
name: "windows_system_account_allowRoot_false",
os: "windows",
euid: 1000,
currentUser: createTestUser("NETBIRD\\WIN2K19-C2$", "S-1-5-18", "S-1-5-18", "C:\\Windows\\System32"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: false, // Root not allowed
expectedRequiresSwitch: true,
},
{
name: "windows_system_account_allowRoot_true",
os: "windows",
euid: 1000,
currentUser: createTestUser("NETBIRD\\WIN2K19-C2$", "S-1-5-18", "S-1-5-18", "C:\\Windows\\System32"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
},
expectedAllowed: true, // SYSTEM can switch to root
expectedRequiresSwitch: true,
},
{
name: "windows_non_admin_request_root_allowRoot_true",
os: "windows",
euid: 1000,
currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
requestedUsername: "root",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
},
expectedAllowed: true, // Let OS decide - deferred security check
expectedRequiresSwitch: true,
},
// Feature doesn't support user switching scenarios
{
name: "linux_root_feature_no_user_switching_same_user",
os: "linux",
euid: 0,
currentUser: createTestUser("root", "0", "0", "/root"),
requestedUsername: "root", // Same user
featureSupportsUserSwitch: false,
allowRoot: true,
lookupUsers: map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
},
expectedAllowed: true, // Same user should work regardless of feature support
expectedRequiresSwitch: false,
},
{
name: "linux_root_feature_no_user_switching_different_user",
os: "linux",
euid: 0,
currentUser: createTestUser("root", "0", "0", "/root"),
requestedUsername: "alice",
featureSupportsUserSwitch: false, // Feature doesn't support switching
allowRoot: true,
lookupUsers: map[string]*user.User{
"alice": createTestUser("alice", "1000", "1000", "/home/alice"),
},
expectedAllowed: false, // Should deny because feature doesn't support switching
expectedRequiresSwitch: true,
},
// Empty username (current user) scenarios
{
name: "linux_non_root_current_user_empty_username",
os: "linux",
euid: 1000,
currentUser: createTestUser("alice", "1000", "1000", "/home/alice"),
requestedUsername: "", // Empty = current user
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: true, // Current user should always work
expectedRequiresSwitch: false,
},
{
name: "linux_root_current_user_empty_username_root_not_allowed",
os: "linux",
euid: 0,
currentUser: createTestUser("root", "0", "0", "/root"),
requestedUsername: "", // Empty = current user (root)
featureSupportsUserSwitch: true,
allowRoot: false, // Root not allowed
expectedAllowed: false, // Should deny root even when it's current user
expectedRequiresSwitch: false,
},
// User not found scenarios
{
name: "linux_root_user_not_found",
os: "linux",
euid: 0,
currentUser: createTestUser("root", "0", "0", "/root"),
requestedUsername: "nonexistent",
featureSupportsUserSwitch: true,
allowRoot: true,
lookupUsers: map[string]*user.User{}, // No users defined = user not found
expectedAllowed: false, // Should fail due to user not found
expectedRequiresSwitch: true,
},
// Windows feature doesn't support user switching
{
name: "windows_admin_feature_no_user_switching_different_user",
os: "windows",
euid: 1000,
currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"),
requestedUsername: "alice",
featureSupportsUserSwitch: false, // Feature doesn't support switching
allowRoot: true,
lookupUsers: map[string]*user.User{
"alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
},
expectedAllowed: false, // Should deny because feature doesn't support switching
expectedRequiresSwitch: true,
},
// Windows regular user scenarios (non-admin)
{
name: "windows_regular_user_same_user",
os: "windows",
euid: 1000,
currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
requestedUsername: "alice", // Same user
featureSupportsUserSwitch: true,
allowRoot: false,
lookupUsers: map[string]*user.User{
"alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
},
expectedAllowed: true, // Regular user accessing themselves should work
expectedRequiresSwitch: false, // No switching for same user
},
{
name: "windows_regular_user_empty_username",
os: "windows",
euid: 1000,
currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"),
requestedUsername: "", // Empty = current user
featureSupportsUserSwitch: true,
allowRoot: false,
expectedAllowed: true, // Current user should always work
expectedRequiresSwitch: false, // No switching for current user
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Inject platform dependencies to test real logic
cleanup := setupTestDependencies(tt.currentUser, nil, tt.os, tt.euid, tt.lookupUsers, nil)
defer cleanup()
server := &Server{allowRootLogin: tt.allowRoot}
result := server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: tt.requestedUsername,
FeatureSupportsUserSwitch: tt.featureSupportsUserSwitch,
FeatureName: "SSH login",
})
assert.Equal(t, tt.expectedAllowed, result.Allowed)
assert.Equal(t, tt.expectedRequiresSwitch, result.RequiresUserSwitching)
})
}
}
func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
// Create test scenario where fallback should occur
server := &Server{allowRootLogin: true}
// Mock dependencies to simulate non-privileged user
originalGetCurrentUser := getCurrentUser
originalGetIsProcessPrivileged := getIsProcessPrivileged
defer func() {
getCurrentUser = originalGetCurrentUser
getIsProcessPrivileged = originalGetIsProcessPrivileged
}()
// Set up mocks for fallback scenario
getCurrentUser = func() (*user.User, error) {
return createTestUser("netbird", "1000", "1000", "/var/lib/netbird"), nil
}
getIsProcessPrivileged = func() bool { return false } // Non-privileged
// Request different user - should fallback
result := server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: "alice",
FeatureSupportsUserSwitch: true,
FeatureName: "SSH login",
})
// Verify fallback occurred
assert.True(t, result.Allowed, "Should allow with fallback")
assert.True(t, result.UsedFallback, "Should indicate fallback was used")
assert.Equal(t, "netbird", result.User.Username, "Should return current user")
assert.False(t, result.RequiresUserSwitching, "Should not require switching when fallback is used")
// Key assertion: When UsedFallback is true, no privilege dropping should be needed
// because all privilege checks have already been performed and we're using current user
t.Logf("UsedFallback=true means: current user (%s) is the target, no privilege dropping needed",
result.User.Username)
}
func TestPrivilegedUsernameDetection(t *testing.T) {
tests := []struct {
name string
username string
platform string
privileged bool
}{
// Unix/Linux tests
{"unix_root", "root", "linux", true},
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows tests
{"windows_administrator", "Administrator", "windows", true},
{"windows_system", "SYSTEM", "windows", true},
{"windows_admin", "admin", "windows", true},
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
{"windows_email_admin", "admin@domain.com", "windows", true},
{"windows_regular_user", "alice", "windows", false},
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
{"windows_localsystem", "localsystem", "windows", true},
{"windows_networkservice", "networkservice", "windows", true},
{"windows_localservice", "localservice", "windows", true},
// Computer accounts (these depend on current user context in real implementation)
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
// Cross-platform
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mock the platform for this test
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
defer cleanup()
result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.privileged, result)
})
}
}
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
tests := []struct {
name string
sid string
privileged bool
description string
}{
// Well-known system accounts
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
{"local_service", "S-1-5-19", true, "Local Service"},
{"network_service", "S-1-5-20", true, "Network Service"},
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
// Domain accounts
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
// Regular users
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
// Groups that are not privileged
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
{"power_users", "S-1-5-32-547", false, "Power Users group"},
// Invalid SIDs
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
{"empty_sid", "", false, "Empty SID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsPrivilegedSID(tt.sid)
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
})
}
}
func TestIsSameUser(t *testing.T) {
tests := []struct {
name string
user1 string
user2 string
os string
expected bool
}{
// Basic cases
{"same_username", "alice", "alice", "linux", true},
{"different_username", "alice", "bob", "linux", false},
// Linux (no domain processing)
{"linux_domain_vs_bare", "DOMAIN\\alice", "alice", "linux", false},
{"linux_email_vs_bare", "alice@domain.com", "alice", "linux", false},
{"linux_same_literal", "DOMAIN\\alice", "DOMAIN\\alice", "linux", true},
// Windows (with domain processing) - Note: parameter order is (requested, current, os, expected)
{"windows_domain_vs_bare", "alice", "DOMAIN\\alice", "windows", true}, // bare username matches domain current user
{"windows_email_vs_bare", "alice", "alice@domain.com", "windows", true}, // bare username matches email current user
{"windows_different_domains_same_user", "DOMAIN1\\alice", "DOMAIN2\\alice", "windows", false}, // SECURITY: different domains = different users
{"windows_case_insensitive", "Alice", "alice", "windows", true},
{"windows_different_users", "DOMAIN\\alice", "DOMAIN\\bob", "windows", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up OS mock
cleanup := setupTestDependencies(nil, nil, tt.os, 1000, nil, nil)
defer cleanup()
result := isSameUser(tt.user1, tt.user2)
assert.Equal(t, tt.expected, result)
})
}
}
func TestUsernameValidation(t *testing.T) {
tests := []struct {
name string
username string
wantErr bool
errMsg string
}{
// Valid usernames
{"valid_alphanumeric", "user123", false, ""},
{"valid_with_dots", "user.name", false, ""},
{"valid_with_hyphens", "user-name", false, ""},
{"valid_with_underscores", "user_name", false, ""},
{"valid_uppercase", "UserName", false, ""},
{"valid_starting_with_digit", "123user", false, ""},
{"valid_starting_with_dot", ".hidden", false, ""},
// Invalid usernames
{"empty_username", "", true, "username cannot be empty"},
{"username_too_long", "thisusernameiswaytoolongandexceedsthe32characterlimit", true, "username too long"},
{"username_starting_with_hyphen", "-user", true, "invalid characters"},
{"username_with_spaces", "user name", true, "invalid characters"},
{"username_with_shell_metacharacters", "user;rm", true, "invalid characters"},
{"username_with_command_injection", "user`rm -rf /`", true, "invalid characters"},
{"username_with_pipe", "user|rm", true, "invalid characters"},
{"username_with_ampersand", "user&rm", true, "invalid characters"},
{"username_with_quotes", "user\"name", true, "invalid characters"},
{"username_with_backslash", "user\\name", true, "invalid characters"},
{"username_with_newline", "user\nname", true, "invalid characters"},
{"reserved_dot", ".", true, "cannot be '.' or '..'"},
{"reserved_dotdot", "..", true, "cannot be '.' or '..'"},
{"username_with_at_symbol", "user@domain", true, "invalid characters"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateUsername(tt.username)
if tt.wantErr {
assert.Error(t, err, "Should reject invalid username")
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg, "Error message should contain expected text")
}
} else {
assert.NoError(t, err, "Should accept valid username")
}
})
}
}
// Test real-world integration scenarios with actual platform capabilities
func TestCheckPrivileges_RealWorldScenarios(t *testing.T) {
tests := []struct {
name string
feature string
featureSupportsUserSwitch bool
requestedUsername string
allowRoot bool
expectedBehaviorPattern string
}{
{"SSH_login_current_user", "SSH login", true, "", true, "should_allow_current_user"},
{"SFTP_current_user", "SFTP", true, "", true, "should_allow_current_user"},
{"port_forwarding_current_user", "port forwarding", false, "", true, "should_allow_current_user"},
{"SSH_login_root_not_allowed", "SSH login", true, "root", false, "should_deny_root"},
{"port_forwarding_different_user", "port forwarding", false, "differentuser", true, "should_deny_switching"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mock privileged environment to ensure consistent test behavior across environments
cleanup := setupTestDependencies(
createTestUser("root", "0", "0", "/root"), // Running as root
nil,
runtime.GOOS,
0, // euid 0 (root)
map[string]*user.User{
"root": createTestUser("root", "0", "0", "/root"),
"differentuser": createTestUser("differentuser", "1000", "1000", "/home/differentuser"),
},
nil,
)
defer cleanup()
server := &Server{allowRootLogin: tt.allowRoot}
result := server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: tt.requestedUsername,
FeatureSupportsUserSwitch: tt.featureSupportsUserSwitch,
FeatureName: tt.feature,
})
switch tt.expectedBehaviorPattern {
case "should_allow_current_user":
assert.True(t, result.Allowed, "Should allow current user access")
assert.False(t, result.RequiresUserSwitching, "Current user should not require switching")
case "should_deny_root":
assert.False(t, result.Allowed, "Should deny root when not allowed")
assert.Contains(t, result.Error.Error(), "root", "Should mention root in error")
case "should_deny_switching":
assert.False(t, result.Allowed, "Should deny when feature doesn't support switching")
assert.Contains(t, result.Error.Error(), "user switching not supported", "Should mention switching in error")
}
})
}
}
// Test with actual platform capabilities - no mocking
func TestCheckPrivileges_ActualPlatform(t *testing.T) {
// This test uses the REAL platform capabilities
server := &Server{allowRootLogin: true}
// Test current user access - should always work
result := server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: "", // Current user
FeatureSupportsUserSwitch: true,
FeatureName: "SSH login",
})
assert.True(t, result.Allowed, "Current user should always be allowed")
assert.False(t, result.RequiresUserSwitching, "Current user should not require switching")
assert.NotNil(t, result.User, "Should return current user")
// Test user switching capability based on actual platform
actualIsPrivileged := isCurrentProcessPrivileged() // REAL check
actualOS := runtime.GOOS // REAL check
t.Logf("Platform capabilities: OS=%s, isPrivileged=%v, supportsUserSwitching=%v",
actualOS, actualIsPrivileged, actualIsPrivileged)
// Test requesting different user
result = server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: "nonexistentuser",
FeatureSupportsUserSwitch: true,
FeatureName: "SSH login",
})
if actualOS == "windows" {
// Windows should deny user switching
assert.False(t, result.Allowed, "Windows should deny user switching")
assert.True(t, result.RequiresUserSwitching, "Should indicate switching is needed")
assert.Contains(t, result.Error.Error(), "user switching not supported",
"Should indicate user switching not supported")
} else if !actualIsPrivileged {
// Non-privileged Unix processes should fallback to current user
assert.True(t, result.Allowed, "Non-privileged Unix process should fallback to current user")
assert.False(t, result.RequiresUserSwitching, "Fallback means no switching actually happens")
assert.True(t, result.UsedFallback, "Should indicate fallback was used")
assert.NotNil(t, result.User, "Should return current user")
} else {
// Privileged Unix processes should attempt user lookup
assert.False(t, result.Allowed, "Should fail due to nonexistent user")
assert.True(t, result.RequiresUserSwitching, "Should indicate switching is needed")
assert.Contains(t, result.Error.Error(), "nonexistentuser",
"Should indicate user not found")
}
}
// Test platform detection logic with dependency injection
func TestPlatformLogic_DependencyInjection(t *testing.T) {
tests := []struct {
name string
os string
euid int
currentUser *user.User
expectedIsProcessPrivileged bool
expectedSupportsUserSwitching bool
}{
{
name: "linux_root_process",
os: "linux",
euid: 0,
currentUser: createTestUser("root", "0", "0", "/root"),
expectedIsProcessPrivileged: true,
expectedSupportsUserSwitching: true,
},
{
name: "linux_non_root_process",
os: "linux",
euid: 1000,
currentUser: createTestUser("alice", "1000", "1000", "/home/alice"),
expectedIsProcessPrivileged: false,
expectedSupportsUserSwitching: false,
},
{
name: "windows_admin_process",
os: "windows",
euid: 1000, // euid ignored on Windows
currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"),
expectedIsProcessPrivileged: true,
expectedSupportsUserSwitching: true, // Windows supports user switching when privileged
},
{
name: "windows_regular_process",
os: "windows",
euid: 1000, // euid ignored on Windows
currentUser: createTestUser("alice", "1001", "1001", "C:\\Users\\alice"),
expectedIsProcessPrivileged: false,
expectedSupportsUserSwitching: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Inject platform dependencies and test REAL logic
cleanup := setupTestDependencies(tt.currentUser, nil, tt.os, tt.euid, nil, nil)
defer cleanup()
// Test the actual functions with injected dependencies
actualIsPrivileged := isCurrentProcessPrivileged()
actualSupportsUserSwitching := actualIsPrivileged
assert.Equal(t, tt.expectedIsProcessPrivileged, actualIsPrivileged,
"isCurrentProcessPrivileged() result mismatch")
assert.Equal(t, tt.expectedSupportsUserSwitching, actualSupportsUserSwitching,
"supportsUserSwitching() result mismatch")
t.Logf("Platform: %s, EUID: %d, User: %s", tt.os, tt.euid, tt.currentUser.Username)
t.Logf("Results: isPrivileged=%v, supportsUserSwitching=%v",
actualIsPrivileged, actualSupportsUserSwitching)
})
}
}
func TestCheckPrivileges_WindowsElevatedUserSwitching(t *testing.T) {
// Test Windows elevated user switching scenarios with simplified privilege logic
tests := []struct {
name string
currentUser *user.User
requestedUsername string
allowRoot bool
expectedAllowed bool
expectedErrorContains string
}{
{
name: "windows_admin_can_switch_to_alice",
currentUser: createTestUser("administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\\\Users\\\\Administrator"),
requestedUsername: "alice",
allowRoot: true,
expectedAllowed: true,
},
{
name: "windows_non_admin_can_try_switch",
currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\\\Users\\\\alice"),
requestedUsername: "bob",
allowRoot: true,
expectedAllowed: true, // Privilege check allows it, OS will reject during execution
},
{
name: "windows_system_can_switch_to_alice",
currentUser: createTestUser("SYSTEM", "S-1-5-18", "S-1-5-18", "C:\\\\Windows\\\\system32\\\\config\\\\systemprofile"),
requestedUsername: "alice",
allowRoot: true,
expectedAllowed: true,
},
{
name: "windows_admin_root_not_allowed",
currentUser: createTestUser("administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\\\Users\\\\Administrator"),
requestedUsername: "root",
allowRoot: false,
expectedAllowed: false,
expectedErrorContains: "privileged user login is disabled",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup test dependencies with Windows OS and specified privileges
lookupUsers := map[string]*user.User{
tt.requestedUsername: createTestUser(tt.requestedUsername, "1002", "1002", "C:\\\\Users\\\\"+tt.requestedUsername),
}
cleanup := setupTestDependencies(tt.currentUser, nil, "windows", 1000, lookupUsers, nil)
defer cleanup()
server := &Server{allowRootLogin: tt.allowRoot}
result := server.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: tt.requestedUsername,
FeatureSupportsUserSwitch: true,
FeatureName: "SSH login",
})
assert.Equal(t, tt.expectedAllowed, result.Allowed,
"Privilege check result should match expected for %s", tt.name)
if !tt.expectedAllowed && tt.expectedErrorContains != "" {
assert.NotNil(t, result.Error, "Should have error when not allowed")
assert.Contains(t, result.Error.Error(), tt.expectedErrorContains,
"Error should contain expected message")
}
if tt.expectedAllowed && tt.requestedUsername != "" && tt.currentUser.Username != tt.requestedUsername {
assert.True(t, result.RequiresUserSwitching, "Should require user switching for different user")
}
})
}
}

View File

@@ -0,0 +1,245 @@
//go:build unix
package server
import (
"errors"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"os/user"
"regexp"
"runtime"
"strconv"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
// First character cannot be hyphen (POSIX requirement)
var posixUsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._-]*$`)
// validateUsername validates that a username conforms to POSIX standards with security considerations
func validateUsername(username string) error {
if username == "" {
return errors.New("username cannot be empty")
}
// POSIX allows up to 256 characters, but practical limit is 32 for compatibility
if len(username) > 32 {
return errors.New("username too long (max 32 characters)")
}
if !posixUsernameRegex.MatchString(username) {
return errors.New("username contains invalid characters (must match POSIX portable filename character set)")
}
if username == "." || username == ".." {
return fmt.Errorf("username cannot be '.' or '..'")
}
// Warn if username is fully numeric (can cause issues with UID/username ambiguity)
if isFullyNumeric(username) {
log.Warnf("fully numeric username '%s' may cause issues with some commands", username)
}
return nil
}
// isFullyNumeric checks if username contains only digits
func isFullyNumeric(username string) bool {
for _, char := range username {
if char < '0' || char > '9' {
return false
}
}
return true
}
// createSecurePtyUserSwitchCommand creates a Pty command with proper user switching
// For privileged processes, uses login command. For non-privileged, falls back to shell.
func (s *Server) createPtyUserSwitchCommand(_ []string, localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
if !isCurrentProcessPrivileged() {
// Non-privileged process: fallback to shell with login flag
return s.createNonPrivilegedPtyCommand(localUser, ptyReq, session)
}
// Privileged process: use login command for proper user switching
return s.createPrivilegedPtyLoginCommand(localUser, ptyReq, session)
}
// createNonPrivilegedPtyCommand creates a Pty command for non-privileged processes
func (s *Server) createNonPrivilegedPtyCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
shell := getUserShell(localUser.Uid)
args := []string{shell, "-l"}
execCmd := exec.CommandContext(session.Context(), args[0], args[1:]...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// createPrivilegedPtyLoginCommand creates a Pty command using login for privileged processes
func (s *Server) createPrivilegedPtyLoginCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
rawCmd := session.RawCommand()
// If there's a command to execute, use su -l -c instead of login
if rawCmd != "" {
return s.createPrivilegedPtySuCommand(localUser, ptyReq, session, rawCmd)
}
// For interactive sessions (no command), use login
loginPath, args, err := s.getRootLoginCmd(localUser.Username, session.RemoteAddr())
if err != nil {
return nil, fmt.Errorf("get login command: %w", err)
}
execCmd := exec.CommandContext(session.Context(), loginPath, args...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// createPrivilegedPtySuCommand creates a Pty command using su -l -c for command execution
func (s *Server) createPrivilegedPtySuCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session, command string) (*exec.Cmd, error) {
suPath, err := exec.LookPath("su")
if err != nil {
return nil, fmt.Errorf("su command not available: %w", err)
}
// Use su -l -c to execute the command as the target user with login environment
args := []string{"-l", localUser.Username, "-c", command}
execCmd := exec.CommandContext(session.Context(), suPath, args...)
execCmd.Dir = localUser.HomeDir
execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session)
return execCmd, nil
}
// getRootLoginCmd returns the login command and args for privileged Pty user switching
func (s *Server) getRootLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) {
loginPath, err := exec.LookPath("login")
if err != nil {
return "", nil, fmt.Errorf("login command not available: %w", err)
}
addrPort, err := netip.ParseAddrPort(remoteAddr.String())
if err != nil {
return "", nil, fmt.Errorf("parse remote address: %w", err)
}
switch runtime.GOOS {
case "linux":
// Special handling for Arch Linux without /etc/pam.d/remote
if s.fileExists("/etc/arch-release") && !s.fileExists("/etc/pam.d/remote") {
return loginPath, []string{"-f", username, "-p"}, nil
}
return loginPath, []string{"-f", username, "-h", addrPort.Addr().String(), "-p"}, nil
case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil
default:
return "", nil, fmt.Errorf("unsupported Unix platform for login command: %s", runtime.GOOS)
}
}
// fileExists checks if a file exists (helper for login command logic)
func (s *Server) fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// parseUserCredentials extracts numeric UID, GID, and supplementary groups
func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []uint32, error) {
uid64, err := strconv.ParseUint(localUser.Uid, 10, 32)
if err != nil {
return 0, 0, nil, fmt.Errorf("invalid UID %s: %w", localUser.Uid, err)
}
uid := uint32(uid64)
gid64, err := strconv.ParseUint(localUser.Gid, 10, 32)
if err != nil {
return 0, 0, nil, fmt.Errorf("invalid GID %s: %w", localUser.Gid, err)
}
gid := uint32(gid64)
groups, err := s.getSupplementaryGroups(localUser.Username)
if err != nil {
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
groups = []uint32{gid}
}
return uid, gid, groups, nil
}
// getSupplementaryGroups retrieves supplementary group IDs for a user
func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) {
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("lookup user %s: %w", username, err)
}
groupIDStrings, err := u.GroupIds()
if err != nil {
return nil, fmt.Errorf("get group IDs for user %s: %w", username, err)
}
groups := make([]uint32, len(groupIDStrings))
for i, gidStr := range groupIDStrings {
gid64, err := strconv.ParseUint(gidStr, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err)
}
groups[i] = uint32(gid64)
}
return groups, nil
}
// createExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping
func (s *Server) createExecutorCommand(session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, error) {
log.Debugf("creating executor command for user %s (Pty: %v)", localUser.Username, hasPty)
if err := validateUsername(localUser.Username); err != nil {
return nil, fmt.Errorf("invalid username: %w", err)
}
uid, gid, groups, err := s.parseUserCredentials(localUser)
if err != nil {
return nil, fmt.Errorf("parse user credentials: %w", err)
}
privilegeDropper := NewPrivilegeDropper()
config := ExecutorConfig{
UID: uid,
GID: gid,
Groups: groups,
WorkingDir: localUser.HomeDir,
Shell: getUserShell(localUser.Uid),
Command: session.RawCommand(),
PTY: hasPty,
}
return privilegeDropper.CreateExecutorCommand(session.Context(), config)
}
// createDirectCommand creates a command that runs without privilege dropping
func (s *Server) createDirectCommand(session ssh.Session, localUser *user.User) (*exec.Cmd, error) {
log.Debugf("creating direct command for user %s (no user switching needed)", localUser.Username)
shell := getUserShell(localUser.Uid)
args := s.getShellCommandArgs(shell, session.RawCommand())
cmd := exec.CommandContext(session.Context(), args[0], args[1:]...)
cmd.Dir = localUser.HomeDir
return cmd, nil
}
// enableUserSwitching is a no-op on Unix systems
func enableUserSwitching() error {
return nil
}

View File

@@ -0,0 +1,290 @@
//go:build windows
package server
import (
"errors"
"fmt"
"os/exec"
"os/user"
"strings"
"unsafe"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// validateUsername validates Windows usernames according to SAM Account Name rules
func validateUsername(username string) error {
if username == "" {
return fmt.Errorf("username cannot be empty")
}
// Windows SAM Account Name limits: 20 characters for users, 16 for computers
// We use 20 as the general limit
if len(username) > 20 {
return fmt.Errorf("username too long (max 20 characters for Windows)")
}
// Check for Windows SAM Account Name invalid characters
// Prohibited: " / \ [ ] : ; | = , + * ? < >
invalidChars := []rune{'"', '/', '\\', '[', ']', ':', ';', '|', '=', ',', '+', '*', '?', '<', '>'}
for _, char := range username {
for _, invalid := range invalidChars {
if char == invalid {
return fmt.Errorf("username contains invalid character '%c'", char)
}
}
// Check for control characters (ASCII < 32 or == 127)
if char < 32 || char == 127 {
return fmt.Errorf("username contains control characters")
}
}
// Period cannot be the final character
if strings.HasSuffix(username, ".") {
return fmt.Errorf("username cannot end with a period")
}
// Check for reserved patterns
if username == "." || username == ".." {
return fmt.Errorf("username cannot be '.' or '..'")
}
// Warn about @ character (causes login issues)
if strings.Contains(username, "@") {
log.Warnf("username '%s' contains '@' character which may cause login issues", username)
}
return nil
}
// createSecureUserSwitchCommand creates a command for Windows with user switching support
func (s *Server) createSecureUserSwitchCommand(_ []string, localUser *user.User, session ssh.Session) (*exec.Cmd, error) {
winCmd, err := s.createUserSwitchCommand(localUser, session, false)
if err != nil {
return nil, fmt.Errorf("Windows user switching failed for %s: %w", localUser.Username, err)
}
return winCmd, nil
}
// createExecutorCommand creates a command using Windows executor for privilege dropping
func (s *Server) createExecutorCommand(session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, error) {
log.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
username, _ := s.parseUsername(localUser.Username)
if err := validateUsername(username); err != nil {
return nil, fmt.Errorf("invalid username: %w", err)
}
return s.createUserSwitchCommand(localUser, session, hasPty)
}
// createDirectCommand is not supported on Windows - always use user switching with token creation
func (s *Server) createDirectCommand(session ssh.Session, localUser *user.User) (*exec.Cmd, error) {
return nil, fmt.Errorf("direct command execution not supported on Windows - use user switching with token creation")
}
// createPtyUserSwitchCommand creates a Pty command with user switching for Windows
func (s *Server) createPtyUserSwitchCommand(_ []string, localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
return s.createUserSwitchCommand(localUser, session, true)
}
// createSecurePtyUserSwitchCommand creates a Pty command with secure privilege dropping
func (s *Server) createSecurePtyUserSwitchCommand([]string, *user.User, ssh.Pty, ssh.Session) (*exec.Cmd, error) {
return nil, nil
}
// createUserSwitchCommand creates a command with Windows user switching
func (s *Server) createUserSwitchCommand(localUser *user.User, session ssh.Session, interactive bool) (*exec.Cmd, error) {
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
rawCmd := session.RawCommand()
var command string
if rawCmd != "" {
command = rawCmd
}
config := WindowsExecutorConfig{
Username: username,
Domain: domain,
WorkingDir: localUser.HomeDir,
Shell: shell,
Command: command,
Interactive: interactive || (rawCmd == ""),
}
dropper := NewPrivilegeDropper()
return dropper.CreateWindowsExecutorCommand(session.Context(), config)
}
// parseUsername extracts username and domain from a Windows username
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]
username = fullUsername[idx+1:]
return username, domain
}
// Handle username@domain format
if idx := strings.Index(fullUsername, "@"); idx != -1 {
username = fullUsername[:idx]
domain = fullUsername[idx+1:]
return username, domain
}
// Local user (no domain)
return fullUsername, "."
}
// validateUserSwitchingPrivileges validates Windows-specific user switching privileges
// This checks for SeAssignPrimaryTokenPrivilege which is required for CreateProcessWithTokenW
func validateUserSwitchingPrivileges() error {
process := windows.CurrentProcess()
var token windows.Token
err := windows.OpenProcessToken(
process,
windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY,
&token,
)
if err != nil {
return fmt.Errorf("open process token: %w", err)
}
defer func() {
if err := windows.CloseHandle(windows.Handle(token)); err != nil {
log.Warnf("close process token: %v", err)
}
}()
hasAssignToken, err := hasPrivilege(windows.Handle(token), "SeAssignPrimaryTokenPrivilege")
if err != nil {
return fmt.Errorf("has validation: %w", err)
}
if !hasAssignToken {
return ErrPrivilegeRequired
}
return nil
}
// hasPrivilege checks if the current process has a specific privilege
func hasPrivilege(token windows.Handle, privilegeName string) (bool, error) {
var luid windows.LUID
if err := windows.LookupPrivilegeValue(nil, windows.StringToUTF16Ptr(privilegeName), &luid); err != nil {
return false, fmt.Errorf("lookup privilege value: %w", err)
}
var returnLength uint32
err := windows.GetTokenInformation(
windows.Token(token),
windows.TokenPrivileges,
nil, // null buffer to get size
0,
&returnLength,
)
if err != nil && !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return false, fmt.Errorf("get token information size: %w", err)
}
buffer := make([]byte, returnLength)
err = windows.GetTokenInformation(
windows.Token(token),
windows.TokenPrivileges,
&buffer[0],
returnLength,
&returnLength,
)
if err != nil {
return false, fmt.Errorf("get token information: %w", err)
}
privileges := (*windows.Tokenprivileges)(unsafe.Pointer(&buffer[0]))
// Check if the privilege is present and enabled
for i := uint32(0); i < privileges.PrivilegeCount; i++ {
privilege := (*windows.LUIDAndAttributes)(unsafe.Pointer(
uintptr(unsafe.Pointer(&privileges.Privileges[0])) +
uintptr(i)*unsafe.Sizeof(windows.LUIDAndAttributes{}),
))
if privilege.Luid == luid {
return (privilege.Attributes & windows.SE_PRIVILEGE_ENABLED) != 0, nil
}
}
return false, nil
}
// enablePrivilege enables a specific privilege for the current process token
// This is required because privileges like SeAssignPrimaryTokenPrivilege are present
// but disabled by default, even for the SYSTEM account
func enablePrivilege(token windows.Handle, privilegeName string) error {
var luid windows.LUID
if err := windows.LookupPrivilegeValue(nil, windows.StringToUTF16Ptr(privilegeName), &luid); err != nil {
return fmt.Errorf("lookup privilege value for %s: %w", privilegeName, err)
}
privileges := windows.Tokenprivileges{
PrivilegeCount: 1,
Privileges: [1]windows.LUIDAndAttributes{
{
Luid: luid,
Attributes: windows.SE_PRIVILEGE_ENABLED,
},
},
}
err := windows.AdjustTokenPrivileges(
windows.Token(token),
false,
&privileges,
0,
nil,
nil,
)
if err != nil {
return fmt.Errorf("adjust token privileges for %s: %w", privilegeName, err)
}
hasPriv, err := hasPrivilege(token, privilegeName)
if err != nil {
return fmt.Errorf("verify privilege %s after enabling: %w", privilegeName, err)
}
if !hasPriv {
return fmt.Errorf("privilege %s could not be enabled (may not be granted to account)", privilegeName)
}
log.Debugf("Successfully enabled privilege %s for current process", privilegeName)
return nil
}
// enableUserSwitching enables required privileges for Windows user switching
func enableUserSwitching() error {
process := windows.CurrentProcess()
var token windows.Token
err := windows.OpenProcessToken(
process,
windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY,
&token,
)
if err != nil {
return fmt.Errorf("open process token: %w", err)
}
defer func() {
if err := windows.CloseHandle(windows.Handle(token)); err != nil {
log.Debugf("Failed to close process token: %v", err)
}
}()
if err := enablePrivilege(windows.Handle(token), "SeAssignPrimaryTokenPrivilege"); err != nil {
return fmt.Errorf("enable SeAssignPrimaryTokenPrivilege: %w", err)
}
log.Infof("Windows user switching privileges enabled successfully")
return nil
}

View File

@@ -0,0 +1,466 @@
//go:build windows
package winpty
import (
"context"
"fmt"
"io"
"strings"
"sync"
"syscall"
"unsafe"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
extendedStartupInfoPresent = 0x00080000
createUnicodeEnvironment = 0x00000400
procThreadAttributePseudoConsole = 0x00020016
PowerShellCommandFlag = "-Command"
errCloseInputRead = "close input read handle: %v"
errCloseConPtyCleanup = "close ConPty handle during cleanup"
)
// PtyConfig holds configuration for Pty execution.
type PtyConfig struct {
Shell string
Command string
Width int
Height int
WorkingDir string
}
// UserConfig holds user execution configuration.
type UserConfig struct {
Token windows.Handle
Environment []string
}
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
procInitializeProcThreadAttributeList = kernel32.NewProc("InitializeProcThreadAttributeList")
procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute")
procDeleteProcThreadAttributeList = kernel32.NewProc("DeleteProcThreadAttributeList")
)
// ExecutePtyWithUserToken executes a command with ConPty using user token.
func ExecutePtyWithUserToken(ctx context.Context, session ssh.Session, ptyConfig PtyConfig, userConfig UserConfig) error {
args := buildShellArgs(ptyConfig.Shell, ptyConfig.Command)
commandLine := buildCommandLine(args)
config := ExecutionConfig{
Pty: ptyConfig,
User: userConfig,
Session: session,
Context: ctx,
}
return executeConPtyWithConfig(commandLine, config)
}
// ExecutionConfig holds all execution configuration.
type ExecutionConfig struct {
Pty PtyConfig
User UserConfig
Session ssh.Session
Context context.Context
}
// executeConPtyWithConfig creates ConPty and executes process with configuration.
func executeConPtyWithConfig(commandLine string, config ExecutionConfig) error {
ctx := config.Context
session := config.Session
width := config.Pty.Width
height := config.Pty.Height
userToken := config.User.Token
userEnv := config.User.Environment
workingDir := config.Pty.WorkingDir
inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes()
if err != nil {
return fmt.Errorf("create ConPty pipes: %w", err)
}
hPty, err := createConPty(width, height, inputRead, outputWrite)
if err != nil {
return fmt.Errorf("create ConPty: %w", err)
}
primaryToken, err := duplicateToPrimaryToken(userToken)
if err != nil {
if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 {
log.Debugf(errCloseConPtyCleanup)
}
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
return fmt.Errorf("duplicate to primary token: %w", err)
}
defer func() {
if err := windows.CloseHandle(primaryToken); err != nil {
log.Debugf("close primary token: %v", err)
}
}()
siEx, err := setupConPtyStartupInfo(hPty)
if err != nil {
if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 {
log.Debugf(errCloseConPtyCleanup)
}
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
return fmt.Errorf("setup startup info: %w", err)
}
defer func() {
_, _, _ = procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList)))
}()
pi, err := createConPtyProcess(commandLine, primaryToken, userEnv, workingDir, siEx)
if err != nil {
if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 {
log.Debugf(errCloseConPtyCleanup)
}
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
return fmt.Errorf("create process as user with ConPty: %w", err)
}
defer closeProcessInfo(pi)
if err := windows.CloseHandle(inputRead); err != nil {
log.Debugf(errCloseInputRead, err)
}
if err := windows.CloseHandle(outputWrite); err != nil {
log.Debugf("close output write handle: %v", err)
}
return bridgeConPtyIO(ctx, hPty, inputWrite, outputRead, session, session, pi.Process)
}
// createConPtyPipes creates input/output pipes for ConPty.
func createConPtyPipes() (inputRead, inputWrite, outputRead, outputWrite windows.Handle, err error) {
if err := windows.CreatePipe(&inputRead, &inputWrite, nil, 0); err != nil {
return 0, 0, 0, 0, fmt.Errorf("create input pipe: %w", err)
}
if err := windows.CreatePipe(&outputRead, &outputWrite, nil, 0); err != nil {
if closeErr := windows.CloseHandle(inputRead); closeErr != nil {
log.Debugf(errCloseInputRead, closeErr)
}
if closeErr := windows.CloseHandle(inputWrite); closeErr != nil {
log.Debugf("close input write handle: %v", closeErr)
}
return 0, 0, 0, 0, fmt.Errorf("create output pipe: %w", err)
}
return inputRead, inputWrite, outputRead, outputWrite, nil
}
// createConPty creates a Windows ConPty with the specified size and pipe handles.
func createConPty(width, height int, inputRead, outputWrite windows.Handle) (windows.Handle, error) {
size := windows.Coord{X: int16(width), Y: int16(height)}
var hPty windows.Handle
if err := windows.CreatePseudoConsole(size, inputRead, outputWrite, 0, &hPty); err != nil {
return 0, fmt.Errorf("CreatePseudoConsole: %w", err)
}
return hPty, nil
}
// setupConPtyStartupInfo prepares the STARTUPINFOEX with ConPty attributes.
func setupConPtyStartupInfo(hPty windows.Handle) (*windows.StartupInfoEx, error) {
var siEx windows.StartupInfoEx
siEx.StartupInfo.Cb = uint32(unsafe.Sizeof(siEx))
var attrListSize uintptr
ret, _, _ := procInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&attrListSize)))
if ret == 0 && attrListSize == 0 {
return nil, fmt.Errorf("get attribute list size")
}
attrListBytes := make([]byte, attrListSize)
siEx.ProcThreadAttributeList = (*windows.ProcThreadAttributeList)(unsafe.Pointer(&attrListBytes[0]))
ret, _, err := procInitializeProcThreadAttributeList.Call(
uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList)),
1,
0,
uintptr(unsafe.Pointer(&attrListSize)),
)
if ret == 0 {
return nil, fmt.Errorf("initialize attribute list: %w", err)
}
ret, _, err = procUpdateProcThreadAttribute.Call(
uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList)),
0,
procThreadAttributePseudoConsole,
uintptr(hPty),
unsafe.Sizeof(hPty),
0,
0,
)
if ret == 0 {
return nil, fmt.Errorf("update thread attribute: %w", err)
}
return &siEx, nil
}
// createConPtyProcess creates the actual process with ConPty.
func createConPtyProcess(commandLine string, userToken windows.Handle, userEnv []string, workingDir string, siEx *windows.StartupInfoEx) (*windows.ProcessInformation, error) {
var pi windows.ProcessInformation
creationFlags := uint32(extendedStartupInfoPresent | createUnicodeEnvironment)
commandLinePtr, err := windows.UTF16PtrFromString(commandLine)
if err != nil {
return nil, fmt.Errorf("convert command line to UTF16: %w", err)
}
envPtr, err := convertEnvironmentToUTF16(userEnv)
if err != nil {
return nil, err
}
var workingDirPtr *uint16
if workingDir != "" {
workingDirPtr, err = windows.UTF16PtrFromString(workingDir)
if err != nil {
return nil, fmt.Errorf("convert working directory to UTF16: %w", err)
}
}
siEx.StartupInfo.Flags |= windows.STARTF_USESTDHANDLES
siEx.StartupInfo.StdInput = windows.Handle(0)
siEx.StartupInfo.StdOutput = windows.Handle(0)
siEx.StartupInfo.StdErr = siEx.StartupInfo.StdOutput
if userToken != windows.InvalidHandle {
err = windows.CreateProcessAsUser(
windows.Token(userToken),
nil,
commandLinePtr,
nil,
nil,
true,
creationFlags,
envPtr,
workingDirPtr,
&siEx.StartupInfo,
&pi,
)
} else {
err = windows.CreateProcess(
nil,
commandLinePtr,
nil,
nil,
true,
creationFlags,
envPtr,
workingDirPtr,
&siEx.StartupInfo,
&pi,
)
}
if err != nil {
return nil, fmt.Errorf("create process: %w", err)
}
return &pi, nil
}
// convertEnvironmentToUTF16 converts environment variables to Windows UTF16 format.
func convertEnvironmentToUTF16(userEnv []string) (*uint16, error) {
if len(userEnv) == 0 {
return nil, nil
}
var envUTF16 []uint16
for _, envVar := range userEnv {
if envVar != "" {
utf16Str, err := windows.UTF16FromString(envVar)
if err != nil {
log.Debugf("skipping invalid environment variable: %s (error: %v)", envVar, err)
continue
}
envUTF16 = append(envUTF16, utf16Str[:len(utf16Str)-1]...)
envUTF16 = append(envUTF16, 0)
}
}
envUTF16 = append(envUTF16, 0)
if len(envUTF16) > 0 {
return &envUTF16[0], nil
}
return nil, nil
}
// duplicateToPrimaryToken converts an impersonation token to a primary token.
func duplicateToPrimaryToken(token windows.Handle) (windows.Handle, error) {
var primaryToken windows.Handle
if err := windows.DuplicateTokenEx(
windows.Token(token),
windows.TOKEN_ALL_ACCESS,
nil,
windows.SecurityImpersonation,
windows.TokenPrimary,
(*windows.Token)(&primaryToken),
); err != nil {
return 0, fmt.Errorf("duplicate token: %w", err)
}
return primaryToken, nil
}
// bridgeConPtyIO handles I/O bridging between ConPty and readers/writers.
func bridgeConPtyIO(ctx context.Context, hPty, inputWrite, outputRead windows.Handle, reader io.ReadCloser, writer io.Writer, process windows.Handle) error {
if err := ctx.Err(); err != nil {
return err
}
var wg sync.WaitGroup
startIOBridging(ctx, &wg, inputWrite, outputRead, reader, writer)
processErr := waitForProcess(ctx, process)
if processErr != nil {
return processErr
}
// Clean up in the original order after process completes
if err := reader.Close(); err != nil {
log.Debugf("close reader: %v", err)
}
ret, _, err := procClosePseudoConsole.Call(uintptr(hPty))
if ret == 0 {
log.Debugf("close ConPty handle: %v", err)
}
wg.Wait()
if err := windows.CloseHandle(outputRead); err != nil {
log.Debugf("close output read handle: %v", err)
}
return nil
}
// startIOBridging starts the I/O bridging goroutines.
func startIOBridging(ctx context.Context, wg *sync.WaitGroup, inputWrite, outputRead windows.Handle, reader io.ReadCloser, writer io.Writer) {
wg.Add(2)
// Input: reader (SSH session) -> inputWrite (ConPty)
go func() {
defer wg.Done()
defer func() {
if err := windows.CloseHandle(inputWrite); err != nil {
log.Debugf("close input write handle in goroutine: %v", err)
}
}()
if _, err := io.Copy(&windowsHandleWriter{handle: inputWrite}, reader); err != nil {
log.Debugf("input copy ended with error: %v", err)
}
}()
// Output: outputRead (ConPty) -> writer (SSH session)
go func() {
defer wg.Done()
if _, err := io.Copy(writer, &windowsHandleReader{handle: outputRead}); err != nil {
log.Debugf("output copy ended with error: %v", err)
}
}()
}
// waitForProcess waits for process completion with context cancellation.
func waitForProcess(ctx context.Context, process windows.Handle) error {
if _, err := windows.WaitForSingleObject(process, windows.INFINITE); err != nil {
return fmt.Errorf("wait for process %d: %w", process, err)
}
return nil
}
// buildShellArgs builds shell arguments for ConPty execution.
func buildShellArgs(shell, command string) []string {
if command != "" {
return []string{shell, PowerShellCommandFlag, command}
}
return []string{shell}
}
// buildCommandLine builds a Windows command line from arguments using proper escaping.
func buildCommandLine(args []string) string {
if len(args) == 0 {
return ""
}
var result strings.Builder
for i, arg := range args {
if i > 0 {
result.WriteString(" ")
}
result.WriteString(syscall.EscapeArg(arg))
}
return result.String()
}
// closeHandles closes multiple Windows handles.
func closeHandles(handles ...windows.Handle) {
for _, handle := range handles {
if handle != windows.InvalidHandle {
if err := windows.CloseHandle(handle); err != nil {
log.Debugf("close handle: %v", err)
}
}
}
}
// closeProcessInfo closes process and thread handles.
func closeProcessInfo(pi *windows.ProcessInformation) {
if pi != nil {
if err := windows.CloseHandle(pi.Process); err != nil {
log.Debugf("close process handle: %v", err)
}
if err := windows.CloseHandle(pi.Thread); err != nil {
log.Debugf("close thread handle: %v", err)
}
}
}
// windowsHandleReader wraps a Windows handle for reading.
type windowsHandleReader struct {
handle windows.Handle
}
func (r *windowsHandleReader) Read(p []byte) (n int, err error) {
var bytesRead uint32
if err := windows.ReadFile(r.handle, p, &bytesRead, nil); err != nil {
return 0, err
}
return int(bytesRead), nil
}
func (r *windowsHandleReader) Close() error {
return windows.CloseHandle(r.handle)
}
// windowsHandleWriter wraps a Windows handle for writing.
type windowsHandleWriter struct {
handle windows.Handle
}
func (w *windowsHandleWriter) Write(p []byte) (n int, err error) {
var bytesWritten uint32
if err := windows.WriteFile(w.handle, p, &bytesWritten, nil); err != nil {
return 0, err
}
return int(bytesWritten), nil
}
func (w *windowsHandleWriter) Close() error {
return windows.CloseHandle(w.handle)
}

View File

@@ -0,0 +1,286 @@
//go:build windows
package winpty
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestBuildShellArgs(t *testing.T) {
tests := []struct {
name string
shell string
command string
expected []string
}{
{
name: "Shell with command",
shell: "powershell.exe",
command: "Get-Process",
expected: []string{"powershell.exe", "-Command", "Get-Process"},
},
{
name: "CMD with command",
shell: "cmd.exe",
command: "dir",
expected: []string{"cmd.exe", "-Command", "dir"},
},
{
name: "Shell interactive",
shell: "powershell.exe",
command: "",
expected: []string{"powershell.exe"},
},
{
name: "CMD interactive",
shell: "cmd.exe",
command: "",
expected: []string{"cmd.exe"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildShellArgs(tt.shell, tt.command)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBuildCommandLine(t *testing.T) {
tests := []struct {
name string
args []string
expected string
}{
{
name: "Simple args",
args: []string{"cmd.exe", "/c", "echo"},
expected: "cmd.exe /c echo",
},
{
name: "Args with spaces",
args: []string{"Program Files\\app.exe", "arg with spaces"},
expected: `"Program Files\app.exe" "arg with spaces"`,
},
{
name: "Args with quotes",
args: []string{"cmd.exe", "/c", `echo "hello world"`},
expected: `cmd.exe /c "echo \"hello world\""`,
},
{
name: "PowerShell calling PowerShell",
args: []string{"powershell.exe", "-Command", `powershell.exe -Command "Get-Process | Where-Object {$_.Name -eq 'notepad'}"`},
expected: `powershell.exe -Command "powershell.exe -Command \"Get-Process | Where-Object {$_.Name -eq 'notepad'}\""`,
},
{
name: "Complex nested quotes",
args: []string{"cmd.exe", "/c", `echo "He said \"Hello\" to me"`},
expected: `cmd.exe /c "echo \"He said \\\"Hello\\\" to me\""`,
},
{
name: "Path with spaces and args",
args: []string{`C:\Program Files\MyApp\app.exe`, "--config", `C:\My Config\settings.json`},
expected: `"C:\Program Files\MyApp\app.exe" --config "C:\My Config\settings.json"`,
},
{
name: "Empty argument",
args: []string{"cmd.exe", "/c", "echo", ""},
expected: `cmd.exe /c echo ""`,
},
{
name: "Argument with backslashes",
args: []string{"robocopy", `C:\Source\`, `C:\Dest\`, "/E"},
expected: `robocopy C:\Source\ C:\Dest\ /E`,
},
{
name: "Empty args",
args: []string{},
expected: "",
},
{
name: "Single arg with space",
args: []string{"path with spaces"},
expected: `"path with spaces"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildCommandLine(tt.args)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCreateConPtyPipes(t *testing.T) {
inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes()
require.NoError(t, err, "Should create ConPty pipes successfully")
// Verify all handles are valid
assert.NotEqual(t, windows.InvalidHandle, inputRead, "Input read handle should be valid")
assert.NotEqual(t, windows.InvalidHandle, inputWrite, "Input write handle should be valid")
assert.NotEqual(t, windows.InvalidHandle, outputRead, "Output read handle should be valid")
assert.NotEqual(t, windows.InvalidHandle, outputWrite, "Output write handle should be valid")
// Clean up handles
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
}
func TestCreateConPty(t *testing.T) {
inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes()
require.NoError(t, err, "Should create ConPty pipes successfully")
defer closeHandles(inputRead, inputWrite, outputRead, outputWrite)
hPty, err := createConPty(80, 24, inputRead, outputWrite)
require.NoError(t, err, "Should create ConPty successfully")
assert.NotEqual(t, windows.InvalidHandle, hPty, "ConPty handle should be valid")
// Clean up ConPty
ret, _, _ := procClosePseudoConsole.Call(uintptr(hPty))
assert.NotEqual(t, uintptr(0), ret, "Should close ConPty successfully")
}
func TestConvertEnvironmentToUTF16(t *testing.T) {
tests := []struct {
name string
userEnv []string
hasError bool
}{
{
name: "Valid environment variables",
userEnv: []string{"PATH=C:\\Windows", "USER=testuser", "HOME=C:\\Users\\testuser"},
hasError: false,
},
{
name: "Empty environment",
userEnv: []string{},
hasError: false,
},
{
name: "Environment with empty strings",
userEnv: []string{"PATH=C:\\Windows", "", "USER=testuser"},
hasError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := convertEnvironmentToUTF16(tt.userEnv)
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if len(tt.userEnv) == 0 {
assert.Nil(t, result, "Empty environment should return nil")
} else {
assert.NotNil(t, result, "Non-empty environment should return valid pointer")
}
}
})
}
}
func TestDuplicateToPrimaryToken(t *testing.T) {
if testing.Short() {
t.Skip("Skipping token tests in short mode")
}
// Get current process token for testing
var token windows.Token
err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_ALL_ACCESS, &token)
require.NoError(t, err, "Should open current process token")
defer func() {
if err := windows.CloseHandle(windows.Handle(token)); err != nil {
t.Logf("Failed to close token: %v", err)
}
}()
primaryToken, err := duplicateToPrimaryToken(windows.Handle(token))
require.NoError(t, err, "Should duplicate token to primary")
assert.NotEqual(t, windows.InvalidHandle, primaryToken, "Primary token should be valid")
// Clean up
err = windows.CloseHandle(primaryToken)
assert.NoError(t, err, "Should close primary token")
}
func TestWindowsHandleReader(t *testing.T) {
// Create a pipe for testing
var readHandle, writeHandle windows.Handle
err := windows.CreatePipe(&readHandle, &writeHandle, nil, 0)
require.NoError(t, err, "Should create pipe for testing")
defer closeHandles(readHandle, writeHandle)
// Write test data
testData := []byte("Hello, Windows Handle Reader!")
var bytesWritten uint32
err = windows.WriteFile(writeHandle, testData, &bytesWritten, nil)
require.NoError(t, err, "Should write test data")
require.Equal(t, uint32(len(testData)), bytesWritten, "Should write all test data")
// Close write handle to signal EOF
if err := windows.CloseHandle(writeHandle); err != nil {
t.Fatalf("Should close write handle: %v", err)
}
// Test reading
reader := &windowsHandleReader{handle: readHandle}
buffer := make([]byte, len(testData))
n, err := reader.Read(buffer)
require.NoError(t, err, "Should read from handle")
assert.Equal(t, len(testData), n, "Should read expected number of bytes")
assert.Equal(t, testData, buffer, "Should read expected data")
}
func TestWindowsHandleWriter(t *testing.T) {
// Create a pipe for testing
var readHandle, writeHandle windows.Handle
err := windows.CreatePipe(&readHandle, &writeHandle, nil, 0)
require.NoError(t, err, "Should create pipe for testing")
defer closeHandles(readHandle, writeHandle)
// Test writing
testData := []byte("Hello, Windows Handle Writer!")
writer := &windowsHandleWriter{handle: writeHandle}
n, err := writer.Write(testData)
require.NoError(t, err, "Should write to handle")
assert.Equal(t, len(testData), n, "Should write expected number of bytes")
// Close write handle
if err := windows.CloseHandle(writeHandle); err != nil {
t.Fatalf("Should close write handle: %v", err)
}
// Verify data was written by reading it back
buffer := make([]byte, len(testData))
var bytesRead uint32
err = windows.ReadFile(readHandle, buffer, &bytesRead, nil)
require.NoError(t, err, "Should read back written data")
assert.Equal(t, uint32(len(testData)), bytesRead, "Should read back expected number of bytes")
assert.Equal(t, testData, buffer, "Should read back expected data")
}
// BenchmarkConPtyCreation benchmarks ConPty creation performance
func BenchmarkConPtyCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes()
if err != nil {
b.Fatal(err)
}
hPty, err := createConPty(80, 24, inputRead, outputWrite)
if err != nil {
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
b.Fatal(err)
}
// Clean up
procClosePseudoConsole.Call(uintptr(hPty))
closeHandles(inputRead, inputWrite, outputRead, outputWrite)
}
}

View File

@@ -30,9 +30,8 @@ const RSA KeyType = "rsa"
// RSAKeySize is a size of newly generated RSA key
const RSAKeySize = 2048
// GeneratePrivateKey creates RSA Private Key of specified byte size
// GeneratePrivateKey creates a private key of the specified type.
func GeneratePrivateKey(keyType KeyType) ([]byte, error) {
var key crypto.Signer
var err error
switch keyType {
@@ -57,7 +56,7 @@ func GeneratePrivateKey(keyType KeyType) ([]byte, error) {
return pemBytes, nil
}
// GeneratePublicKey returns the public part of the private key
// GeneratePublicKey returns the public part of the private key.
func GeneratePublicKey(key []byte) ([]byte, error) {
signer, err := gossh.ParsePrivateKey(key)
if err != nil {
@@ -68,20 +67,17 @@ func GeneratePublicKey(key []byte) ([]byte, error) {
return []byte(strKey), nil
}
// EncodePrivateKeyToPEM encodes Private Key from RSA to PEM format
// EncodePrivateKeyToPEM encodes a private key to PEM format.
func EncodePrivateKeyToPEM(privateKey crypto.Signer) ([]byte, error) {
mk, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
// pem.Block
privBlock := pem.Block{
Type: "PRIVATE KEY",
Bytes: mk,
}
// Private key in PEM format
privatePEM := pem.EncodeToMemory(&privBlock)
return privatePEM, nil
}

View File

@@ -71,6 +71,11 @@ type Info struct {
BlockInbound bool
LazyConnectionEnabled bool
EnableSSHRoot bool
EnableSSHSFTP bool
EnableSSHLocalPortForwarding bool
EnableSSHRemotePortForwarding bool
}
func (i *Info) SetFlags(
@@ -78,6 +83,7 @@ func (i *Info) SetFlags(
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
disableDNS, disableFirewall, blockLANAccess, blockInbound, lazyConnectionEnabled bool,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
) {
i.RosenpassEnabled = rosenpassEnabled
i.RosenpassPermissive = rosenpassPermissive
@@ -93,6 +99,19 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.LazyConnectionEnabled = lazyConnectionEnabled
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
}
if enableSSHSFTP != nil {
i.EnableSSHSFTP = *enableSSHSFTP
}
if enableSSHLocalPortForwarding != nil {
i.EnableSSHLocalPortForwarding = *enableSSHLocalPortForwarding
}
if enableSSHRemotePortForwarding != nil {
i.EnableSSHRemotePortForwarding = *enableSSHRemotePortForwarding
}
}
// StaticInfo is an object that contains machine information that does not change

View File

@@ -222,25 +222,33 @@ type serviceClient struct {
iInterfacePort *widget.Entry
// switch elements for settings form
sRosenpassPermissive *widget.Check
sNetworkMonitor *widget.Check
sDisableDNS *widget.Check
sDisableClientRoutes *widget.Check
sDisableServerRoutes *widget.Check
sBlockLANAccess *widget.Check
sRosenpassPermissive *widget.Check
sNetworkMonitor *widget.Check
sDisableDNS *widget.Check
sDisableClientRoutes *widget.Check
sDisableServerRoutes *widget.Check
sBlockLANAccess *widget.Check
sEnableSSHRoot *widget.Check
sEnableSSHSFTP *widget.Check
sEnableSSHLocalPortForward *widget.Check
sEnableSSHRemotePortForward *widget.Check
// observable settings over corresponding iMngURL and iPreSharedKey values.
managementURL string
preSharedKey string
adminURL string
RosenpassPermissive bool
interfaceName string
interfacePort int
networkMonitor bool
disableDNS bool
disableClientRoutes bool
disableServerRoutes bool
blockLANAccess bool
managementURL string
preSharedKey string
adminURL string
RosenpassPermissive bool
interfaceName string
interfacePort int
networkMonitor bool
disableDNS bool
disableClientRoutes bool
disableServerRoutes bool
blockLANAccess bool
enableSSHRoot bool
enableSSHSFTP bool
enableSSHLocalPortForward bool
enableSSHRemotePortForward bool
connected bool
update *version.Update
@@ -360,98 +368,157 @@ func (s *serviceClient) showSettingsUI() {
s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil)
s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil)
s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil)
s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil)
s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil)
s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil)
s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil)
s.wSettings.SetContent(s.getSettingsForm())
s.wSettings.Resize(fyne.NewSize(600, 500))
s.wSettings.Resize(fyne.NewSize(600, 400))
s.wSettings.SetFixedSize(true)
s.getSrvConfig()
s.wSettings.Show()
}
// getSettingsForm to embed it into settings window.
func (s *serviceClient) getSettingsForm() *widget.Form {
// getConnectionForm creates the connection settings form
func (s *serviceClient) getConnectionForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
{Text: "Interface Port", Widget: s.iInterfacePort},
{Text: "Management URL", Widget: s.iMngURL},
{Text: "Admin URL", Widget: s.iAdminURL},
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
{Text: "Interface Port", Widget: s.iInterfacePort},
{Text: "Config File", Widget: s.iConfigFile},
{Text: "Log File", Widget: s.iLogFile},
},
}
}
// getNetworkForm creates the network settings form
func (s *serviceClient) getNetworkForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "Network Monitor", Widget: s.sNetworkMonitor},
{Text: "Disable DNS", Widget: s.sDisableDNS},
{Text: "Disable Client Routes", Widget: s.sDisableClientRoutes},
{Text: "Disable Server Routes", Widget: s.sDisableServerRoutes},
{Text: "Disable LAN Access", Widget: s.sBlockLANAccess},
},
SubmitText: "Save",
OnSubmit: func() {
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
// validate preSharedKey if it added
if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
dialog.ShowError(fmt.Errorf("Invalid Pre-shared Key Value"), s.wSettings)
return
}
}
}
}
port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
if err != nil {
dialog.ShowError(errors.New("Invalid interface port"), s.wSettings)
return
}
iAdminURL := strings.TrimSpace(s.iAdminURL.Text)
iMngURL := strings.TrimSpace(s.iMngURL.Text)
defer s.wSettings.Close()
// Check if any settings have changed
if s.managementURL != iMngURL || s.preSharedKey != s.iPreSharedKey.Text ||
s.adminURL != iAdminURL || s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
s.interfaceName != s.iInterfaceName.Text || s.interfacePort != int(port) ||
s.networkMonitor != s.sNetworkMonitor.Checked ||
s.disableDNS != s.sDisableDNS.Checked ||
s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
s.blockLANAccess != s.sBlockLANAccess.Checked {
s.managementURL = iMngURL
s.preSharedKey = s.iPreSharedKey.Text
s.adminURL = iAdminURL
loginRequest := proto.LoginRequest{
ManagementUrl: iMngURL,
AdminURL: iAdminURL,
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
RosenpassPermissive: &s.sRosenpassPermissive.Checked,
InterfaceName: &s.iInterfaceName.Text,
WireguardPort: &port,
NetworkMonitor: &s.sNetworkMonitor.Checked,
DisableDns: &s.sDisableDNS.Checked,
DisableClientRoutes: &s.sDisableClientRoutes.Checked,
DisableServerRoutes: &s.sDisableServerRoutes.Checked,
BlockLanAccess: &s.sBlockLANAccess.Checked,
}
if s.iPreSharedKey.Text != censoredPreSharedKey {
loginRequest.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
if err := s.restartClient(&loginRequest); err != nil {
log.Errorf("restarting client connection: %v", err)
return
}
}
},
OnCancel: func() {
s.wSettings.Close()
// getSSHForm creates the SSH settings form
func (s *serviceClient) getSSHForm() *widget.Form {
return &widget.Form{
Items: []*widget.FormItem{
{Text: "SSH Root Login", Widget: s.sEnableSSHRoot},
{Text: "SSH SFTP", Widget: s.sEnableSSHSFTP},
{Text: "SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward},
{Text: "SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward},
},
}
}
// getSettingsForm creates the tabbed settings interface
func (s *serviceClient) getSettingsForm() fyne.CanvasObject {
// Create individual forms for each tab
connectionForm := s.getConnectionForm()
networkForm := s.getNetworkForm()
sshForm := s.getSSHForm()
// Create tabs
tabs := container.NewAppTabs(
container.NewTabItem("Connection", connectionForm),
container.NewTabItem("Network", networkForm),
container.NewTabItem("SSH", sshForm),
)
// Create save and cancel buttons
saveButton := widget.NewButton("Save", func() {
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
// validate preSharedKey if it added
if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
dialog.ShowError(fmt.Errorf("Invalid Pre-shared Key Value"), s.wSettings)
return
}
}
port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
if err != nil {
dialog.ShowError(errors.New("Invalid interface port"), s.wSettings)
return
}
iAdminURL := strings.TrimSpace(s.iAdminURL.Text)
iMngURL := strings.TrimSpace(s.iMngURL.Text)
defer s.wSettings.Close()
// Check if any settings have changed
if s.managementURL != iMngURL || s.preSharedKey != s.iPreSharedKey.Text ||
s.adminURL != iAdminURL || s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
s.interfaceName != s.iInterfaceName.Text || s.interfacePort != int(port) ||
s.networkMonitor != s.sNetworkMonitor.Checked ||
s.disableDNS != s.sDisableDNS.Checked ||
s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
s.blockLANAccess != s.sBlockLANAccess.Checked ||
s.enableSSHRoot != s.sEnableSSHRoot.Checked ||
s.enableSSHSFTP != s.sEnableSSHSFTP.Checked ||
s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked ||
s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked {
s.managementURL = iMngURL
s.preSharedKey = s.iPreSharedKey.Text
s.adminURL = iAdminURL
loginRequest := proto.LoginRequest{
ManagementUrl: iMngURL,
AdminURL: iAdminURL,
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
RosenpassPermissive: &s.sRosenpassPermissive.Checked,
InterfaceName: &s.iInterfaceName.Text,
WireguardPort: &port,
NetworkMonitor: &s.sNetworkMonitor.Checked,
DisableDns: &s.sDisableDNS.Checked,
DisableClientRoutes: &s.sDisableClientRoutes.Checked,
DisableServerRoutes: &s.sDisableServerRoutes.Checked,
BlockLanAccess: &s.sBlockLANAccess.Checked,
EnableSSHRoot: &s.sEnableSSHRoot.Checked,
EnableSSHSFTP: &s.sEnableSSHSFTP.Checked,
EnableSSHLocalPortForwarding: &s.sEnableSSHLocalPortForward.Checked,
EnableSSHRemotePortForwarding: &s.sEnableSSHRemotePortForward.Checked,
}
if s.iPreSharedKey.Text != censoredPreSharedKey {
loginRequest.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
if err := s.restartClient(&loginRequest); err != nil {
log.Errorf("restarting client connection: %v", err)
return
}
}
})
cancelButton := widget.NewButton("Cancel", func() {
s.wSettings.Close()
})
// Create button container
buttonContainer := container.NewHBox(
layout.NewSpacer(),
cancelButton,
saveButton,
)
// Return the complete layout with tabs and buttons
return container.NewBorder(nil, buttonContainer, nil, nil, tabs)
}
func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -828,6 +895,10 @@ func (s *serviceClient) getSrvConfig() {
s.disableClientRoutes = cfg.DisableClientRoutes
s.disableServerRoutes = cfg.DisableServerRoutes
s.blockLANAccess = cfg.BlockLanAccess
s.enableSSHRoot = cfg.EnableSSHRoot
s.enableSSHSFTP = cfg.EnableSSHSFTP
s.enableSSHLocalPortForward = cfg.EnableSSHLocalPortForwarding
s.enableSSHRemotePortForward = cfg.EnableSSHRemotePortForwarding
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
@@ -846,6 +917,10 @@ func (s *serviceClient) getSrvConfig() {
s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes)
s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes)
s.sBlockLANAccess.SetChecked(cfg.BlockLanAccess)
s.sEnableSSHRoot.SetChecked(cfg.EnableSSHRoot)
s.sEnableSSHSFTP.SetChecked(cfg.EnableSSHSFTP)
s.sEnableSSHLocalPortForward.SetChecked(cfg.EnableSSHLocalPortForwarding)
s.sEnableSSHRemotePortForward.SetChecked(cfg.EnableSSHRemotePortForwarding)
}
if s.mNotifications == nil {

4
go.mod
View File

@@ -74,11 +74,11 @@ require (
github.com/pion/stun/v2 v2.0.0
github.com/pion/transport/v3 v3.0.1
github.com/pion/turn/v3 v3.0.1
github.com/pkg/sftp v1.10.1
github.com/prometheus/client_golang v1.22.0
github.com/quic-go/quic-go v0.48.2
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
github.com/runletapp/go-console v0.0.0-20211204140000-27323a28410a
github.com/shirou/gopsutil/v3 v3.24.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
@@ -179,7 +179,6 @@ require (
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/iamacarpet/go-winpty v1.0.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -193,6 +192,7 @@ require (
github.com/kelseyhightower/envconfig v1.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/libdns/libdns v0.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
github.com/magiconair/properties v1.8.7 // indirect

7
go.sum
View File

@@ -156,7 +156,6 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0=
@@ -386,8 +385,6 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/iamacarpet/go-winpty v1.0.2 h1:jwPVTYrjAHZx6Mcm6K5i9G4opMp5TblEHH5EQCl/Gzw=
github.com/iamacarpet/go-winpty v1.0.2/go.mod h1:/GHKJicG/EVRQIK1IQikMYBakBkhj/3hTjLgdzYsmpI=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
@@ -428,6 +425,7 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -569,6 +567,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pkg/sftp v1.10.1 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -597,8 +596,6 @@ github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/runletapp/go-console v0.0.0-20211204140000-27323a28410a h1:1hh8CSomjZSJPk7AgHV8o33Su13bZby81PrC6pIvJqQ=
github.com/runletapp/go-console v0.0.0-20211204140000-27323a28410a/go.mod h1:9Y3jw1valnPKqsYSsBWxQNAuxqNSBuwd2ZEeElxgNUI=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.26.0
// protoc v3.21.12
// protoc v4.24.3
// source: management.proto
package proto
@@ -798,16 +798,20 @@ type Flags struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
RosenpassEnabled bool `protobuf:"varint,1,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"`
RosenpassPermissive bool `protobuf:"varint,2,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
ServerSSHAllowed bool `protobuf:"varint,3,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"`
DisableClientRoutes bool `protobuf:"varint,4,opt,name=disableClientRoutes,proto3" json:"disableClientRoutes,omitempty"`
DisableServerRoutes bool `protobuf:"varint,5,opt,name=disableServerRoutes,proto3" json:"disableServerRoutes,omitempty"`
DisableDNS bool `protobuf:"varint,6,opt,name=disableDNS,proto3" json:"disableDNS,omitempty"`
DisableFirewall bool `protobuf:"varint,7,opt,name=disableFirewall,proto3" json:"disableFirewall,omitempty"`
BlockLANAccess bool `protobuf:"varint,8,opt,name=blockLANAccess,proto3" json:"blockLANAccess,omitempty"`
BlockInbound bool `protobuf:"varint,9,opt,name=blockInbound,proto3" json:"blockInbound,omitempty"`
LazyConnectionEnabled bool `protobuf:"varint,10,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"`
RosenpassEnabled bool `protobuf:"varint,1,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"`
RosenpassPermissive bool `protobuf:"varint,2,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
ServerSSHAllowed bool `protobuf:"varint,3,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"`
DisableClientRoutes bool `protobuf:"varint,4,opt,name=disableClientRoutes,proto3" json:"disableClientRoutes,omitempty"`
DisableServerRoutes bool `protobuf:"varint,5,opt,name=disableServerRoutes,proto3" json:"disableServerRoutes,omitempty"`
DisableDNS bool `protobuf:"varint,6,opt,name=disableDNS,proto3" json:"disableDNS,omitempty"`
DisableFirewall bool `protobuf:"varint,7,opt,name=disableFirewall,proto3" json:"disableFirewall,omitempty"`
BlockLANAccess bool `protobuf:"varint,8,opt,name=blockLANAccess,proto3" json:"blockLANAccess,omitempty"`
BlockInbound bool `protobuf:"varint,9,opt,name=blockInbound,proto3" json:"blockInbound,omitempty"`
LazyConnectionEnabled bool `protobuf:"varint,10,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"`
EnableSSHRoot bool `protobuf:"varint,11,opt,name=enableSSHRoot,proto3" json:"enableSSHRoot,omitempty"`
EnableSSHSFTP bool `protobuf:"varint,12,opt,name=enableSSHSFTP,proto3" json:"enableSSHSFTP,omitempty"`
EnableSSHLocalPortForwarding bool `protobuf:"varint,13,opt,name=enableSSHLocalPortForwarding,proto3" json:"enableSSHLocalPortForwarding,omitempty"`
EnableSSHRemotePortForwarding bool `protobuf:"varint,14,opt,name=enableSSHRemotePortForwarding,proto3" json:"enableSSHRemotePortForwarding,omitempty"`
}
func (x *Flags) Reset() {
@@ -912,6 +916,34 @@ func (x *Flags) GetLazyConnectionEnabled() bool {
return false
}
func (x *Flags) GetEnableSSHRoot() bool {
if x != nil {
return x.EnableSSHRoot
}
return false
}
func (x *Flags) GetEnableSSHSFTP() bool {
if x != nil {
return x.EnableSSHSFTP
}
return false
}
func (x *Flags) GetEnableSSHLocalPortForwarding() bool {
if x != nil {
return x.EnableSSHLocalPortForwarding
}
return false
}
func (x *Flags) GetEnableSSHRemotePortForwarding() bool {
if x != nil {
return x.EnableSSHRemotePortForwarding
}
return false
}
// PeerSystemMeta is machine meta data like OS and version.
type PeerSystemMeta struct {
state protoimpl.MessageState
@@ -3413,7 +3445,7 @@ var file_management_proto_rawDesc = []byte{
0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12,
0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e,
0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65,
0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xc1, 0x03, 0x0a, 0x05,
0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x97, 0x05, 0x0a, 0x05,
0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
@@ -3441,425 +3473,439 @@ var file_management_proto_rawDesc = []byte{
0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a,
0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f,
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22,
0xf2, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65,
0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12,
0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f,
0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f,
0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a,
0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65,
0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56,
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72,
0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41,
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f,
0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18,
0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c,
0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f,
0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28,
0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65,
0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75,
0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69,
0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72,
0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d,
0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66,
0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66,
0x6c, 0x61, 0x67, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72,
0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69,
0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72,
0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65,
0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07,
0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76,
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22,
0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f,
0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12,
0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74,
0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18,
0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05,
0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f,
0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f,
0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22,
0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55,
0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a,
0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53,
0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b,
0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75,
0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12,
0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c,
0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e,
0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b,
0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a,
0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72,
0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53,
0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65,
0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12,
0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08,
0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64,
0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28,
0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65,
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c,
0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e,
0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x7d, 0x0a, 0x13, 0x50,
0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a,
0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73,
0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a,
0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x81, 0x02, 0x0a, 0x0a, 0x50,
0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72,
0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71,
0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48,
0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73,
0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67,
0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f,
0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79,
0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e,
0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0xb9,
0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a,
0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53,
0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a,
0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a,
0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74,
0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a,
0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65,
0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a,
0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12,
0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12,
0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x66,
0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72,
0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f,
0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52,
0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69,
0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70,
0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73,
0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69,
0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61,
0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61,
0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52,
0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61,
0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73,
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12,
0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74,
0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72,
0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28,
0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61,
0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12,
0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f,
0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53,
0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61,
0x72, 0x64, 0x69, 0x6e, 0x67, 0x22, 0xf2, 0x04, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79,
0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74,
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74,
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e,
0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c,
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53,
0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72,
0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65,
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56,
0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c,
0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b,
0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09,
0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65,
0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e,
0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73,
0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e,
0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74,
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66,
0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73,
0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39,
0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e,
0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c,
0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65,
0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c,
0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x0d, 0x4c,
0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d,
0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d,
0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a,
0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18,
0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b,
0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69,
0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73,
0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20,
0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72,
0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73,
0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05,
0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05,
0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66,
0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43,
0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a,
0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53,
0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61,
0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b,
0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b,
0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f,
0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50,
0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53,
0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35,
0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74,
0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12,
0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65,
0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64,
0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64,
0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01,
0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f,
0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x22, 0x81, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73,
0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72,
0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x49, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62,
0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b,
0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62,
0x4b, 0x65, 0x79, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74,
0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65,
0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50,
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48,
0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41,
0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41,
0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77,
0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x22, 0xb8, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e,
0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e,
0x74, 0x49, 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63,
0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e,
0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69,
0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12,
0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44,
0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50,
0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52,
0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73,
0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34,
0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c,
0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x22, 0xb9, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20,
0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70,
0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65,
0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33,
0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44,
0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65,
0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65,
0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65,
0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61,
0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20,
0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75,
0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73,
0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72,
0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f,
0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a,
0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f,
0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52,
0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73,
0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65,
0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x18,
0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70,
0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04,
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x49,
0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73,
0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73,
0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09,
0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76,
0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17,
0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69,
0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74,
0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70,
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a,
0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a,
0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64,
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76,
0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76,
0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xb8, 0x03, 0x0a, 0x0e, 0x50,
0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a,
0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x43, 0x6c, 0x69,
0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a,
0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44,
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63,
0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63,
0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45,
0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44,
0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41,
0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44,
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65,
0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f,
0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74,
0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a,
0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20,
0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c,
0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d,
0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44,
0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22,
0xed, 0x01, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79,
0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74,
0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69,
0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18,
0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64,
0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69,
0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09,
0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x22,
0xb4, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a,
0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65,
0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b,
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43,
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f,
0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c,
0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73,
0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73,
0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10,
0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c,
0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61,
0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d,
0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18,
0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18,
0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52,
0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72,
0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f,
0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a,
0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53,
0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79,
0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa7, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77,
0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49,
0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12,
0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44,
0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74,
0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74,
0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12,
0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f,
0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74,
0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44,
0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44,
0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65,
0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68,
0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50,
0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a,
0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e,
0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67,
0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74,
0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65,
0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72,
0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a,
0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41,
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a,
0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28,
0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70,
0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e,
0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79,
0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73,
0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12,
0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50,
0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63,
0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63,
0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a,
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01,
0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65,
0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72,
0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c,
0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74,
0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e,
0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f,
0x72, 0x74, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12,
0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10,
0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43,
0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05,
0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f,
0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54,
0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04,
0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x32, 0x90, 0x04, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05,
0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69,
0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45,
0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65,
0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a,
0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28,
0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a,
0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e,
0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75,
0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f,
0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55,
0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72,
0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62,
0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20,
0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d,
0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0xed, 0x01, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12,
0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12,
0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50,
0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12,
0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75,
0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73,
0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44,
0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a,
0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07,
0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52,
0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70,
0x52, 0x6f, 0x75, 0x74, 0x65, 0x22, 0xb4, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d,
0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70,
0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65,
0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52,
0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x22, 0x58, 0x0a, 0x0a,
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f,
0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61,
0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52,
0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65,
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79,
0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14,
0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43,
0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28,
0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a,
0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70,
0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e,
0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72,
0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69,
0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32,
0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65,
0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50,
0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa7, 0x02, 0x0a,
0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a,
0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50,
0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e,
0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65,
0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34,
0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e,
0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75,
0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74,
0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74,
0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f,
0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f,
0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f,
0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49,
0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10,
0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63,
0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69,
0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73,
0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a,
0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70,
0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00,
0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65,
0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74,
0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f,
0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12,
0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e,
0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20,
0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70,
0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e,
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49,
0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a,
0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08,
0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64,
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f,
0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50,
0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63,
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a,
0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52,
0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75,
0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74,
0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69,
0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f,
0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73,
0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11,
0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61,
0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72,
0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c,
0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x2a, 0x4c, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65,
0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e,
0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07,
0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03,
0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55,
0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69,
0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12,
0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65,
0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54,
0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x32, 0x90, 0x04, 0x0a,
0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47,
0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61,
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70,
0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63,
0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e,
0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a,
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30,
0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65,
0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74,
0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65,
0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43,
0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00,
0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f,
0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d,
0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d,
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79,
0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e,
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42,
0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
}
var (

View File

@@ -143,6 +143,11 @@ message Flags {
bool blockInbound = 9;
bool lazyConnectionEnabled = 10;
bool enableSSHRoot = 11;
bool enableSSHSFTP = 12;
bool enableSSHLocalPortForwarding = 13;
bool enableSSHRemotePortForwarding = 14;
}
// PeerSystemMeta is machine meta data like OS and version.