sshx

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

x/sshx

Persistent SSH connections that manage themselves — dial once, run many, heal on failure.

Status: experimental, under x/, at v0.1.1. The x/ contract applies in full: the API may break between minors, the experiment may fail, and the package may be deleted outright. Nothing at the w-tools root depends on it — never build anything load-bearing on an x/ package. Graduation to the root, under a new import path, is the only way it earns stability.

TL;DR

go get github.com/Wigata-Intech/w-tools/x/sshx@latest
  • One self-healing connection per host: automatic reconnect with jittered exponential backoff, health probes, and a pool-wide cap on concurrent dials so a fleet cold-start can't trip sshd's throttle
  • Never blocks on a dead host: a not-ready connection fails in nanoseconds, so polling fifty hosts survives ten being down
  • context.Context end to end — dial, command, session, ping all cancel cleanly
  • Fail-closed host keys: strict pinning or pin-on-first-use with your confirmation callback; accepting an unknown host silently requires calling something named InsecureAcceptAny
  • One-shot commands with os/exec-familiar shapes: output is returned even when the command fails, exit codes included
  • Interactive shells as pure io streams with PTY and live resize — wire them to a terminal, a websocket, or a recorder; the library never touches yours
  • Typed errors throughout (errors.As-able), and a keys subpackage that parses, loads (passphrase via callback), and generates keys
  • One dependency, deliberately: golang.org/x/crypto — the Go team's SSH implementation. Nothing else, ever

What problem this solves

Anything that manages machines over SSH — a fleet dashboard, a deploy job, an ops bot — hits the same wall: connections die, and naive code either redials per command (paying the key-exchange handshake every time), blocks a whole refresh loop on one dead host, or reconnects twenty hosts in lockstep after a network blip and trips the server's connection throttle. Add the interactive parts — host-key trust prompts, passphrases, terminal resizing — and the SSH plumbing ends up welded to one UI, unusable anywhere else.

sshx is that plumbing, extracted and made self-managing: connections that keep themselves alive and multiplex everything over one handshake, with every interactive decision handed back to you as a callback or a stream.

How it solves it

hostKey, err := sshx.TOFU(knownHostsPath, confirmOnMyUI) // or sshx.KnownHosts(path) for automation
if err != nil { /* ... */ }
signer, err := keys.Load(keyPath, promptOnMyUI) // prompt runs only if the key is encrypted
if err != nil { /* ... */ }

cfg := sshx.Config{User: "deploy", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKey: hostKey}

pool := sshx.NewPool(16) // at most 16 handshakes in flight across the fleet
defer pool.Close()

web := pool.Add(sshx.ManagedConfig{
    Dial: func(ctx context.Context) (*sshx.Client, error) {
        return sshx.Dial(ctx, "10.0.0.7:22", cfg)
    },
})

// Poll it forever; a dead host answers ErrNotReady instantly and heals itself.
out, err := web.CombinedOutput(ctx, "uptime")

One-shot against a single host, no pool:

c, err := sshx.Dial(ctx, "10.0.0.7:22", cfg)
if err != nil { /* stages: network / hostkey / handshake — errors.As for the details */ }
defer c.Close()
res, err := c.Output(ctx, "systemctl is-active my-service") // res.Stdout, res.Stderr, res.ExitCode — populated even on failure

An interactive shell, streams only — the consumer owns the terminal:

sess, err := c.Shell(ctx, sshx.SessionConfig{
    Stdin: myIn, Stdout: myOut, Stderr: myErr,
    TTY: &sshx.TTYConfig{Term: "xterm-256color", Cols: w, Rows: h},
})
// on window change: sess.Resize(w, h)
err = sess.Wait()

