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.
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:
- Secure transport β Encrypted communication channel
- Authentication β Prove who you are (password, keys, certificates)
- 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 }
System Information and Search
// 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
Related Articles
Building Automation Services with Go: Practical Tools & Real-World Solutions
Master building useful automation services and tools with Go. Learn to create production-ready services that solve real problems: log processors, API monitors, deployment tools, data pipelines, and more.
Automation with Go: Building Scalable, Concurrent Systems for Real-World Tasks
Master Go for automation. Learn to build fast, concurrent automation tools, CLI utilities, monitoring systems, and deployment pipelines. Go's concurrency model makes it perfect for real-world automation.
Automation Tools for Developers: Real Workflows Without AI - CLI, Scripts & Open Source
Master free automation tools for developers. Learn to automate repetitive tasks, workflows, deployments, monitoring, and operations. Build custom automation pipelines with open-source toolsβno AI needed.