017 Project 17: SSH Remote Orchestrator

017 Build an SSH Remote Orchestrator

Run commands on many hosts concurrently over SSH and aggregate results.

host list -> worker pool -> ssh exec per host -> collect stdout/stderr -> summary

Setup

go mod init example.com/gossh-orchestrator
go get golang.org/x/crypto/ssh@latest

Full main.go

package main

import (
    "flag"
    "fmt"
    "os"
    "strings"
    "sync"
    "time"

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

type result struct {
    host string
    out  string
    err  error
}

func run(host, user, keyPath, cmd string, timeout time.Duration) result {
    key, err := os.ReadFile(keyPath)
    if err != nil {
        return result{host: host, err: err}
    }
    signer, err := ssh.ParsePrivateKey(key)
    if err != nil {
        return result{host: host, err: err}
    }

    cfg := &ssh.ClientConfig{
        User:            user,
        Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
        Timeout:         timeout,
    }

    c, err := ssh.Dial("tcp", host+":22", cfg)
    if err != nil {
        return result{host: host, err: err}
    }
    defer c.Close()

    s, err := c.NewSession()
    if err != nil {
        return result{host: host, err: err}
    }
    defer s.Close()

    out, err := s.CombinedOutput(cmd)
    return result{host: host, out: string(out), err: err}
}

func main() {
    hostsArg := flag.String("hosts", "", "comma-separated host list")
    user := flag.String("user", "root", "ssh user")
    key := flag.String("key", os.Getenv("HOME")+"/.ssh/id_rsa", "private key path")
    cmd := flag.String("cmd", "uname -a", "command to run")
    workers := flag.Int("w", 10, "worker count")
    timeout := flag.Duration("t", 5*time.Second, "ssh timeout")
    flag.Parse()

    if *hostsArg == "" {
        fmt.Fprintln(os.Stderr, "provide -hosts")
        os.Exit(2)
    }
    hosts := strings.Split(*hostsArg, ",")

    jobs := make(chan string)
    results := make(chan result)
    var wg sync.WaitGroup

    for i := 0; i < *workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for h := range jobs {
                results <- run(strings.TrimSpace(h), *user, *key, *cmd, *timeout)
            }
        }()
    }

    go func() {
        for _, h := range hosts {
            jobs <- h
        }
        close(jobs)
        wg.Wait()
        close(results)
    }()

    ok := 0
    for r := range results {
        if r.err != nil {
            fmt.Printf("[%s] ERROR: %v\n", r.host, r.err)
            continue
        }
        ok++
        fmt.Printf("[%s]\n%s\n", r.host, strings.TrimSpace(r.out))
    }
    fmt.Printf("done: %d/%d succeeded\n", ok, len(hosts))
}

Run

go run . -hosts "10.0.0.11,10.0.0.12" -user root -cmd "uptime"

Security Notes

  • Replace ssh.InsecureIgnoreHostKey with known-hosts validation in production.
  • Use dedicated restricted SSH keys for automation.

Step-by-Step Explanation

  1. Model jobs, workers, and outputs explicitly.
  2. Bound concurrency using worker pools and buffered channels.
  3. Use sync.WaitGroup for lifecycle control.
  4. Aggregate worker results in one place.
  5. Verify behavior under both normal and failure paths.

Code Anatomy

  • Producer pushes jobs into a channel.
  • Workers consume jobs and emit results.
  • Aggregator merges results and prints summary.

Learning Goals

  • Build leak-free goroutine patterns.
  • Balance throughput and resource limits.
  • Understand fan-out/fan-in architecture.