02 - SSH & Remote Execution
📋 Jump to Takeaways🎁 What if you could run commands on remote machines, transfer files, and tunnel through bastion hosts, all from native Go code without ever shelling out to the ssh binary?
Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.
x/crypto/ssh Package
The golang.org/x/crypto/ssh package implements the SSH protocol in pure Go. It handles key exchange, authentication, channel multiplexing, and session management. Combined with github.com/pkg/sftp, you get file transfer too.
go get golang.org/x/crypto/ssh@latest
go get github.com/pkg/sftp@latestThe package gives you ssh.Client (a connection), ssh.Session (a command execution context), and various auth methods. One client can open many sessions concurrently.
Key-Based Authentication
Production systems use key-based auth. Load a private key and build a ClientConfig:
package main
import (
"os"
"time"
"golang.org/x/crypto/ssh"
)
func loadPrivateKey(path string) (ssh.Signer, error) {
key, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ssh.ParsePrivateKey(key)
}
func newSSHConfig(user, keyPath string) (*ssh.ClientConfig, error) {
signer, err := loadPrivateKey(keyPath)
if err != nil {
return nil, err
}
return &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // dev only!
Timeout: 10 * time.Second,
}, nil
}For passphrase-protected keys, use ssh.ParsePrivateKeyWithPassphrase. In production, replace InsecureIgnoreHostKey with a proper known-hosts verifier:
import "golang.org/x/crypto/ssh/knownhosts"
func knownHostsCallback() (ssh.HostKeyCallback, error) {
return knownhosts.New(os.ExpandEnv("$HOME/.ssh/known_hosts"))
}Running Commands Remotely
Open a connection, create a session, run a command:
func runCommand(host, user, keyPath, command string) (string, error) {
config, err := newSSHConfig(user, keyPath)
if err != nil {
return "", err
}
client, err := ssh.Dial("tcp", host+":22", config)
if err != nil {
return "", fmt.Errorf("dial %s: %w", host, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("new session: %w", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return string(output), fmt.Errorf("command failed: %w\noutput: %s", err, output)
}
return string(output), nil
}Each Session runs one command. For multiple commands, open multiple sessions on the same client. The TCP connection is reused:
func runMultiple(client *ssh.Client, commands []string) ([]string, error) {
results := make([]string, 0, len(commands))
for _, cmd := range commands {
session, err := client.NewSession()
if err != nil {
return results, err
}
out, err := session.CombinedOutput(cmd)
session.Close()
if err != nil {
return results, fmt.Errorf("cmd %q: %w", cmd, err)
}
results = append(results, string(out))
}
return results, nil
}Capturing Separate Stdout and Stderr
When you need stdout and stderr as separate streams:
func runWithStreams(client *ssh.Client, command string) (stdout, stderr string, err error) {
session, err := client.NewSession()
if err != nil {
return "", "", err
}
defer session.Close()
var stdoutBuf, stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
err = session.Run(command)
return stdoutBuf.String(), stderrBuf.String(), err
}File Transfer with SFTP
pkg/sftp gives you an FTP-like interface over the SSH connection:
import "github.com/pkg/sftp"
func uploadFile(client *ssh.Client, localPath, remotePath string) error {
sftp, err := sftp.NewClient(client)
if err != nil {
return err
}
defer sftp.Close()
srcFile, err := os.Open(localPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := sftp.Create(remotePath)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
func downloadFile(client *ssh.Client, remotePath, localPath string) error {
sftp, err := sftp.NewClient(client)
if err != nil {
return err
}
defer sftp.Close()
srcFile, err := sftp.Open(remotePath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(localPath)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}The SFTP client also supports Stat, ReadDir, Mkdir, Remove, and Chmod, giving you the full filesystem API.
Jump Hosts and Bastion Proxying
In production, servers sit behind a bastion host. You SSH to the bastion, then dial the internal host through that connection:
func dialViaBastion(bastion, target, user, keyPath string) (*ssh.Client, error) {
config, err := newSSHConfig(user, keyPath)
if err != nil {
return nil, err
}
// Connect to the bastion
bastionClient, err := ssh.Dial("tcp", bastion+":22", config)
if err != nil {
return nil, fmt.Errorf("dial bastion: %w", err)
}
// Dial the target through the bastion's TCP connection
conn, err := bastionClient.Dial("tcp", target+":22")
if err != nil {
bastionClient.Close()
return nil, fmt.Errorf("dial target via bastion: %w", err)
}
// Perform SSH handshake over the proxied connection
ncc, chans, reqs, err := ssh.NewClientConn(conn, target+":22", config)
if err != nil {
conn.Close()
bastionClient.Close()
return nil, fmt.Errorf("handshake to target: %w", err)
}
return ssh.NewClient(ncc, chans, reqs), nil
}The target server sees the connection coming from the bastion's IP. You can chain multiple hops by repeating this pattern.
Session Management and Timeouts
SSH sessions can hang if the remote host goes unresponsive. Use context-based cancellation:
func runWithTimeout(client *ssh.Client, command string, timeout time.Duration) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
// Use a channel to signal completion
done := make(chan struct{})
var output []byte
var runErr error
go func() {
output, runErr = session.CombinedOutput(command)
close(done)
}()
select {
case <-done:
return string(output), runErr
case <-time.After(timeout):
session.Signal(ssh.SIGKILL)
return "", fmt.Errorf("command timed out after %v", timeout)
}
}For connection keepalives, send periodic requests on the client:
func keepAlive(client *ssh.Client, interval time.Duration, done <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_, _, err := client.SendRequest("[email protected]", true, nil)
if err != nil {
return
}
case <-done:
return
}
}
}SSH Remote Runner
A tool that connects to a remote host via SSH key auth, runs system commands (hostname, disk usage, uptime), and uploads a configuration file via SFTP. All in pure Go without shelling out to the ssh binary.
Input: Environment variables SSH_HOST, SSH_USER, and SSH_KEY pointing to the target server and private key
Output: Command output streamed to stdout, confirmation of file upload to /tmp/app.conf
Full source: examples/ssh-remote-runner
Key Takeaways
x/crypto/sshgives you a full SSH client in pure Go, no subprocess calls tossh- One
ssh.Clientsupports many concurrent sessions over a single TCP connection - Use
ssh.ParsePrivateKeyfor key auth; addknownhosts.Newfor host verification in production pkg/sftpprovides a complete filesystem API (upload, download, stat, mkdir) over SSH- Bastion/jump-host proxying uses
client.Dialto tunnel through the first hop - Always set
TimeoutonClientConfigand implement command-level timeouts for reliability - Send keepalive requests to detect dead connections before they hang your automation
🎁 What if you could pull images, start containers, and stream logs using the same Go API that the docker CLI itself is built on?