Why it matters

  • Fail-closed by construction. There is no default that skips host-key verification, no way to store a plaintext password in a config struct, and a changed host key is never confirmable — it is always a hard error, because that's what a man-in-the-middle looks like. Unknown-host trust decisions and passphrases reach you as callbacks; what you do with them is your policy.
  • Standards underneath. The SSH protocol itself (RFC 4251–4254) is golang.org/x/crypto/ssh, kept current by the Go team — sshx adds no crypto and exposes no algorithm-downgrade knobs. Key generation defaults to Ed25519 (RFC 8709) and refuses RSA below 2048 bits.
  • Built for many hosts. Backoff jitter prevents reconnection stampedes, the dial cap respects sshd MaxStartups, and not-ready-never-blocks means one dead machine can't freeze a fleet view.
  • No UI opinions. Everything interactive is a stream or a callback, so the same library serves a TUI, a web app, and an unattended job.

The keys invariants are fuzz-tested, not asserted: 20M+ generated inputs against ParsePrivate (arbitrary bytes never panic; a nil error always carries a usable signer) and ~700k comment round-trips against Generate (every accepted comment survives an authorized_keys parse round-trip intact, on exactly one line). The comment fuzzer caught a real bug before first release — whitespace-only comments silently vanished in the round-trip — which is why Generate refuses edge-whitespace comments and the crasher lives in the committed corpus as a permanent regression test.

Fuzzing — commands (30s smoke; release runs are longer)
$ cd keys
$ go test -run='^$' -fuzz=FuzzParsePrivate -fuzztime=30s .
$ go test -run='^$' -fuzz=FuzzGenerateComment -fuzztime=30s .

What it costs

Measured on a MacBook Pro — Apple M2 Pro (10 cores), 16 GB RAM, macOS 26.5.2, go1.26.6, against an in-process SSH server on loopback:

cd x/sshx && go test -run='^$' -bench=. -benchmem .
Raw output
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/x/sshx
cpu: Apple M2 Pro
BenchmarkManagedCombinedOutput-10       6848      169520 ns/op     71600 B/op      138 allocs/op
BenchmarkPoolColdStart-10                265     5118863 ns/op   1468634 B/op     9103 allocs/op
BenchmarkClientCombinedOutput-10        7852      136943 ns/op     71568 B/op      138 allocs/op
PASS
ok      github.com/Wigata-Intech/w-tools/x/sshx 3.996s
Situation Cost Meaning for you
One command, bare Client ~140µs, 138 allocs The floor: a full SSH channel open→exec→close round-trip on the multiplexed transport — protocol, not overhead added here
One command, pooled Managed ~170µs, 138 allocs The self-healing wrapper adds a mutex acquisition and error classification — identical allocations, round-trip dominated
16-host fleet, cold start to all-Ready ~5ms total Sixteen full handshakes through the shared dial semaphore

Structural costs: one background goroutine per live connection (keepalive) plus one per Managed (maintenance loop), both exiting on close; and the module requires Go 1.25+ with golang.org/x/crypto — the one dependency this repo's policy admits, scoped to x/ modules implementing a protocol the standard library doesn't.

The promises

As of v0.1.0:

  • Host-key verification cannot be disabled by accident: every path is pinned unless you explicitly construct InsecureAcceptAny.
  • Command output is never discarded on failure — whatever arrived is returned alongside the error.
  • Managed execution methods return ErrNotReady immediately when the host is down; they never block waiting for a reconnect.
  • A non-zero remote exit never tears down the connection; only transport-level failures trigger redial.
  • All errors are typed. The single, deliberate exception to "never inspect error text" is IsAuthFailure — one audited classifier, pinned by tests against real rejections from a live SSH server, so your code never has to substring-match.
  • The x/ contract: this API may change or vanish. Pin a tag.

Documentation

Overview

Package sshx manages persistent SSH connections.

The core of the package is the Pool/Managed pair: one self-healing connection per host, reconnecting with jittered exponential backoff, capped dial concurrency across the pool, and non-blocking not-ready semantics so a dead host never stalls a caller polling many. Around that core sit the pieces a persistent connection needs: Dial with context cancellation, one-shot execution shaped like os/exec (Client.Output, Client.CombinedOutput), stream-based interactive sessions (Client.Shell), and fail-closed host-key verification (KnownHosts, TOFU, InsecureAcceptAny).

