Updated Jul 20, 2026

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@latest

The 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/ssh gives you a full SSH client in pure Go, no subprocess calls to ssh
  • One ssh.Client supports many concurrent sessions over a single TCP connection
  • Use ssh.ParsePrivateKey for key auth; add knownhosts.New for host verification in production
  • pkg/sftp provides a complete filesystem API (upload, download, stat, mkdir) over SSH
  • Bastion/jump-host proxying uses client.Dial to tunnel through the first hop
  • Always set Timeout on ClientConfig and 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?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy