SSH with Go: Connections, Commands, User Management, and Production Patterns

SSH with Go: Connections, Commands, User Management, and Production Patterns

Master SSH client programming in Go. Learn connection pooling, command execution, key authentication, user/data manipulation, and production-ready patterns for managing remote systems at scale.

By Omar Flores

The Hidden Superpower: SSH in Your Code

Most developers use SSH to connect to servers manually. They ssh user@host, run a command, disconnect.

But SSH is not just for humans. SSH is an API.

Imagine you can, from Go code:

  • Connect to 100 servers simultaneously
  • Execute commands in parallel
  • Capture and parse output
  • Create users, modify files, restart services
  • All securely, all programmatically, all at scale

This is not exotic. This is how the largest organizations automate their infrastructure.

This guide teaches you to do it.


Part 1: SSH Fundamentals β€” The Protocol

SSH is not magical. It is a well-defined protocol with clear capabilities.

What SSH Actually Is

SSH does three things:

  1. Secure transport β€” Encrypted communication channel
  2. Authentication β€” Prove who you are (password, keys, certificates)
  3. Session β€” Execute commands, forward ports, transfer files

Most developers only use #1 and #3. You need all three to understand what is possible.

Authentication Methods (In Order of Production Use)

Method 1: Public Key Authentication (Best)

You have a private key. Server has your public key. You prove you have the private key without sending it.

import (
	"golang.org/x/crypto/ssh"
	"os"
)

// Read private key
keyBytes, _ := os.ReadFile("/home/user/.ssh/id_rsa")

// Parse key
signer, _ := ssh.ParsePrivateKey(keyBytes)

// Create config with key auth
config := &ssh.ClientConfig{
	User: "deployer",
	Auth: []ssh.AuthMethod{
		ssh.PublicKeys(signer),
	},
	HostKeyCallback: verifyHostKey,
}

Method 2: Password Authentication (Acceptable for Internal)

Simple but less secure. Username + password over encrypted channel.

config := &ssh.ClientConfig{
	User: "deployer",
	Auth: []ssh.AuthMethod{
		ssh.Password("SecurePassword123"),
	},
	HostKeyCallback: verifyHostKey,
}

Method 3: Keyboard Interactive (Rare)

Used for two-factor or custom auth. Complex to implement.

Method 4: No Authentication (Never in Production)

config := &ssh.ClientConfig{
	HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

Only for development. Never for production.

Host Key Verification (The Critical Part)

Most developers skip this:

// WRONG: Ignores host key verification
config.HostKeyCallback = ssh.InsecureIgnoreHostKey()

This leaves you vulnerable to man-in-the-middle attacks.

Right way:

// Verify against known_hosts file
hostKeyCallback, _ := knownhosts.New("/home/user/.ssh/known_hosts")
config.HostKeyCallback = hostKeyCallback

Or verify against hardcoded fingerprints for critical servers:

config.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
	expectedFingerprint := "SHA256:AAAAB3NzaC1yc2EAAA..."
	actualFingerprint := ssh.FingerprintSHA256(key)

	if actualFingerprint != expectedFingerprint {
		return fmt.Errorf("host key mismatch")
	}
	return nil
}

Part 2: Building Connection Pools β€” The Foundation

Like SFTP, opening SSH connections is expensive. You need pooling.

Full Connection Pool Implementation

// sshpool/pool.go
package sshpool

import (
	"fmt"
	"sync"

	"golang.org/x/crypto/ssh"
)

type Connection struct {
	client *ssh.Client
	closed bool
}

type Pool struct {
	host     string
	config   *ssh.ClientConfig
	mu       sync.RWMutex
	conns    []*Connection
	maxConns int
	inUse    int
}

func NewPool(host string, config *ssh.ClientConfig, maxConns int) *Pool {
	return &Pool{
		host:     host,
		config:   config,
		conns:    make([]*Connection, 0, maxConns),
		maxConns: maxConns,
	}
}