The package is headless by design: it never touches the process's terminal, environment, or standard streams. Interactive decisions — confirming an unknown host key, supplying a passphrase — are callbacks the consumer wires to a terminal, a GUI, or an automated policy. Private-key parsing, loading, and generation live in the keys subpackage.

Index

Constants

View Source
const (
	// StageNetwork: the TCP connection could not be established.
	StageNetwork = "network"
	// StageHostKey: the transport came up but the host-key policy refused the
	// server's identity. The policy's error is retrievable with errors.As.
	StageHostKey = "hostkey"
	// StageHandshake: the SSH handshake failed after host-key verification
	// passed — authentication exhaustion, protocol failure, or peer close.
	StageHandshake = "handshake"
)

Dial stages reported by DialError.Stage. The stage states with certainty where a dial died; it never guesses at causes inside a stage.

View Source
const (
	DefaultDialTimeout = 10 * time.Second
	DefaultPingTimeout = 5 * time.Second
	DefaultTerm        = "xterm-256color"
	DefaultCols        = 80
	DefaultRows        = 24
	DefaultMaxDials    = 16
)

Defaults applied wherever the caller leaves a zero value: Dial and Ping when ctx carries no deadline, TTYConfig fields left empty, and NewPool given a non-positive cap.

Variables

View Source
var ErrAuthRequired = errors.New("sshx: at least one auth method required")

ErrAuthRequired is returned by Dial when Config.Auth is empty.

View Source
var ErrClosed = errors.New("sshx: closed")

ErrClosed is returned for operations on a closed client, session, or pool.

View Source
var ErrHostKeyRequired = errors.New("sshx: host key policy required")

ErrHostKeyRequired is returned by Dial when Config.HostKey is nil. There is no insecure default; opting out takes an explicit InsecureAcceptAny.

View Source
var ErrNotReady = errors.New("sshx: connection not ready")

ErrNotReady is returned by Managed when no live connection exists right now (connecting, backing off, or reconnecting). It is returned immediately, never after blocking.

Functions

func InsecureAcceptAny

func InsecureAcceptAny() ssh.HostKeyCallback

InsecureAcceptAny returns a verifier that accepts every host key without checking anything. It exists for lab use; production traffic has no business anywhere near it.

func IsAuthFailure added in v0.1.1

func IsAuthFailure(err error) bool

IsAuthFailure reports whether err represents an SSH authentication rejection — key not accepted, wrong password, no method left to try. It is a heuristic, not a guarantee: x/crypto exposes no typed auth error, so this is the one place in the module that inspects error text, pinned by tests against real rejections so an upstream rewording breaks here, loudly, instead of silently in every consumer.

func KnownHosts

func KnownHosts(path string) (ssh.HostKeyCallback, error)

KnownHosts returns a strict host-key verifier pinned to the OpenSSH-format file at path: unknown hosts fail with UnknownHostKeyError, changed keys with HostKeyMismatchError. The file is created empty (0600, directory 0700) if absent.

func TOFU

func TOFU(path string, confirm ConfirmHostFunc) (ssh.HostKeyCallback, error)

TOFU returns a trust-on-first-use verifier over the file at path: known hosts are checked strictly, and an unknown host is handed to confirm, whose consent pins the key. A nil confirm degrades to strict. A changed key is never confirmable.

Concurrent first-contact dials through one TOFU value collapse to a single confirmation and a single pinned line. That guarantee is per returned callback: use one TOFU value per known_hosts file, not one per dial.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is one authenticated SSH connection. Command execution and sessions multiplex over it; only Dial pays the handshake.

func Dial

func Dial(ctx context.Context, addr string, cfg Config) (*Client, error)

Dial connects to addr (host:port) and authenticates. ctx bounds the whole dial — TCP connect and SSH handshake; without a deadline DefaultDialTimeout applies. Failures are reported as *DialError with the stage that died, and host-key policy errors are retrievable from it with errors.As.

func (*Client) Close

func (c *Client) Close() error

