mirror of
https://github.com/netbirdio/netbird.git
synced 2025-06-21 10:18:50 +02:00
Refactor ssh server and client
This commit is contained in:
parent
f3e657a345
commit
f7d8bba0c1
@ -3,9 +3,11 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
@ -17,43 +19,34 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
user = "root"
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
host string
|
||||
command string
|
||||
)
|
||||
|
||||
var sshCmd = &cobra.Command{
|
||||
Use: "ssh [user@]host",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("requires a host argument")
|
||||
}
|
||||
Use: "ssh [user@]host [command]",
|
||||
Short: "Connect to a NetBird peer via SSH",
|
||||
Long: `Connect to a NetBird peer using SSH.
|
||||
|
||||
split := strings.Split(args[0], "@")
|
||||
if len(split) == 2 {
|
||||
user = split[0]
|
||||
host = split[1]
|
||||
} else {
|
||||
host = args[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Short: "connect to a remote SSH server",
|
||||
Examples:
|
||||
netbird ssh peer-hostname
|
||||
netbird ssh user@peer-hostname
|
||||
netbird ssh peer-hostname --login myuser
|
||||
netbird ssh peer-hostname -p 22022
|
||||
netbird ssh peer-hostname ls -la
|
||||
netbird ssh peer-hostname whoami`,
|
||||
DisableFlagParsing: true,
|
||||
Args: validateSSHArgsWithoutFlagParsing,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
SetFlagsFromEnvVars(cmd)
|
||||
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
|
||||
err := util.InitLog(logLevel, "console")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed initializing log %v", err)
|
||||
}
|
||||
|
||||
if !util.IsAdmin() {
|
||||
cmd.Printf("error: you must have Administrator privileges to run this command\n")
|
||||
return nil
|
||||
if err := util.InitLog(logLevel, "console"); err != nil {
|
||||
return fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
|
||||
ctx := internal.CtxInitState(cmd.Context())
|
||||
@ -62,7 +55,7 @@ var sshCmd = &cobra.Command{
|
||||
ConfigPath: configPath,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("update config: %w", err)
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
@ -70,7 +63,6 @@ var sshCmd = &cobra.Command{
|
||||
sshctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
// blocking
|
||||
if err := runSSH(sshctx, host, []byte(config.SSHKey), cmd); err != nil {
|
||||
cmd.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@ -88,31 +80,124 @@ var sshCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func runSSH(ctx context.Context, addr string, pemKey []byte, cmd *cobra.Command) error {
|
||||
c, err := nbssh.DialWithKey(fmt.Sprintf("%s:%d", addr, port), user, pemKey)
|
||||
func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("host argument required")
|
||||
}
|
||||
|
||||
// Reset globals to defaults
|
||||
port = nbssh.DefaultSSHPort
|
||||
username = ""
|
||||
host = ""
|
||||
command = ""
|
||||
|
||||
// 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 {
|
||||
cmd.Printf("Error: %v\n", err)
|
||||
cmd.Printf("Couldn't connect. Please check the connection status or if the ssh server is enabled on the other peer" +
|
||||
"\nYou can verify the connection by running:\n\n" +
|
||||
" netbird status\n\n")
|
||||
return err
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Get the remaining args (hostname and command)
|
||||
remaining := fs.Args()
|
||||
if len(remaining) < 1 {
|
||||
return errors.New("host argument required")
|
||||
}
|
||||
|
||||
// Set parsed values
|
||||
port = *portFlag
|
||||
if *userFlag != "" {
|
||||
username = *userFlag
|
||||
} else if *loginFlag != "" {
|
||||
username = *loginFlag
|
||||
}
|
||||
|
||||
return parseHostnameAndCommand(remaining)
|
||||
}
|
||||
|
||||
func parseHostnameAndCommand(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("host argument required")
|
||||
}
|
||||
|
||||
// Parse hostname (possibly with user@host format)
|
||||
arg := args[0]
|
||||
if strings.Contains(arg, "@") {
|
||||
parts := strings.SplitN(arg, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return errors.New("invalid user@host format")
|
||||
}
|
||||
// Only use username from host if not already set by flags
|
||||
if username == "" {
|
||||
username = parts[0]
|
||||
}
|
||||
host = parts[1]
|
||||
} else {
|
||||
host = arg
|
||||
}
|
||||
|
||||
// Set default username if none provided
|
||||
if username == "" {
|
||||
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||
username = sudoUser
|
||||
} else if currentUser, err := user.Current(); err == nil {
|
||||
username = currentUser.Username
|
||||
} else {
|
||||
username = "root"
|
||||
}
|
||||
}
|
||||
|
||||
// Everything after hostname becomes the command
|
||||
if len(args) > 1 {
|
||||
command = strings.Join(args[1:], " ")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSSH(ctx context.Context, addr string, pemKey []byte, cmd *cobra.Command) error {
|
||||
target := fmt.Sprintf("%s:%d", addr, port)
|
||||
c, err := nbssh.DialWithKey(ctx, target, username, pemKey)
|
||||
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")
|
||||
return fmt.Errorf("dial %s: %w", target, err)
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
err = c.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
err = c.OpenTerminal()
|
||||
if err != nil {
|
||||
return err
|
||||
if command != "" {
|
||||
if err := c.ExecuteCommandWithIO(ctx, command); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := c.OpenTerminal(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
sshCmd.PersistentFlags().IntVarP(&port, "port", "p", nbssh.DefaultSSHPort, "Sets remote SSH port. Defaults to "+fmt.Sprint(nbssh.DefaultSSHPort))
|
||||
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)")
|
||||
}
|
||||
|
342
client/cmd/ssh_test.go
Normal file
342
client/cmd/ssh_test.go
Normal file
@ -0,0 +1,342 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSHCommand_FlagParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedHost string
|
||||
expectedUser string
|
||||
expectedPort int
|
||||
expectedCmd string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "basic host",
|
||||
args: []string{"hostname"},
|
||||
expectedHost: "hostname",
|
||||
expectedUser: "",
|
||||
expectedPort: 22022,
|
||||
expectedCmd: "",
|
||||
},
|
||||
{
|
||||
name: "user@host format",
|
||||
args: []string{"user@hostname"},
|
||||
expectedHost: "hostname",
|
||||
expectedUser: "user",
|
||||
expectedPort: 22022,
|
||||
expectedCmd: "",
|
||||
},
|
||||
{
|
||||
name: "host with command",
|
||||
args: []string{"hostname", "echo", "hello"},
|
||||
expectedHost: "hostname",
|
||||
expectedUser: "",
|
||||
expectedPort: 22022,
|
||||
expectedCmd: "echo hello",
|
||||
},
|
||||
{
|
||||
name: "command with flags should be preserved",
|
||||
args: []string{"hostname", "ls", "-la", "/tmp"},
|
||||
expectedHost: "hostname",
|
||||
expectedUser: "",
|
||||
expectedPort: 22022,
|
||||
expectedCmd: "ls -la /tmp",
|
||||
},
|
||||
{
|
||||
name: "double dash separator",
|
||||
args: []string{"hostname", "--", "ls", "-la"},
|
||||
expectedHost: "hostname",
|
||||
expectedUser: "",
|
||||
expectedPort: 22022,
|
||||
expectedCmd: "-- ls -la",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset global variables
|
||||
host = ""
|
||||
username = ""
|
||||
port = 22022
|
||||
command = ""
|
||||
|
||||
// Mock command for testing
|
||||
cmd := sshCmd
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedHost, host, "host mismatch")
|
||||
if tt.expectedUser != "" {
|
||||
assert.Equal(t, tt.expectedUser, username, "username mismatch")
|
||||
}
|
||||
assert.Equal(t, tt.expectedPort, port, "port mismatch")
|
||||
assert.Equal(t, tt.expectedCmd, command, "command mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand_FlagConflictPrevention(t *testing.T) {
|
||||
// Test that SSH flags don't conflict with command flags
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedCmd string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ls with -la flags",
|
||||
args: []string{"hostname", "ls", "-la"},
|
||||
expectedCmd: "ls -la",
|
||||
description: "ls flags should be passed to remote command",
|
||||
},
|
||||
{
|
||||
name: "grep with -r flag",
|
||||
args: []string{"hostname", "grep", "-r", "pattern", "/path"},
|
||||
expectedCmd: "grep -r pattern /path",
|
||||
description: "grep flags should be passed to remote command",
|
||||
},
|
||||
{
|
||||
name: "ps with aux flags",
|
||||
args: []string{"hostname", "ps", "aux"},
|
||||
expectedCmd: "ps aux",
|
||||
description: "ps flags should be passed to remote command",
|
||||
},
|
||||
{
|
||||
name: "command with double dash",
|
||||
args: []string{"hostname", "--", "ls", "-la"},
|
||||
expectedCmd: "-- ls -la",
|
||||
description: "double dash should be preserved in command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset global variables
|
||||
host = ""
|
||||
username = ""
|
||||
port = 22022
|
||||
command = ""
|
||||
|
||||
cmd := sshCmd
|
||||
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedCmd, command, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand_NonInteractiveExecution(t *testing.T) {
|
||||
// Test that commands with arguments should execute the command and exit,
|
||||
// not drop to an interactive shell
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedCmd string
|
||||
shouldExit bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ls command should execute and exit",
|
||||
args: []string{"hostname", "ls"},
|
||||
expectedCmd: "ls",
|
||||
shouldExit: true,
|
||||
description: "ls command should execute and exit, not drop to shell",
|
||||
},
|
||||
{
|
||||
name: "ls with flags should execute and exit",
|
||||
args: []string{"hostname", "ls", "-la"},
|
||||
expectedCmd: "ls -la",
|
||||
shouldExit: true,
|
||||
description: "ls with flags should execute and exit, not drop to shell",
|
||||
},
|
||||
{
|
||||
name: "pwd command should execute and exit",
|
||||
args: []string{"hostname", "pwd"},
|
||||
expectedCmd: "pwd",
|
||||
shouldExit: true,
|
||||
description: "pwd command should execute and exit, not drop to shell",
|
||||
},
|
||||
{
|
||||
name: "echo command should execute and exit",
|
||||
args: []string{"hostname", "echo", "hello"},
|
||||
expectedCmd: "echo hello",
|
||||
shouldExit: true,
|
||||
description: "echo command should execute and exit, not drop to shell",
|
||||
},
|
||||
{
|
||||
name: "no command should open shell",
|
||||
args: []string{"hostname"},
|
||||
expectedCmd: "",
|
||||
shouldExit: false,
|
||||
description: "no command should open interactive shell",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset global variables
|
||||
host = ""
|
||||
username = ""
|
||||
port = 22022
|
||||
command = ""
|
||||
|
||||
cmd := sshCmd
|
||||
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedCmd, command, tt.description)
|
||||
|
||||
// When command is present, it should execute the command and exit
|
||||
// When command is empty, it should open interactive shell
|
||||
hasCommand := command != ""
|
||||
assert.Equal(t, tt.shouldExit, hasCommand, "Command presence should match expected behavior")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand_FlagHandling(t *testing.T) {
|
||||
// Test that flags after hostname are not parsed by netbird but passed to SSH command
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedHost string
|
||||
expectedCmd string
|
||||
expectError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ls with -la flag should not be parsed by netbird",
|
||||
args: []string{"debian2", "ls", "-la"},
|
||||
expectedHost: "debian2",
|
||||
expectedCmd: "ls -la",
|
||||
expectError: false,
|
||||
description: "ls -la should be passed as SSH command, not parsed as netbird flags",
|
||||
},
|
||||
{
|
||||
name: "command with netbird-like flags should be passed through",
|
||||
args: []string{"hostname", "echo", "--help"},
|
||||
expectedHost: "hostname",
|
||||
expectedCmd: "echo --help",
|
||||
expectError: false,
|
||||
description: "--help should be passed to echo, not parsed by netbird",
|
||||
},
|
||||
{
|
||||
name: "command with -p flag should not conflict with SSH port flag",
|
||||
args: []string{"hostname", "ps", "-p", "1234"},
|
||||
expectedHost: "hostname",
|
||||
expectedCmd: "ps -p 1234",
|
||||
expectError: false,
|
||||
description: "ps -p should be passed to ps command, not parsed as port",
|
||||
},
|
||||
{
|
||||
name: "tar with flags should be passed through",
|
||||
args: []string{"hostname", "tar", "-czf", "backup.tar.gz", "/home"},
|
||||
expectedHost: "hostname",
|
||||
expectedCmd: "tar -czf backup.tar.gz /home",
|
||||
expectError: false,
|
||||
description: "tar flags should be passed to tar command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset global variables
|
||||
host = ""
|
||||
username = ""
|
||||
port = 22022
|
||||
command = ""
|
||||
|
||||
cmd := sshCmd
|
||||
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedHost, host, "host mismatch")
|
||||
assert.Equal(t, tt.expectedCmd, command, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommand_RegressionFlagParsing(t *testing.T) {
|
||||
// Regression test for the specific issue: "sudo ./netbird ssh debian2 ls -la"
|
||||
// should not parse -la as netbird flags but pass them to the SSH command
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedHost string
|
||||
expectedCmd string
|
||||
expectError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "original issue: ls -la should be preserved",
|
||||
args: []string{"debian2", "ls", "-la"},
|
||||
expectedHost: "debian2",
|
||||
expectedCmd: "ls -la",
|
||||
expectError: false,
|
||||
description: "The original failing case should now work",
|
||||
},
|
||||
{
|
||||
name: "ls -l should be preserved",
|
||||
args: []string{"hostname", "ls", "-l"},
|
||||
expectedHost: "hostname",
|
||||
expectedCmd: "ls -l",
|
||||
expectError: false,
|
||||
description: "Single letter flags should be preserved",
|
||||
},
|
||||
{
|
||||
name: "SSH port flag should work",
|
||||
args: []string{"-p", "2222", "hostname", "ls", "-la"},
|
||||
expectedHost: "hostname",
|
||||
expectedCmd: "ls -la",
|
||||
expectError: false,
|
||||
description: "SSH -p flag should be parsed, command flags preserved",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset global variables
|
||||
host = ""
|
||||
username = ""
|
||||
port = 22022
|
||||
command = ""
|
||||
|
||||
cmd := sshCmd
|
||||
err := validateSSHArgsWithoutFlagParsing(cmd, tt.args)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedHost, host, "host mismatch")
|
||||
assert.Equal(t, tt.expectedCmd, command, tt.description)
|
||||
|
||||
// Check port for the test case with -p flag
|
||||
if len(tt.args) > 0 && tt.args[0] == "-p" {
|
||||
assert.Equal(t, 2222, port, "port should be parsed from -p flag")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -7,7 +7,6 @@ import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
@ -77,6 +76,14 @@ 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
|
||||
@ -172,8 +179,7 @@ type Engine struct {
|
||||
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServerFunc func(hostKeyPEM []byte, addr string) (nbssh.Server, error)
|
||||
sshServer nbssh.Server
|
||||
sshServer sshServer
|
||||
|
||||
statusRecorder *peer.Status
|
||||
peerConnDispatcher *dispatcher.ConnectionDispatcher
|
||||
@ -236,7 +242,6 @@ func NewEngine(
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
sshServerFunc: nbssh.DefaultSSHServer,
|
||||
statusRecorder: statusRecorder,
|
||||
checks: checks,
|
||||
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
|
||||
@ -642,7 +647,7 @@ func (e *Engine) removeAllPeers() error {
|
||||
func (e *Engine) removePeer(peerKey string) error {
|
||||
log.Debugf("removing peer from engine %s", peerKey)
|
||||
|
||||
if !isNil(e.sshServer) {
|
||||
if e.sshServer != nil {
|
||||
e.sshServer.RemoveAuthorizedKey(peerKey)
|
||||
}
|
||||
|
||||
@ -798,65 +803,75 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNil(server nbssh.Server) bool {
|
||||
return server == nil || reflect.ValueOf(server).IsNil()
|
||||
}
|
||||
|
||||
func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error {
|
||||
if e.config.BlockInbound {
|
||||
log.Infof("SSH server is disabled because inbound connections are blocked")
|
||||
return nil
|
||||
log.Info("SSH server is disabled because inbound connections are blocked")
|
||||
return e.stopSSHServer()
|
||||
}
|
||||
|
||||
if !e.config.ServerSSHAllowed {
|
||||
log.Info("SSH server is not enabled")
|
||||
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
|
||||
}
|
||||
|
||||
if sshConf.GetSshEnabled() {
|
||||
if runtime.GOOS == "windows" {
|
||||
log.Warnf("running SSH server on %s is not supported", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
// start SSH server if it wasn't running
|
||||
if isNil(e.sshServer) {
|
||||
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)
|
||||
}
|
||||
// nil sshServer means it has not yet been started
|
||||
var err error
|
||||
e.sshServer, err = e.sshServerFunc(e.config.SSHKey, listenAddr)
|
||||
return e.startSSHServer()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create ssh server: %w", err)
|
||||
}
|
||||
go func() {
|
||||
// blocking
|
||||
err = e.sshServer.Start()
|
||||
if err != nil {
|
||||
// will throw error when we stop it even if it is a graceful stop
|
||||
log.Debugf("stopped SSH server with error %v", err)
|
||||
}
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
e.sshServer = nil
|
||||
log.Infof("stopped SSH server")
|
||||
}()
|
||||
} else {
|
||||
log.Debugf("SSH server is already running")
|
||||
}
|
||||
} else if !isNil(e.sshServer) {
|
||||
// Disable SSH server request, so stop it if it was running
|
||||
err := e.sshServer.Stop()
|
||||
if err != nil {
|
||||
log.Warnf("failed to stop SSH server %v", err)
|
||||
}
|
||||
e.sshServer = nil
|
||||
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")
|
||||
@ -1068,7 +1083,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
e.statusRecorder.FinishPeerListModifications()
|
||||
|
||||
// update SSHServer by adding remote peer SSH keys
|
||||
if !isNil(e.sshServer) {
|
||||
if e.sshServer != nil {
|
||||
for _, config := range networkMap.GetRemotePeers() {
|
||||
if config.GetSshConfig() != nil && config.GetSshConfig().GetSshPubKey() != nil {
|
||||
err := e.sshServer.AddAuthorizedKey(config.WgPubKey, string(config.GetSshConfig().GetSshPubKey()))
|
||||
@ -1470,7 +1485,7 @@ func (e *Engine) close() {
|
||||
e.statusRecorder.SetWgIface(nil)
|
||||
}
|
||||
|
||||
if !isNil(e.sshServer) {
|
||||
if e.sshServer != nil {
|
||||
err := e.sshServer.Stop()
|
||||
if err != nil {
|
||||
log.Warnf("failed stopping the SSH server: %v", err)
|
||||
|
@ -40,7 +40,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
mgmt "github.com/netbirdio/netbird/management/client"
|
||||
@ -229,31 +228,6 @@ func TestEngine_SSH(t *testing.T) {
|
||||
UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
|
||||
}
|
||||
|
||||
var sshKeysAdded []string
|
||||
var sshPeersRemoved []string
|
||||
|
||||
sshCtx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
engine.sshServerFunc = func(hostKeyPEM []byte, addr string) (ssh.Server, error) {
|
||||
return &ssh.MockServer{
|
||||
Ctx: sshCtx,
|
||||
StopFunc: func() error {
|
||||
cancel()
|
||||
return nil
|
||||
},
|
||||
StartFunc: func() error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
AddAuthorizedKeyFunc: func(peer, newKey string) error {
|
||||
sshKeysAdded = append(sshKeysAdded, newKey)
|
||||
return nil
|
||||
},
|
||||
RemoveAuthorizedKeyFunc: func(peer string) {
|
||||
sshPeersRemoved = append(sshPeersRemoved, peer)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
err = engine.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -305,7 +279,6 @@ func TestEngine_SSH(t *testing.T) {
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
assert.NotNil(t, engine.sshServer)
|
||||
assert.Contains(t, sshKeysAdded, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ")
|
||||
|
||||
// now remove peer
|
||||
networkMap = &mgmtProto.NetworkMap{
|
||||
@ -321,7 +294,6 @@ func TestEngine_SSH(t *testing.T) {
|
||||
|
||||
// time.Sleep(250 * time.Millisecond)
|
||||
assert.NotNil(t, engine.sshServer)
|
||||
assert.Contains(t, sshPeersRemoved, "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=")
|
||||
|
||||
// now disable SSH server
|
||||
networkMap = &mgmtProto.NetworkMap{
|
||||
@ -338,7 +310,67 @@ func TestEngine_SSH(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.Nil(t, engine.sshServer)
|
||||
}
|
||||
|
||||
func TestEngine_SSHUpdateLogic(t *testing.T) {
|
||||
// Test that SSH server start/stop logic works based on config
|
||||
engine := &Engine{
|
||||
config: &EngineConfig{
|
||||
ServerSSHAllowed: false, // Start with SSH disabled
|
||||
},
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
// Test SSH disabled config
|
||||
sshConfig := &mgmtProto.SSHConfig{SshEnabled: false}
|
||||
err := engine.updateSSH(sshConfig)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, engine.sshServer)
|
||||
|
||||
// Test inbound blocked
|
||||
engine.config.BlockInbound = true
|
||||
err = engine.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, engine.sshServer)
|
||||
engine.config.BlockInbound = false
|
||||
|
||||
// Test with server SSH not allowed
|
||||
err = engine.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, engine.sshServer)
|
||||
}
|
||||
|
||||
func TestEngine_SSHServerConsistency(t *testing.T) {
|
||||
|
||||
t.Run("server set only on successful creation", func(t *testing.T) {
|
||||
engine := &Engine{
|
||||
config: &EngineConfig{
|
||||
ServerSSHAllowed: true,
|
||||
SSHKey: []byte("test-key"),
|
||||
},
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
engine.wgInterface = nil
|
||||
|
||||
err := engine.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, engine.sshServer)
|
||||
})
|
||||
|
||||
t.Run("cleanup handles nil gracefully", func(t *testing.T) {
|
||||
engine := &Engine{
|
||||
config: &EngineConfig{
|
||||
ServerSSHAllowed: false,
|
||||
},
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := engine.stopSSHServer()
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, engine.sshServer)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
@ -10,106 +12,265 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Client wraps crypto/ssh Client to simplify usage
|
||||
// Client wraps crypto/ssh Client for simplified SSH operations
|
||||
type Client struct {
|
||||
client *ssh.Client
|
||||
client *ssh.Client
|
||||
terminalState *term.State
|
||||
terminalFd int
|
||||
// Windows-specific console state
|
||||
windowsStdoutMode uint32
|
||||
windowsStdinMode uint32
|
||||
}
|
||||
|
||||
// Close closes the wrapped SSH Client
|
||||
// Close terminates the SSH connection
|
||||
func (c *Client) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
// OpenTerminal starts an interactive terminal session with the remote SSH server
|
||||
func (c *Client) OpenTerminal() error {
|
||||
// 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("failed to open new session: %v", err)
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := session.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = session.Close()
|
||||
}()
|
||||
|
||||
fd := int(os.Stdout.Fd())
|
||||
state, err := term.MakeRaw(fd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run raw terminal: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
err := term.Restore(fd, state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
w, h, err := term.GetSize(fd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("terminal get size: %s", err)
|
||||
if err := c.setupTerminalMode(ctx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
c.setupSessionIO(session)
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
return fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
|
||||
terminal := os.Getenv("TERM")
|
||||
if terminal == "" {
|
||||
terminal = "xterm-256color"
|
||||
}
|
||||
if err := session.RequestPty(terminal, h, w, modes); err != nil {
|
||||
return fmt.Errorf("failed requesting pty session with xterm: %s", 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
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
return fmt.Errorf("failed to start login shell on the remote host: %s", err)
|
||||
// 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
|
||||
}
|
||||
|
||||
if err := session.Wait(); err != nil {
|
||||
if e, ok := err.(*ssh.ExitError); ok {
|
||||
if e.ExitStatus() == 130 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed running SSH session: %s", err)
|
||||
var e *ssh.ExitError
|
||||
if !errors.As(err, &e) {
|
||||
// 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
|
||||
return nil
|
||||
}
|
||||
|
||||
// DialWithKey connects to the remote SSH server with a provided private key file (PEM).
|
||||
func DialWithKey(addr, user string, privateKey []byte) (*Client, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(privateKey)
|
||||
// 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
|
||||
if !errors.As(err, &e) {
|
||||
// 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
|
||||
// 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)
|
||||
return nil
|
||||
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)
|
||||
return nil
|
||||
case err := <-done:
|
||||
return c.handleCommandError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleCommandError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var e *ssh.ExitError
|
||||
if !errors.As(err, &e) {
|
||||
// Only return actual errors (not exit status errors)
|
||||
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
|
||||
// The command ran successfully, it just returned a non-zero exit code
|
||||
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: 5 * time.Second,
|
||||
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("tcp", addr, config)
|
||||
return Dial(ctx, "tcp", addr, config)
|
||||
}
|
||||
|
||||
// Dial connects to the remote SSH server.
|
||||
func Dial(network, addr string, config *ssh.ClientConfig) (*Client, error) {
|
||||
client, err := ssh.Dial(network, 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, err
|
||||
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
|
||||
|
1227
client/ssh/client_test.go
Normal file
1227
client/ssh/client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
@ -15,36 +16,91 @@ func isRoot() bool {
|
||||
return os.Geteuid() == 0
|
||||
}
|
||||
|
||||
func getLoginCmd(user string, remoteAddr net.Addr) (loginPath string, args []string, err error) {
|
||||
if !isRoot() {
|
||||
shell := getUserShell(user)
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
return shell, []string{"-l"}, nil
|
||||
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
|
||||
}
|
||||
|
||||
loginPath, err = exec.LookPath("login")
|
||||
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, err
|
||||
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, err
|
||||
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", user, "-p"}, nil
|
||||
return loginPath, []string{"-f", username, "-p"}, nil
|
||||
}
|
||||
return loginPath, []string{"-f", user, "-h", addrPort.Addr().String(), "-p"}, nil
|
||||
case "darwin":
|
||||
return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), user}, nil
|
||||
case "freebsd":
|
||||
return loginPath, []string{"-f", user, "-h", addrPort.Addr().String(), "-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,14 +0,0 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package ssh
|
||||
|
||||
import "os/user"
|
||||
|
||||
func userNameLookup(username string) (*user.User, error) {
|
||||
if username == "" || (username == "root" && !isRoot()) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
return user.Lookup(username)
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func userNameLookup(username string) (*user.User, error) {
|
||||
if username == "" || (username == "root" && !isRoot()) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
var userObject *user.User
|
||||
userObject, err := user.Lookup(username)
|
||||
if err != nil && err.Error() == user.UnknownUserError(username).Error() {
|
||||
return idUserNameLookup(username)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userObject, nil
|
||||
}
|
||||
|
||||
func idUserNameLookup(username string) (*user.User, error) {
|
||||
cmd := exec.Command("id", "-P", username)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while retrieving user with id -P command, error: %v", err)
|
||||
}
|
||||
colon := ":"
|
||||
|
||||
if !bytes.Contains(out, []byte(username+colon)) {
|
||||
return nil, fmt.Errorf("unable to find user in returned string")
|
||||
}
|
||||
// netbird:********:501:20::0:0:netbird:/Users/netbird:/bin/zsh
|
||||
parts := strings.SplitN(string(out), colon, 10)
|
||||
userObject := &user.User{
|
||||
Username: parts[0],
|
||||
Uid: parts[2],
|
||||
Gid: parts[3],
|
||||
Name: parts[7],
|
||||
HomeDir: parts[8],
|
||||
}
|
||||
return userObject, nil
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@ -14,100 +19,122 @@ import (
|
||||
|
||||
"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
|
||||
|
||||
// TerminalTimeout is the timeout for terminal session to be ready
|
||||
const TerminalTimeout = 10 * time.Second
|
||||
// Error message constants
|
||||
const (
|
||||
errWriteSession = "write session error: %v"
|
||||
errExitSession = "exit session error: %v"
|
||||
defaultShell = "/bin/sh"
|
||||
|
||||
// TerminalBackoffDelay is the delay between terminal session readiness checks
|
||||
const TerminalBackoffDelay = 500 * time.Millisecond
|
||||
// Windows shell executables
|
||||
cmdExe = "cmd.exe"
|
||||
powershellExe = "powershell.exe"
|
||||
pwshExe = "pwsh.exe"
|
||||
|
||||
// DefaultSSHServer is a function that creates DefaultServer
|
||||
func DefaultSSHServer(hostKeyPEM []byte, addr string) (Server, error) {
|
||||
return newDefaultServer(hostKeyPEM, addr)
|
||||
}
|
||||
// Shell detection strings
|
||||
powershellName = "powershell"
|
||||
pwshName = "pwsh"
|
||||
)
|
||||
|
||||
// Server is an interface of SSH server
|
||||
type Server interface {
|
||||
// Stop stops SSH server.
|
||||
Stop() error
|
||||
// Start starts SSH server. Blocking
|
||||
Start() error
|
||||
// RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys
|
||||
RemoveAuthorizedKey(peer string)
|
||||
// AddAuthorizedKey add a given peer key to server authorized keys
|
||||
AddAuthorizedKey(peer, newKey string) error
|
||||
}
|
||||
|
||||
// DefaultServer is the embedded NetBird SSH server
|
||||
type DefaultServer struct {
|
||||
listener net.Listener
|
||||
// authorizedKeys is ssh pub key indexed by peer WireGuard public key
|
||||
authorizedKeys map[string]ssh.PublicKey
|
||||
mu sync.Mutex
|
||||
hostKeyPEM []byte
|
||||
sessions []ssh.Session
|
||||
}
|
||||
|
||||
// newDefaultServer creates new server with provided host key
|
||||
func newDefaultServer(hostKeyPEM []byte, addr string) (*DefaultServer, error) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 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>"
|
||||
}
|
||||
allowedKeys := make(map[string]ssh.PublicKey)
|
||||
return &DefaultServer{listener: ln, mu: sync.Mutex{}, hostKeyPEM: hostKeyPEM, authorizedKeys: allowedKeys, sessions: make([]ssh.Session, 0)}, nil
|
||||
if len(cmd) == 1 {
|
||||
return cmd[0]
|
||||
}
|
||||
return fmt.Sprintf("%s [%d args]", cmd[0], len(cmd)-1)
|
||||
}
|
||||
|
||||
// RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys
|
||||
func (srv *DefaultServer) RemoveAuthorizedKey(peer string) {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
|
||||
delete(srv.authorizedKeys, peer)
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// AddAuthorizedKey add a given peer key to server authorized keys
|
||||
func (srv *DefaultServer) AddAuthorizedKey(peer, newKey string) error {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
// 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 err
|
||||
return fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
|
||||
srv.authorizedKeys[peer] = parsedKey
|
||||
s.authorizedKeys[peer] = parsedKey
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops SSH server.
|
||||
func (srv *DefaultServer) Stop() error {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
err := srv.listener.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range srv.sessions {
|
||||
err := session.Close()
|
||||
if err != nil {
|
||||
log.Warnf("failed closing SSH session from %v", err)
|
||||
}
|
||||
// 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 (srv *DefaultServer) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
func (s *Server) publicKeyHandler(_ ssh.Context, key ssh.PublicKey) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, allowed := range srv.authorizedKeys {
|
||||
for _, allowed := range s.authorizedKeys {
|
||||
if ssh.KeysEqual(allowed, key) {
|
||||
return true
|
||||
}
|
||||
@ -132,147 +159,651 @@ func acceptEnv(s string) bool {
|
||||
return split[0] == "TERM" || split[0] == "LANG" || strings.HasPrefix(split[0], "LC_")
|
||||
}
|
||||
|
||||
// sessionHandler handles SSH session post auth
|
||||
func (srv *DefaultServer) sessionHandler(session ssh.Session) {
|
||||
srv.mu.Lock()
|
||||
srv.sessions = append(srv.sessions, session)
|
||||
srv.mu.Unlock()
|
||||
|
||||
// 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() {
|
||||
err := session.Close()
|
||||
if err != nil {
|
||||
return
|
||||
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.Infof("Establishing SSH session for %s from host %s", session.User(), session.RemoteAddr().String())
|
||||
log.WithField("session", sessionKey).Infof("establishing SSH session for %s from %s", session.User(), session.RemoteAddr())
|
||||
|
||||
localUser, err := userNameLookup(session.User())
|
||||
if err != nil {
|
||||
_, err = fmt.Fprintf(session, "remote SSH server couldn't find local user %s\n", session.User()) //nolint
|
||||
err = session.Exit(1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Warnf("failed SSH session from %v, user %s", session.RemoteAddr(), session.User())
|
||||
s.handleUserLookupError(sessionKey, session, err)
|
||||
return
|
||||
}
|
||||
|
||||
ptyReq, winCh, isPty := session.Pty()
|
||||
if isPty {
|
||||
loginCmd, loginArgs, err := getLoginCmd(localUser.Username, session.RemoteAddr())
|
||||
if err != nil {
|
||||
log.Warnf("failed logging-in user %s from remote IP %s", localUser.Username, session.RemoteAddr().String())
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(loginCmd, loginArgs...)
|
||||
go func() {
|
||||
<-session.Context().Done()
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
err := cmd.Process.Kill()
|
||||
if err != nil {
|
||||
log.Debugf("failed killing SSH process %v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
cmd.Dir = localUser.HomeDir
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
|
||||
cmd.Env = append(cmd.Env, prepareUserEnv(localUser, getUserShell(localUser.Uid))...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
cmd.Env = append(cmd.Env, v)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("Login command: %s", cmd.String())
|
||||
file, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Errorf("failed starting SSH server: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for win := range winCh {
|
||||
setWinSize(file, win.Width, win.Height)
|
||||
}
|
||||
}()
|
||||
|
||||
srv.stdInOut(file, session)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err := io.WriteString(session, "only PTY is supported.\n")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = session.Exit(1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !isPty {
|
||||
s.handleNonPTYSession(sessionKey, session)
|
||||
return
|
||||
}
|
||||
log.Debugf("SSH session ended")
|
||||
|
||||
// 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 (srv *DefaultServer) stdInOut(file *os.File, session ssh.Session) {
|
||||
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() {
|
||||
// stdin
|
||||
_, err := io.Copy(file, session)
|
||||
if err != nil {
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
// AWS Linux 2 machines need some time to open the terminal so we need to wait for it
|
||||
timer := time.NewTimer(TerminalTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
_, _ = session.Write([]byte("Reached timeout while opening connection\n"))
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
default:
|
||||
// stdout
|
||||
writtenBytes, err := io.Copy(session, file)
|
||||
if err != nil && writtenBytes != 0 {
|
||||
_ = session.Exit(0)
|
||||
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)
|
||||
}
|
||||
}
|
||||
time.Sleep(TerminalBackoffDelay)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts SSH server. Blocking
|
||||
func (srv *DefaultServer) Start() error {
|
||||
log.Infof("starting SSH server on addr: %s", srv.listener.Addr().String())
|
||||
|
||||
publicKeyOption := ssh.PublicKeyAuth(srv.publicKeyHandler)
|
||||
hostKeyPEM := ssh.HostKeyPEM(srv.hostKeyPEM)
|
||||
err := ssh.Serve(srv.listener, srv.sessionHandler, publicKeyOption, hostKeyPEM)
|
||||
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 getUserShell(userID string) string {
|
||||
if runtime.GOOS == "linux" {
|
||||
output, _ := exec.Command("getent", "passwd", userID).Output()
|
||||
line := strings.SplitN(string(output), ":", 10)
|
||||
if len(line) > 6 {
|
||||
return strings.TrimSpace(line[6])
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -1,44 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import "context"
|
||||
|
||||
// MockServer mocks ssh.Server
|
||||
type MockServer struct {
|
||||
Ctx context.Context
|
||||
StopFunc func() error
|
||||
StartFunc func() error
|
||||
AddAuthorizedKeyFunc func(peer, newKey string) error
|
||||
RemoveAuthorizedKeyFunc func(peer string)
|
||||
}
|
||||
|
||||
// RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys
|
||||
func (srv *MockServer) RemoveAuthorizedKey(peer string) {
|
||||
if srv.RemoveAuthorizedKeyFunc == nil {
|
||||
return
|
||||
}
|
||||
srv.RemoveAuthorizedKeyFunc(peer)
|
||||
}
|
||||
|
||||
// AddAuthorizedKey add a given peer key to server authorized keys
|
||||
func (srv *MockServer) AddAuthorizedKey(peer, newKey string) error {
|
||||
if srv.AddAuthorizedKeyFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.AddAuthorizedKeyFunc(peer, newKey)
|
||||
}
|
||||
|
||||
// Stop stops SSH server.
|
||||
func (srv *MockServer) Stop() error {
|
||||
if srv.StopFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.StopFunc()
|
||||
}
|
||||
|
||||
// Start starts SSH server. Blocking
|
||||
func (srv *MockServer) Start() error {
|
||||
if srv.StartFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.StartFunc()
|
||||
}
|
@ -2,10 +2,14 @@ package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestServer_AddAuthorizedKey(t *testing.T) {
|
||||
@ -13,10 +17,7 @@ func TestServer_AddAuthorizedKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := newDefaultServer(key, "localhost:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(key)
|
||||
|
||||
// add multiple keys
|
||||
keys := map[string][]byte{}
|
||||
@ -53,10 +54,7 @@ func TestServer_RemoveAuthorizedKey(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := newDefaultServer(key, "localhost:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(key)
|
||||
|
||||
remotePrivKey, err := GeneratePrivateKey(ED25519)
|
||||
if err != nil {
|
||||
@ -83,10 +81,7 @@ func TestServer_PubKeyHandler(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := newDefaultServer(key, "localhost:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := NewServer(key)
|
||||
|
||||
var keys []ssh.PublicKey
|
||||
for i := 0; i < 10; i++ {
|
||||
@ -115,7 +110,353 @@ func TestServer_PubKeyHandler(t *testing.T) {
|
||||
for _, key := range keys {
|
||||
accepted := server.publicKeyHandler(nil, key)
|
||||
|
||||
assert.Truef(t, accepted, "expecting SSH connection to be accepted for a given SSH key %s", string(ssh.MarshalAuthorizedKey(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)
|
||||
}
|
||||
|
||||
server := NewServer(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)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate client key pair
|
||||
clientPrivKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
clientPubKey, err := GeneratePublicKey(clientPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create server with random port
|
||||
server := NewServer(hostKey)
|
||||
|
||||
// Add client's public key as authorized
|
||||
err = server.AddAuthorizedKey("test-peer", string(clientPubKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start server in background
|
||||
serverAddr := "127.0.0.1:0"
|
||||
started := make(chan string, 1)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// Get a free port
|
||||
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
|
||||
errChan <- server.Start(actualAddr)
|
||||
}()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Server is ready when we get the started signal
|
||||
|
||||
defer func() {
|
||||
err := server.Stop()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Parse client private key
|
||||
signer, err := ssh.ParsePrivateKey(clientPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse server host key for verification
|
||||
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
|
||||
require.NoError(t, err)
|
||||
hostPubKey := hostPrivParsed.PublicKey()
|
||||
|
||||
// Create SSH client config
|
||||
config := &ssh.ClientConfig{
|
||||
User: "test-user",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
// Connect to SSH server
|
||||
client, err := ssh.Dial("tcp", serverAddr, config)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
if err := client.Close(); err != nil {
|
||||
t.Logf("close client: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test creating a session
|
||||
session, err := client.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
if err := session.Close(); err != nil {
|
||||
t.Logf("close session: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Note: Since we don't have a real shell environment in tests,
|
||||
// we can't test actual command execution, but we can verify
|
||||
// the connection and authentication work
|
||||
t.Log("SSH connection and authentication successful")
|
||||
}
|
||||
|
||||
func TestSSHServerMultipleConnections(t *testing.T) {
|
||||
// Generate host key for server
|
||||
hostKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate client key pair
|
||||
clientPrivKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
clientPubKey, err := GeneratePublicKey(clientPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create server
|
||||
server := NewServer(hostKey)
|
||||
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
|
||||
errChan <- server.Start(actualAddr)
|
||||
}()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Server is ready when we get the started signal
|
||||
|
||||
defer func() {
|
||||
err := server.Stop()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Parse client private key
|
||||
signer, err := ssh.ParsePrivateKey(clientPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse server host key
|
||||
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
|
||||
require.NoError(t, err)
|
||||
hostPubKey := hostPrivParsed.PublicKey()
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: "test-user",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
// Test multiple concurrent connections
|
||||
const numConnections = 5
|
||||
results := make(chan error, numConnections)
|
||||
|
||||
for i := 0; i < numConnections; i++ {
|
||||
go func(id int) {
|
||||
client, err := ssh.Dial("tcp", serverAddr, config)
|
||||
if err != nil {
|
||||
results <- fmt.Errorf("connection %d failed: %w", id, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = client.Close() // Ignore error in test goroutine
|
||||
}()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
results <- fmt.Errorf("session %d failed: %w", id, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = session.Close() // Ignore error in test goroutine
|
||||
}()
|
||||
|
||||
results <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all connections to complete
|
||||
for i := 0; i < numConnections; i++ {
|
||||
select {
|
||||
case err := <-results:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("Connection %d timed out", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHServerAuthenticationFailure(t *testing.T) {
|
||||
// Generate host key for server
|
||||
hostKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate authorized key
|
||||
authorizedPrivKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
authorizedPubKey, err := GeneratePublicKey(authorizedPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate unauthorized key (different from authorized)
|
||||
unauthorizedPrivKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create server with only one authorized key
|
||||
server := NewServer(hostKey)
|
||||
err = server.AddAuthorizedKey("authorized-peer", string(authorizedPubKey))
|
||||
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
|
||||
errChan <- server.Start(actualAddr)
|
||||
}()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Server is ready when we get the started signal
|
||||
|
||||
defer func() {
|
||||
err := server.Stop()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Parse unauthorized private key
|
||||
unauthorizedSigner, err := ssh.ParsePrivateKey(unauthorizedPrivKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse server host key
|
||||
hostPrivParsed, err := ssh.ParsePrivateKey(hostKey)
|
||||
require.NoError(t, err)
|
||||
hostPubKey := hostPrivParsed.PublicKey()
|
||||
|
||||
// Try to connect with unauthorized key
|
||||
config := &ssh.ClientConfig{
|
||||
User: "test-user",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(unauthorizedSigner),
|
||||
},
|
||||
HostKeyCallback: ssh.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")
|
||||
}
|
||||
|
||||
func TestSSHServerStartStopCycle(t *testing.T) {
|
||||
hostKey, err := GeneratePrivateKey(ED25519)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := NewServer(hostKey)
|
||||
serverAddr := "127.0.0.1:0"
|
||||
|
||||
// Test multiple start/stop cycles
|
||||
for i := 0; i < 3; i++ {
|
||||
t.Logf("Start/stop cycle %d", i+1)
|
||||
|
||||
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
|
||||
errChan <- server.Start(actualAddr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case err := <-errChan:
|
||||
t.Fatalf("Cycle %d: Server failed to start: %v", i+1, err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("Cycle %d: Server start timeout", i+1)
|
||||
}
|
||||
|
||||
err = server.Stop()
|
||||
require.NoError(t, err, "Cycle %d: Stop should succeed", i+1)
|
||||
}
|
||||
}
|
||||
|
111
client/ssh/terminal_unix.go
Normal file
111
client/ssh/terminal_unix.go
Normal file
@ -0,0 +1,111 @@
|
||||
//go:build !windows
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error {
|
||||
fd := int(os.Stdout.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
return c.setupNonTerminalMode(ctx, session)
|
||||
}
|
||||
|
||||
state, err := term.MakeRaw(fd)
|
||||
if err != nil {
|
||||
return c.setupNonTerminalMode(ctx, session)
|
||||
}
|
||||
|
||||
c.terminalState = state
|
||||
c.terminalFd = fd
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
|
||||
go func() {
|
||||
defer signal.Stop(sigChan)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = term.Restore(fd, state)
|
||||
case sig := <-sigChan:
|
||||
_ = term.Restore(fd, state)
|
||||
signal.Reset(sig)
|
||||
syscall.Kill(syscall.Getpid(), sig.(syscall.Signal))
|
||||
}
|
||||
}()
|
||||
|
||||
return c.setupTerminal(session, fd)
|
||||
}
|
||||
|
||||
func (c *Client) setupNonTerminalMode(_ context.Context, session *ssh.Session) error {
|
||||
w, h := 80, 24
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
|
||||
terminal := os.Getenv("TERM")
|
||||
if terminal == "" {
|
||||
terminal = "xterm-256color"
|
||||
}
|
||||
|
||||
if err := session.RequestPty(terminal, h, w, modes); err != nil {
|
||||
return fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreWindowsConsoleState is a no-op on Unix systems
|
||||
func (c *Client) restoreWindowsConsoleState() {
|
||||
// No-op on Unix systems
|
||||
}
|
||||
|
||||
func (c *Client) setupTerminal(session *ssh.Session, fd int) error {
|
||||
w, h, err := term.GetSize(fd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get terminal size: %w", err)
|
||||
}
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
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
|
||||
}
|
||||
|
||||
terminal := os.Getenv("TERM")
|
||||
if terminal == "" {
|
||||
terminal = "xterm-256color"
|
||||
}
|
||||
|
||||
if err := session.RequestPty(terminal, h, w, modes); err != nil {
|
||||
return fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
212
client/ssh/terminal_windows.go
Normal file
212
client/ssh/terminal_windows.go
Normal file
@ -0,0 +1,212 @@
|
||||
//go:build windows
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
|
||||
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
|
||||
procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo")
|
||||
)
|
||||
|
||||
const (
|
||||
enableProcessedInput = 0x0001
|
||||
enableLineInput = 0x0002
|
||||
enableEchoInput = 0x0004
|
||||
enableVirtualTerminalProcessing = 0x0004
|
||||
enableVirtualTerminalInput = 0x0200
|
||||
)
|
||||
|
||||
type coord struct {
|
||||
x, y int16
|
||||
}
|
||||
|
||||
type smallRect struct {
|
||||
left, top, right, bottom int16
|
||||
}
|
||||
|
||||
type consoleScreenBufferInfo struct {
|
||||
size coord
|
||||
cursorPosition coord
|
||||
attributes uint16
|
||||
window smallRect
|
||||
maximumWindowSize coord
|
||||
}
|
||||
|
||||
func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) error {
|
||||
if err := c.saveWindowsConsoleState(); err != nil {
|
||||
return fmt.Errorf("save console state: %w", err)
|
||||
}
|
||||
|
||||
if err := c.enableWindowsVirtualTerminal(); err != nil {
|
||||
log.Debugf("failed to enable virtual terminal: %v", err)
|
||||
}
|
||||
|
||||
w, h := c.getWindowsConsoleSize()
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
ssh.ICRNL: 1,
|
||||
ssh.OPOST: 1,
|
||||
ssh.ONLCR: 1,
|
||||
ssh.ISIG: 1,
|
||||
ssh.ICANON: 1,
|
||||
ssh.VINTR: 3, // Ctrl+C
|
||||
ssh.VQUIT: 28, // Ctrl+\
|
||||
ssh.VERASE: 127, // Backspace
|
||||
ssh.VKILL: 21, // Ctrl+U
|
||||
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
|
||||
}
|
||||
|
||||
return session.RequestPty("xterm-256color", h, w, modes)
|
||||
}
|
||||
|
||||
func (c *Client) saveWindowsConsoleState() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Debugf("panic in saveWindowsConsoleState: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
stdout := syscall.Handle(os.Stdout.Fd())
|
||||
stdin := syscall.Handle(os.Stdin.Fd())
|
||||
|
||||
var stdoutMode, stdinMode uint32
|
||||
|
||||
ret, _, err := procGetConsoleMode.Call(uintptr(stdout), uintptr(unsafe.Pointer(&stdoutMode)))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to get stdout console mode: %v", err)
|
||||
return fmt.Errorf("get stdout console mode: %w", err)
|
||||
}
|
||||
|
||||
ret, _, err = procGetConsoleMode.Call(uintptr(stdin), uintptr(unsafe.Pointer(&stdinMode)))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to get stdin console mode: %v", err)
|
||||
return fmt.Errorf("get stdin console mode: %w", err)
|
||||
}
|
||||
|
||||
c.terminalFd = 1
|
||||
c.windowsStdoutMode = stdoutMode
|
||||
c.windowsStdinMode = stdinMode
|
||||
|
||||
log.Debugf("saved Windows console state - stdout: 0x%04x, stdin: 0x%04x", stdoutMode, stdinMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) enableWindowsVirtualTerminal() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Debugf("panic in enableWindowsVirtualTerminal: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
stdout := syscall.Handle(os.Stdout.Fd())
|
||||
stdin := syscall.Handle(os.Stdin.Fd())
|
||||
var mode uint32
|
||||
|
||||
ret, _, err := 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 fmt.Errorf("get stdout console mode: %w", err)
|
||||
}
|
||||
|
||||
mode |= enableVirtualTerminalProcessing
|
||||
ret, _, err = procSetConsoleMode.Call(uintptr(stdout), uintptr(mode))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to enable virtual terminal processing: %v", err)
|
||||
return fmt.Errorf("enable virtual terminal processing: %w", err)
|
||||
}
|
||||
|
||||
ret, _, err = 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 fmt.Errorf("get stdin console mode: %w", err)
|
||||
}
|
||||
|
||||
mode &= ^uint32(enableLineInput | enableEchoInput | enableProcessedInput)
|
||||
mode |= enableVirtualTerminalInput
|
||||
ret, _, err = procSetConsoleMode.Call(uintptr(stdin), uintptr(mode))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to set stdin raw mode: %v", err)
|
||||
return fmt.Errorf("set stdin raw mode: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("enabled Windows virtual terminal processing")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getWindowsConsoleSize() (int, int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Debugf("panic in getWindowsConsoleSize: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
stdout := syscall.Handle(os.Stdout.Fd())
|
||||
var csbi consoleScreenBufferInfo
|
||||
|
||||
ret, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(stdout), uintptr(unsafe.Pointer(&csbi)))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to get console buffer info, using defaults: %v", err)
|
||||
return 80, 24
|
||||
}
|
||||
|
||||
width := int(csbi.window.right - csbi.window.left + 1)
|
||||
height := int(csbi.window.bottom - csbi.window.top + 1)
|
||||
|
||||
log.Debugf("Windows console size: %dx%d", width, height)
|
||||
return width, height
|
||||
}
|
||||
|
||||
func (c *Client) restoreWindowsConsoleState() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Debugf("panic in restoreWindowsConsoleState: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if c.terminalFd != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
stdout := syscall.Handle(os.Stdout.Fd())
|
||||
stdin := syscall.Handle(os.Stdin.Fd())
|
||||
|
||||
ret, _, err := procSetConsoleMode.Call(uintptr(stdout), uintptr(c.windowsStdoutMode))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to restore stdout console mode: %v", err)
|
||||
}
|
||||
|
||||
ret, _, err = procSetConsoleMode.Call(uintptr(stdin), uintptr(c.windowsStdinMode))
|
||||
if ret == 0 {
|
||||
log.Debugf("failed to restore stdin console mode: %v", err)
|
||||
}
|
||||
|
||||
c.terminalFd = 0
|
||||
c.windowsStdoutMode = 0
|
||||
c.windowsStdinMode = 0
|
||||
|
||||
log.Debugf("restored Windows console state")
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
//go:build freebsd
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func setWinSize(file *os.File, width, height int) {
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func setWinSize(file *os.File, width, height int) {
|
||||
syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), uintptr(syscall.TIOCSWINSZ), //nolint
|
||||
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(height), uint16(width), 0, 0})))
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func setWinSize(file *os.File, width, height int) {
|
||||
|
||||
}
|
20
go.mod
20
go.mod
@ -19,8 +19,8 @@ require (
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
@ -41,7 +41,6 @@ require (
|
||||
github.com/cilium/ebpf v0.15.0
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/coreos/go-iptables v0.7.0
|
||||
github.com/creack/pty v1.1.18
|
||||
github.com/eko/gocache/lib/v4 v4.2.0
|
||||
github.com/eko/gocache/store/go_cache/v4 v4.2.2
|
||||
github.com/eko/gocache/store/redis/v4 v4.2.2
|
||||
@ -78,6 +77,7 @@ require (
|
||||
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
|
||||
@ -101,10 +101,10 @@ require (
|
||||
goauthentik.io/api/v3 v3.2023051.3
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
|
||||
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a
|
||||
golang.org/x/net v0.39.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/term v0.31.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/term v0.32.0
|
||||
google.golang.org/api v0.177.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@ -148,6 +148,7 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/creack/pty v1.1.18 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
@ -178,6 +179,7 @@ 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
|
||||
@ -238,10 +240,10 @@ require (
|
||||
go.uber.org/mock v0.4.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240509183442-62759503f434 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
|
37
go.sum
37
go.sum
@ -156,6 +156,7 @@ 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=
|
||||
@ -385,6 +386,8 @@ 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=
|
||||
@ -594,6 +597,8 @@ 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=
|
||||
@ -759,8 +764,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -806,8 +811,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -853,8 +858,8 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@ -883,8 +888,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -952,8 +957,8 @@ golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@ -961,8 +966,8 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -976,8 +981,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@ -1040,8 +1045,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
Loading…
x
Reference in New Issue
Block a user