mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +00:00
Complete overhaul
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
74
client/cmd/ssh_exec_unix.go
Normal file
74
client/cmd/ssh_exec_unix.go
Normal 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
|
||||
}
|
||||
94
client/cmd/ssh_sftp_unix.go
Normal file
94
client/cmd/ssh_sftp_unix.go
Normal 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
|
||||
}
|
||||
94
client/cmd/ssh_sftp_windows.go
Normal file
94
client/cmd/ssh_sftp_windows.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
111
client/firewall/uspfilter/nat_stateful_test.go
Normal file
111
client/firewall/uspfilter/nat_stateful_test.go
Normal 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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
359
client/internal/engine_ssh.go
Normal file
359
client/internal/engine_ssh.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
712
client/ssh/client/client.go
Normal 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
|
||||
}
|
||||
468
client/ssh/client/client_test.go
Normal file
468
client/ssh/client/client_test.go
Normal 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"
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
556
client/ssh/config/manager.go
Normal file
556
client/ssh/config/manager.go
Normal 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
|
||||
}
|
||||
|
||||
364
client/ssh/config/manager_test.go
Normal file
364
client/ssh/config/manager_test.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
298
client/ssh/server/command_execution.go
Normal file
298
client/ssh/server/command_execution.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
262
client/ssh/server/command_execution_unix.go
Normal file
262
client/ssh/server/command_execution_unix.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
403
client/ssh/server/command_execution_windows.go
Normal file
403
client/ssh/server/command_execution_windows.go
Normal 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)
|
||||
}
|
||||
}
|
||||
691
client/ssh/server/compatibility_test.go
Normal file
691
client/ssh/server/compatibility_test.go
Normal 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")
|
||||
}
|
||||
226
client/ssh/server/executor_test.go
Normal file
226
client/ssh/server/executor_test.go
Normal 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")
|
||||
}
|
||||
252
client/ssh/server/executor_unix.go
Normal file
252
client/ssh/server/executor_unix.go
Normal 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
|
||||
}
|
||||
594
client/ssh/server/executor_windows.go
Normal file
594
client/ssh/server/executor_windows.go
Normal 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("as)),
|
||||
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")
|
||||
}
|
||||
411
client/ssh/server/port_forwarding.go
Normal file
411
client/ssh/server/port_forwarding.go
Normal 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
555
client/ssh/server/server.go
Normal 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)
|
||||
}
|
||||
374
client/ssh/server/server_config_test.go
Normal file
374
client/ssh/server/server_config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
145
client/ssh/server/session_handlers.go
Normal file
145
client/ssh/server/session_handlers.go
Normal 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
81
client/ssh/server/sftp.go
Normal 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
|
||||
}
|
||||
215
client/ssh/server/sftp_test.go
Normal file
215
client/ssh/server/sftp_test.go
Normal 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")
|
||||
}
|
||||
71
client/ssh/server/sftp_unix.go
Normal file
71
client/ssh/server/sftp_unix.go
Normal 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
|
||||
}
|
||||
85
client/ssh/server/sftp_windows.go
Normal file
85
client/ssh/server/sftp_windows.go
Normal 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
175
client/ssh/server/shell.go
Normal 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),
|
||||
}
|
||||
}
|
||||
168
client/ssh/server/socket_filter_linux.go
Normal file
168
client/ssh/server/socket_filter_linux.go
Normal 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
|
||||
}
|
||||
19
client/ssh/server/socket_filter_nonlinux.go
Normal file
19
client/ssh/server/socket_filter_nonlinux.go
Normal 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
|
||||
}
|
||||
48
client/ssh/server/socket_filter_nonlinux_test.go
Normal file
48
client/ssh/server/socket_filter_nonlinux_test.go
Normal 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")
|
||||
}
|
||||
160
client/ssh/server/socket_filter_test.go
Normal file
160
client/ssh/server/socket_filter_test.go
Normal 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
43
client/ssh/server/test.go
Normal 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 ""
|
||||
}
|
||||
430
client/ssh/server/user_utils.go
Normal file
430
client/ssh/server/user_utils.go
Normal 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
|
||||
}
|
||||
836
client/ssh/server/user_utils_test.go
Normal file
836
client/ssh/server/user_utils_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
245
client/ssh/server/userswitching_unix.go
Normal file
245
client/ssh/server/userswitching_unix.go
Normal 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
|
||||
}
|
||||
290
client/ssh/server/userswitching_windows.go
Normal file
290
client/ssh/server/userswitching_windows.go
Normal 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
|
||||
}
|
||||
466
client/ssh/server/winpty/conpty.go
Normal file
466
client/ssh/server/winpty/conpty.go
Normal 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)
|
||||
}
|
||||
286
client/ssh/server/winpty/conpty_test.go
Normal file
286
client/ssh/server/winpty/conpty_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
go.mod
@@ -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
7
go.sum
@@ -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=
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user