// Get retrieves or creates an SSH connection
func (p *Pool) Get() (*Connection, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	// Try to reuse
	for i, conn := range p.conns {
		if !conn.closed {
			p.conns = append(p.conns[:i], p.conns[i+1:]...)
			p.inUse++
			return conn, nil
		}
	}

	// Create new
	if p.inUse < p.maxConns {
		client, err := ssh.Dial("tcp", p.host+":22", p.config)
		if err != nil {
			return nil, err
		}
		p.inUse++
		return &Connection{client: client}, nil
	}

	return nil, fmt.Errorf("pool exhausted")
}

// Return returns connection to pool
func (p *Pool) Return(conn *Connection) {
	p.mu.Lock()
	defer p.mu.Unlock()

	if conn.closed {
		p.inUse--
		return
	}

	p.conns = append(p.conns, conn)
	p.inUse--
}

// Close closes all connections
func (p *Pool) Close() error {
	p.mu.Lock()
	defer p.mu.Unlock()

	for _, conn := range p.conns {
		conn.client.Close()
	}
	return nil
}

// Exec runs a command and returns output
func (c *Connection) Exec(cmd string) (string, error) {
	session, err := c.client.NewSession()
	if err != nil {
		return "", err
	}
	defer session.Close()

	output, err := session.CombinedOutput(cmd)
	return string(output), err
}

Part 3: Command Execution Patterns β€” Beyond Simple Exec

SSH is not just β€œexecute a command and get output.” There are patterns for real-world scenarios.

Pattern 1: Simple Command Execution

// Execute a command, get output
conn, _ := pool.Get()
defer pool.Return(conn)

output, err := conn.Exec("ls -la /home/deployer")
if err != nil {
	log.Println("Command failed:", err)
}
log.Println(output)

Pattern 2: Stream Large Output

For commands that return huge output, stream instead of buffering:

func (c *Connection) ExecStream(cmd string, stdout io.Writer) error {
	session, err := c.client.NewSession()
	if err != nil {
		return err
	}
	defer session.Close()

	// Stream stdout in real-time
	session.Stdout = stdout
	session.Stderr = os.Stderr

	return session.Run(cmd)
}

// Usage
conn, _ := pool.Get()
defer pool.Return(conn)

err := conn.ExecStream("tail -f /var/log/app.log", os.Stdout)
// Log output streams to terminal in real-time

Pattern 3: Execute Multiple Commands in One Session

func (c *Connection) ExecMultiple(cmds []string) ([]string, error) {
	session, err := c.client.NewSession()
	if err != nil {
		return nil, err
	}
	defer session.Close()

	results := make([]string, len(cmds))

	for i, cmd := range cmds {
		output, err := session.CombinedOutput(cmd)
		if err != nil {
			return nil, fmt.Errorf("cmd %d failed: %w", i, err)
		}
		results[i] = string(output)
	}

	return results, nil
}

// Usage
conn, _ := pool.Get()
defer pool.Return(conn)

results, _ := conn.ExecMultiple([]string{
	"whoami",
	"pwd",
	"date",
})

// All executed in one session
for _, r := range results {
	log.Println(r)
}

Pattern 4: Capture Separate Stdout and Stderr

func (c *Connection) ExecSeparate(cmd string) (stdout, stderr string, err error) {
	session, err := c.client.NewSession()
	if err != nil {
		return "", "", err
	}
	defer session.Close()

	outBuf := &bytes.Buffer{}
	errBuf := &bytes.Buffer{}

	session.Stdout = outBuf
	session.Stderr = errBuf

	err = session.Run(cmd)
	return outBuf.String(), errBuf.String(), err
}

// Usage
stdout, stderr, err := conn.ExecSeparate("docker ps --format json")
if err != nil {
	log.Println("Error:", stderr)
} else {
	log.Println("Output:", stdout)
}

Part 4: User and Data Manipulation β€” Real System Administration

Creating and Managing Users

// AddUser creates a new user account
func (c *Connection) AddUser(username string, uid int, groups []string) error {
	// Create user
	cmd := fmt.Sprintf("sudo useradd -u %d -m -s /bin/bash %s", uid, username)
	_, err := c.Exec(cmd)
	if err != nil {
		return err
	}

	// Add to groups
	for _, group := range groups {
		cmd := fmt.Sprintf("sudo usermod -aG %s %s", group, username)
		_, err := c.Exec(cmd)
		if err != nil {
			return err
		}
	}

	return nil
}

// SetPassword sets user password (should use key-based auth instead)
func (c *Connection) SetPassword(username, password string) error {
	// Use echo and chpasswd for safer password setting
	cmd := fmt.Sprintf("echo '%s:%s' | sudo chpasswd", username, password)
	_, err := c.Exec(cmd)
	return err
}

// AddSSHKey adds a public key for a user
func (c *Connection) AddSSHKey(username, publicKey string) error {
	cmd := fmt.Sprintf(
		"sudo -u %s sh -c 'mkdir -p ~/.ssh && echo %q >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'",
		username, publicKey,
	)
	_, err := c.Exec(cmd)
	return err
}

// DeleteUser removes a user
func (c *Connection) DeleteUser(username string, removeHome bool) error {
	flag := ""
	if removeHome {
		flag = " -r"
	}
	cmd := fmt.Sprintf("sudo userdel%s %s", flag, username)
	_, err := c.Exec(cmd)
	return err
}

// Usage
conn, _ := pool.Get()
defer pool.Return(conn)

_ = conn.AddUser("deployer", 1001, []string{"sudo", "docker"})
_ = conn.AddSSHKey("deployer", "ssh-rsa AAAA...")

File and Data Operations

// WriteFile writes data to a remote file
func (c *Connection) WriteFile(path string, data []byte) error {
	// Use base64 encoding to safely transfer binary data
	encoded := base64.StdEncoding.EncodeToString(data)
	cmd := fmt.Sprintf(
		"echo '%s' | base64 -d > %s && chmod 644 %s",
		encoded, path, path,
	)
	_, err := c.Exec(cmd)
	return err
}

// ReadFile reads a remote file
func (c *Connection) ReadFile(path string) ([]byte, error) {
	cmd := fmt.Sprintf("cat %s | base64", path)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(output))
	return data, err
}

// AppendFile appends to a remote file
func (c *Connection) AppendFile(path, content string) error {
	cmd := fmt.Sprintf("echo %q >> %s", content, path)
	_, err := c.Exec(cmd)
	return err
}

// GetFileInfo gets file metadata
func (c *Connection) GetFileInfo(path string) (os.FileInfo, error) {
	output, err := c.Exec(fmt.Sprintf("stat -c '%%s %%Y %%a' %s", path))
	if err != nil {
		return nil, err
	}

	var size int64
	var mtime int64
	var mode string
	_, err = fmt.Sscanf(output, "%d %d %s", &size, &mtime, &mode)
	if err != nil {
		return nil, err
	}

	// Return simplified FileInfo
	return &remoteFileInfo{
		name:  filepath.Base(path),
		size:  size,
		mtime: time.Unix(mtime, 0),
	}, nil
}

type remoteFileInfo struct {
	name  string
	size  int64
	mtime time.Time
}

func (f *remoteFileInfo) Name() string       { return f.name }
func (f *remoteFileInfo) Size() int64        { return f.size }
func (f *remoteFileInfo) ModTime() time.Time { return f.mtime }
func (f *remoteFileInfo) Mode() os.FileMode  { return 0644 }
func (f *remoteFileInfo) IsDir() bool        { return false }
func (f *remoteFileInfo) Sys() interface{}   { return nil }
// GetSystemInfo retrieves system information
func (c *Connection) GetSystemInfo() (map[string]string, error) {
	info := make(map[string]string)

	commands := map[string]string{
		"hostname": "hostname",
		"kernel":   "uname -r",
		"cpu":      "nproc",
		"memory":   "free -h | grep Mem",
		"disk":     "df -h / | tail -1",
	}

	for key, cmd := range commands {
		output, err := c.Exec(cmd)
		if err != nil {
			return nil, err
		}
		info[key] = strings.TrimSpace(output)
	}

	return info, nil
}

// SearchFiles finds files matching a pattern
func (c *Connection) SearchFiles(dir, pattern string) ([]string, error) {
	cmd := fmt.Sprintf("find %s -type f -name %q 2>/dev/null", dir, pattern)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	var files []string
	for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
		if line != "" {
			files = append(files, line)
		}
	}
	return files, nil
}

// SearchFilesContent searches file contents
func (c *Connection) SearchFilesContent(dir, pattern string) ([]string, error) {
	cmd := fmt.Sprintf("grep -r %q %s 2>/dev/null | cut -d: -f1 | sort -u", pattern, dir)
	output, err := c.Exec(cmd)
	if err != nil {
		return nil, err
	}

	var files []string
	for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
		if line != "" {
			files = append(files, line)
		}
	}
	return files, nil
}

Part 5: Parallel Operations at Scale

Execute commands on multiple servers simultaneously:

// ParallelExec executes a command on multiple servers
func ParallelExec(pools map[string]*Pool, cmd string, workers int) map[string]string {
	results := make(map[string]string)
	mu := sync.Mutex{}

	workChan := make(chan string, 100)
	var wg sync.WaitGroup

	// Start workers
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for host := range workChan {
				pool := pools[host]
				conn, err := pool.Get()
				if err != nil {
					mu.Lock()
					results[host] = "ERROR: " + err.Error()
					mu.Unlock()
					continue
				}

				output, err := conn.Exec(cmd)
				pool.Return(conn)

				mu.Lock()
				if err != nil {
					results[host] = "ERROR: " + err.Error()
				} else {
					results[host] = strings.TrimSpace(output)
				}
				mu.Unlock()
			}
		}()
	}

	// Send work
	go func() {
		for host := range pools {
			workChan <- host
		}
		close(workChan)
	}()

	wg.Wait()
	return results
}

// Usage
pools := map[string]*Pool{
	"server1.com": pool1,
	"server2.com": pool2,
	"server3.com": pool3,
}

results := ParallelExec(pools, "uptime", 4)
for host, output := range results {
	log.Printf("%s: %s\n", host, output)
}

Part 6: CLI Tool Example

Build a production SSH management tool:

// ssh-tool/main.go
package main

import (
	"flag"
	"fmt"
	"log"
	"golang.org/x/crypto/ssh"
	"os"
)

func main() {
	cmd := flag.NewFlagSet("ssh-tool", flag.ExitOnError)
	host := cmd.String("host", "", "Remote host")
	user := cmd.String("user", "", "Username")
	key := cmd.String("key", os.ExpandEnv("$HOME/.ssh/id_rsa"), "Private key path")
	operation := cmd.String("op", "", "Operation: exec, user-add, file-write, search")
	param1 := cmd.String("p1", "", "Parameter 1")
	param2 := cmd.String("p2", "", "Parameter 2")

	cmd.Parse(flag.Args())

	// Load private key
	keyBytes, _ := os.ReadFile(*key)
	signer, _ := ssh.ParsePrivateKey(keyBytes)

	// Create config
	config := &ssh.ClientConfig{
		User: *user,
		Auth: []ssh.AuthMethod{
			ssh.PublicKeys(signer),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}

	// Connect
	client, err := ssh.Dial("tcp", *host+":22", config)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// Execute operation
	switch *operation {
	case "exec":
		session, _ := client.NewSession()
		session.Run(*param1)
		session.Close()

	case "user-add":
		cmd := fmt.Sprintf("sudo useradd -m -s /bin/bash %s", *param1)
		session, _ := client.NewSession()
		session.Run(cmd)
		session.Close()

	case "file-write":
		cmd := fmt.Sprintf("echo %q > %s", *param2, *param1)
		session, _ := client.NewSession()
		session.Run(cmd)
		session.Close()

	case "search":
		cmd := fmt.Sprintf("find %s -name %q", *param1, *param2)
		session, _ := client.NewSession()
		output, _ := session.Output(cmd)
		fmt.Println(string(output))
		session.Close()

	default:
		fmt.Println("Unknown operation")
	}
}

Usage:

# Execute command
./ssh-tool -host example.com -user deployer -op exec -p1 "ls -la"

# Add user
./ssh-tool -host example.com -user deployer -op user-add -p1 "newuser"

# Write file
./ssh-tool -host example.com -user deployer -op file-write -p1 "/tmp/config.txt" -p2 "content"

# Search files
./ssh-tool -host example.com -user deployer -op search -p1 "/home" -p2 "*.log"

Part 7: Security Best Practices

SSH Security Checklist

Authentication:

  • βœ… Use key-based authentication, never passwords in code
  • βœ… Store private keys securely (encrypted, restricted permissions)
  • βœ… Rotate keys regularly
  • βœ… Use key passphrases

Host Verification:

  • βœ… Always verify host keys in production
  • βœ… Use known_hosts or pinned fingerprints
  • βœ… Never use InsecureIgnoreHostKey in production

Command Execution:

  • βœ… Never shell-escape user input, use proper APIs
  • βœ… Validate all command parameters
  • βœ… Use sudoers restrictions for privilege escalation
  • βœ… Log all remote commands executed

Network:

  • βœ… Use SSH key exchange algorithms (ed25519, ecdsa)
  • βœ… Disable weak algorithms
  • βœ… Consider bastion hosts for accessing internal servers
  • βœ… Use SSH certificates for large deployments

Secure Key Loading

// Load and validate private key
func loadPrivateKey(keyPath string, passphrase string) (ssh.Signer, error) {
	// Read key file
	keyBytes, err := os.ReadFile(keyPath)
	if err != nil {
		return nil, err
	}

	// Validate file permissions (should be 600)
	info, _ := os.Stat(keyPath)
	if info.Mode()&0077 != 0 {
		return nil, fmt.Errorf("private key has unsafe permissions: %o", info.Mode())
	}

	// Parse with passphrase if needed
	var signer ssh.Signer
	if passphrase != "" {
		signer, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(passphrase))
	} else {
		signer, err = ssh.ParsePrivateKey(keyBytes)
	}

	return signer, err
}

Part 8: Common Patterns and Recipes

Deploying Application Code

func DeployApplication(host, user, keyPath, appPath, deployPath string) error {
	// 1. Copy code
	if err := copyDirectory(host, user, keyPath, appPath, deployPath); err != nil {
		return err
	}

	// 2. Install dependencies
	pool := createPool(host, user, keyPath)
	conn, _ := pool.Get()
	defer pool.Return(conn)

	if _, err := conn.Exec(fmt.Sprintf("cd %s && go mod download", deployPath)); err != nil {
		return err
	}

	// 3. Build
	if _, err := conn.Exec(fmt.Sprintf("cd %s && go build -o app", deployPath)); err != nil {
		return err
	}

	// 4. Restart service
	if _, err := conn.Exec("sudo systemctl restart app"); err != nil {
		return err
	}

	return nil
}

Health Check

func HealthCheck(host, user, keyPath string) bool {
	pool := createPool(host, user, keyPath)
	conn, err := pool.Get()
	if err != nil {
		return false
	}
	defer pool.Return(conn)

	output, err := conn.Exec("curl -s http://localhost:8080/health")
	return err == nil && strings.Contains(output, "ok")
}

Part 9: The Real Cost of Bad SSH Code

Developers often treat SSH as fire-and-forget:

// ANTI-PATTERN: New connection for every operation
ssh.Dial("tcp", host+":22", config)
// Execute command
ssh.Dial("tcp", host+":22", config)
// Execute another command

At scale:

  • Per-connection handshake: 100-500ms each
  • 100 commands: 10+ seconds in serial
  • Broken connections: No retry, task fails
  • No host verification: Vulnerability to MITM

The right patterns (pooling, verification, safe execution) cost a few hundred lines of code. They save hours of debugging and prevent security breaches.

The difference between SSH code that works and SSH code that is production-ready is not complexity. It is discipline. Connection pooling. Host verification. Safe command execution. These are not optional. They are the difference between a script and an infrastructure tool.

Tags

#go #golang #ssh #remote-execution #system-management #connections #authentication #cli #best-practices #security #devops #automation #backend