Close terminates the connection and stops the keepalive goroutine. It is idempotent; every call returns the first close's error.

func (*Client) CombinedOutput

func (c *Client) CombinedOutput(ctx context.Context, cmd string) (string, error)

CombinedOutput runs cmd like Output but folds stdout and stderr into one string, preserving their interleaving.

func (*Client) Output

func (c *Client) Output(ctx context.Context, cmd string) (Result, error)

Output runs cmd in a fresh session over the shared transport and returns stdout and stderr separately. Like os/exec, output captured before a failure is still returned: a non-zero exit populates Result and returns the wrapped *ssh.ExitError. ctx cancellation closes the in-flight session (the remote command is abandoned, not killed) and returns ctx's error.

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) error

Ping sends an OpenSSH keepalive request and reports whether the peer answered. It is deadline-guarded — DefaultPingTimeout unless ctx sets less — so a black-hole peer that never replies cannot wedge the caller (golang/go#21478).

func (*Client) Shell

func (c *Client) Shell(ctx context.Context, cfg SessionConfig) (*Session, error)

Shell starts the remote login shell wired to cfg's streams. ctx governs the session's lifetime: cancellation closes it and unblocks Wait. The library touches no terminal state — raw mode, size discovery, and resize signals are the consumer's, fed in through cfg and Resize.

type Config

type Config struct {
	User    string
	Auth    []ssh.AuthMethod
	HostKey ssh.HostKeyCallback
}

Config configures a Dial. HostKey and at least one Auth method are required; there are no insecure defaults.

type ConfirmHostFunc

type ConfirmHostFunc func(h HostInfo) (bool, error)

ConfirmHostFunc decides whether a previously unseen host is trusted. Returning true pins the key; false or an error refuses the connection.

type DialError

type DialError struct {
	Stage string // StageNetwork, StageHostKey, or StageHandshake
	Addr  string
	Err   error
}

DialError reports a failed Dial with the stage it died in.

func (*DialError) Error

func (e *DialError) Error() string

Error implements error.

func (*DialError) Unwrap

func (e *DialError) Unwrap() error

Unwrap exposes the underlying error to errors.Is and errors.As.

type HostInfo

type HostInfo struct {
	Host        string // address as dialed, host:port
	Remote      net.Addr
	KeyType     string // e.g. "ssh-ed25519"
	Fingerprint string // SHA-256, OpenSSH format
	Key         ssh.PublicKey
}

HostInfo describes a host presenting a key, as handed to a ConfirmHostFunc.

type HostKeyMismatchError

type HostKeyMismatchError struct {
	Host        string
	KeyType     string
	Fingerprint string
}

HostKeyMismatchError reports a host whose presented key differs from the pinned one — a possible man-in-the-middle. It is never confirmable.

func (*HostKeyMismatchError) Error

func (e *HostKeyMismatchError) Error() string

Error implements error.

type Managed

type Managed struct {
	// contains filtered or unexported fields
}

Managed is a self-healing connection to one host: it keeps a single live Client, redialing with jittered exponential backoff after failures, and multiplexes all execution over it — only dials pay the handshake.

func (*Managed) Client

func (m *Managed) Client() *Client

Client returns the live connection for direct reuse — an interactive session on an already-pooled host without a second handshake — or nil when not currently ready. The Managed still owns it: don't Close it.

func (*Managed) Close

func (m *Managed) Close()

Close stops maintaining the connection and tears down the live transport. StateClosed is terminal; subsequent operations return ErrClosed.

func (*Managed) CombinedOutput

func (m *Managed) CombinedOutput(ctx context.Context, cmd string) (string, error)

CombinedOutput runs cmd on the live connection. It returns ErrNotReady immediately — without blocking — when no connection is established, so a caller polling many hosts never stalls on a dead one, and ErrClosed after Close. A transport-level failure schedules a reconnect; a non-zero exit does not.

func (*Managed) Err

func (m *Managed) Err() error

Err returns the error from the last failed attempt, or nil.

func (*Managed) Output

func (m *Managed) Output(ctx context.Context, cmd string) (Result, error)

Output runs cmd on the live connection with the same not-ready and reconnect semantics as CombinedOutput.

func (*Managed) State

func (m *Managed) State() State

State returns the current lifecycle state.

type ManagedConfig

type ManagedConfig struct {
	// Dial establishes the connection; it is called for the first connect and
	// every reconnect. Its ctx is canceled when the Managed closes.
	Dial func(ctx context.Context) (*Client, error)
	// OnStateChange, when non-nil, is invoked on every lifecycle transition
	// with the new state and the error that drove it (nil on recovery and on
	// close). Transitions from the maintenance loop arrive in order; the
	// StateClosed notification comes from the closing goroutine and is not
	// ordered relative to a loop notification already in flight — State()
	// itself is always accurate after Close. Keep the callback fast and
	// non-blocking.
	OnStateChange func(s State, err error)
}

ManagedConfig configures one self-healing connection.

type Pool

type Pool struct {
	// contains filtered or unexported fields
}

Pool owns a set of Managed connections and the dial-concurrency limit shared across them. Capping concurrent dials keeps a cold start of many hosts from tripping a server's sshd MaxStartups throttle or exhausting local sockets.

func NewPool

func NewPool(maxConcurrentDials int) *Pool

NewPool returns a pool permitting at most maxConcurrentDials dials in flight across all its connections. Non-positive means DefaultMaxDials.

func (*Pool) Add

func (p *Pool) Add(cfg ManagedConfig) *Managed

Add registers a connection and starts maintaining it in the background, returning immediately. The returned Managed is usable at once — its execution methods report ErrNotReady until the first dial succeeds.

func (*Pool) Close

func (p *Pool) Close()

Close tears down every Managed connection. Idempotent; Add after Close returns an already-closed Managed.

type Result

type Result struct {
	Stdout   []byte
	Stderr   []byte
	ExitCode int // -1 when the command never returned a status
}

Result is the outcome of a one-shot command.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is a live interactive shell on the remote host.

func (*Session) Close

func (s *Session) Close() error

Close tears the session down. It is idempotent; every call returns the first close's error.

func (*Session) Resize

func (s *Session) Resize(cols, rows int) error

Resize informs the remote PTY of a new size. It is a no-op server-side unless a PTY was requested.

func (*Session) Wait

func (s *Session) Wait() error

Wait blocks until the remote shell exits — by the user ending it, the connection dying, or the session's ctx being canceled.

type SessionConfig

type SessionConfig struct {
	Stdin          io.Reader
	Stdout, Stderr io.Writer
	TTY            *TTYConfig // nil: no PTY is requested
}

SessionConfig wires an interactive session to the consumer's streams. Any nil stream is simply not connected.

type State

type State int

State is the lifecycle state of a Managed connection.

const (
	// StateConnecting is the initial state, before the first dial resolves.
	StateConnecting State = iota
	// StateReady means a live connection is established and usable.
	StateReady
	// StateBroken means the last attempt failed; a reconnect is scheduled.
	StateBroken
	// StateClosed is terminal: the Managed was closed and will not reconnect.
	StateClosed
)

func (State) String

func (s State) String() string

String renders the state for status lines.

type TTYConfig

type TTYConfig struct {
	Term       string // DefaultTerm when empty
	Cols, Rows int    // DefaultCols x DefaultRows when non-positive
}

TTYConfig requests a remote pseudo-terminal. The library never inspects the local environment — the consumer supplies the terminal type and size it wants the remote side to see.

type UnknownHostKeyError

type UnknownHostKeyError struct {
	Host        string
	KeyType     string
	Fingerprint string
}

UnknownHostKeyError reports a host absent from known_hosts that was not (or could not be) confirmed.

func (*UnknownHostKeyError) Error

func (e *UnknownHostKeyError) Error() string

Error implements error.

Directories

Path Synopsis
Package keys parses, loads, and generates SSH private keys for use with sshx.
Package keys parses, loads, and generates SSH private keys for use with sshx.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL