Refactor ssh server and client

This commit is contained in:
Viktor Liu
2025-07-02 19:34:36 +02:00
parent 520f2cfdb4
commit 6ed846ae29
19 changed files with 3530 additions and 552 deletions
+215 -54
View File
@@ -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
File diff suppressed because it is too large Load Diff
+73 -17
View File
@@ -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)
}
-14
View File
@@ -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)
}
-51
View File
@@ -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
}
+705 -174
View File
@@ -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"
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
}
return shell
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
}
-44
View File
@@ -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()
}
+356 -15
View File
@@ -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
View 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
View 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")
}
-10
View File
@@ -1,10 +0,0 @@
//go:build freebsd
package ssh
import (
"os"
)
func setWinSize(file *os.File, width, height int) {
}
-14
View File
@@ -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})))
}
-9
View File
@@ -1,9 +0,0 @@
package ssh
import (
"os"
)
func setWinSize(file *os.File, width, height int) {
}