Documentation
¶
Overview ¶
In-process SSH client over the cloudbox matrix tunnel.
The remote outpost's `internal/agent/ssh.go` wraps every incoming WS connection as a net.Conn and feeds it to ssh.NewServerConn — the SSH protocol runs end-to-end through the byte pipe. This client mirrors that: dial the WS, wrap it as a net.Conn, hand the conn to ssh.NewClientConn. The result is a full Go SSH client that reaches paired hosts without any system /usr/bin/ssh involvement.
Authentication: when cloudbox stamps `X-Periscope-Role: user|admin` on the WS upgrade (which it does whenever the matrix_elev cookie passes the elevation gate), the remote SSH server flips into NoClientAuth mode — the OS-password challenge is skipped because the WS-layer auth already proved the operator's intent. We therefore pass no AuthMethods on the client side. If the WS handshake itself fails 401/403 the dial returns EAuthRequiredError before we get this far.
Wave 1 of the SSH self-sufficiency work supports Exec only — one- shot remote commands. Interactive shell, SFTP, and port-forwarding are Wave 2 additions on the same Client surface.
Trust-on-first-use host-key pinning for the in-process SSH client.
The remote outpost presents a persistent ed25519 host key from `internal/agent/hostkey.go`. The first time we connect to a given (host alias, key) pair, we pin it; subsequent connects must match. A mismatch is a hard failure surfaced as REMOTE HOST IDENTIFICATION HAS CHANGED so the operator notices.
File format (one entry per line, OpenSSH-known_hosts-compatible for the simple case):
<alias> <key-type> <base64-marshaled-public-key>
We don't use `golang.org/x/crypto/ssh/knownhosts` because that package's checker calls SplitHostPort on the dynamic `remote net.Addr` it receives from the SSH layer — and our underlying transport is a websocket-wrapped net.Conn whose RemoteAddr returns a synthetic non-parseable string. The full known_hosts matcher buys us no benefit for our trust model (alias-only, no IP/DNS pinning), so a focused 50-LOC implementation is the right shape.
Transport: WebSocket dial to cloudbox's `/h/<host>/ssh` endpoint.
This is the same handshake `outpost ssh-proxy` has used since day one — bearer + matrix_elev cookie + WSS upgrade — extracted into a shared package so the new in-process SSH client (cmd/outpost/ ssh_tree.go and the MCP `outpost_ssh_exec` tool) can reuse it. `cmd/outpost/ssh.go` (ssh-proxy) keeps its existing thin wrapper that delegates here.
Why the extraction: ssh-proxy treats the WS as a byte pipe and lets the user's `/usr/bin/ssh` do the SSH protocol on top. The new client path does the SSH protocol *in-process* via golang.org/x/crypto/ssh over the same byte pipe. Both paths need an identical dial — bearer resolution, elev-cookie attach, 401/403 retry semantics — so it belongs in one place.
Index ¶
- Constants
- func AsNetConn(ctx context.Context, conn *websocket.Conn) net.Conn
- func BuildWSURL(server string, port int, protocol, host string) (string, error)
- func DialWS(ctx context.Context, opts DialOptions) (*websocket.Conn, error)
- func HostAliasForHost(host string) string
- func KnownHostsCallbackTOFU(path, hostAlias string) (ssh.HostKeyCallback, error)
- type Client
- func (c *Client) Close() error
- func (c *Client) DirectTCPIP(ctx context.Context, host string, port int) (net.Conn, error)
- func (c *Client) Exec(ctx context.Context, opts ExecOptions) (*ExecResult, error)
- func (c *Client) LocalForward(ctx context.Context, listener net.Listener, destHost string, destPort int) error
- func (c *Client) SFTP() (*sftp.Client, error)
- func (c *Client) Shell(ctx context.Context, opts ShellOptions) (int, error)
- type Config
- type DialOptions
- type EAuthRequiredError
- type EHostOfflineError
- type ElevationCallback
- type ExecOptions
- type ExecResult
- type ShellOptions
Constants ¶
const StrictHostKeyEnv = "OUTPOST_SSH_STRICT_HOST_KEY"
StrictHostKeyEnv, when set to a truthy value (1/true/yes/on), makes a changed host key a hard failure (classic OpenSSH StrictHostKeyChecking). Default behavior is lenient: outpost is a personal-fleet tool where the operator controls both ends, so a host that legitimately re-keys — a re-install, a per-user host key (two outposts on one machine present different keys on the same address), or key regeneration — should silently re-pin and continue rather than block every command with REMOTE HOST IDENTIFICATION HAS CHANGED.
Variables ¶
This section is empty.
Functions ¶
func AsNetConn ¶
AsNetConn is a small convenience wrapper for callers that already have a *websocket.Conn from DialWS and want the canonical net.Conn form to feed into Dial. Equivalent to websocket.NetConn(ctx, conn, websocket.MessageBinary) — exposed here so callers don't need to import coder/websocket directly when sshclient is sufficient.
func BuildWSURL ¶
BuildWSURL constructs the `ws(s)://<server>/h/<host>/ssh` URL from the outpost's FileConfig fields. server may be a bare hostname ("ai.dhnt.io"), host:port ("172.16.25.23:18080"), or a full URL ("https://example.com"). protocol is the matrix-tunnel transport returned by /api/register/exchange — "wss" => wss://, anything else => ws://. Same shape as `cmd/outpost/ssh.go`'s buildSSHWSURL — extracted here so both ssh-proxy and the new in-process client route through one definition.
func DialWS ¶
DialWS opens the WebSocket to cloudbox/h/<host>/ssh, attaches Bearer + optional cookie, and (if OnElevate is set) recovers once from a 401/403 by minting a fresh cookie. The returned *websocket.Conn has ReadLimit disabled so SSH streams aren't artificially capped.
Caller is responsible for wrapping the conn as a net.Conn via websocket.NetConn(ctx, conn, websocket.MessageBinary) when feeding it to ssh.NewClientConn.
func HostAliasForHost ¶
HostAliasForHost is the canonical "outpost-<host>" alias used both here and in `outpost ssh-config`'s emitted ~/.ssh/config stanzas. Centralized so the two stay in sync.
func KnownHostsCallbackTOFU ¶
func KnownHostsCallbackTOFU(path, hostAlias string) (ssh.HostKeyCallback, error)
KnownHostsCallbackTOFU returns an ssh.HostKeyCallback that:
- on first contact for `hostAlias`, pins the presented key (TOFU);
- on subsequent contact, verifies key bytes match the pinned entry;
- on mismatch, by default re-pins the new key and continues (the operator controls both ends — see StrictHostKeyEnv); with OUTPOST_SSH_STRICT_HOST_KEY set, fails hard with REMOTE HOST IDENTIFICATION HAS CHANGED instead.
`path` is created with mode 0600 on first pin. The parent directory is created if missing.
`hostAlias` is the alias used in entries — typically `outpost-<host>` for consistency with `outpost ssh-config`.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps an established SSH connection (over the matrix tunnel) for one paired host.
func Dial ¶
Dial runs the SSH handshake over an already-established transport. Caller is responsible for the WS dial (DialWS); this just layers SSH protocol on top.
On success, the returned Client owns the transport — Close() will tear it down.
func (*Client) Close ¶
Close tears down the SSH conn and the underlying transport in the right order. Idempotent — repeated Close calls return nil after the first.
func (*Client) DirectTCPIP ¶
DirectTCPIP opens an SSH "direct-tcpip" channel through this Client to (host, port) on the remote outpost's reachable network. The returned net.Conn byte-bridges through the channel — suitable for either layering another SSH session on top (the hop/ProxyJump pattern) or for plain TCP forwarding (the tunnel pattern).
host is the destination as the remote outpost would resolve it. For paired-outpost-to-paired-outpost hops this is a peer hostname the remote's peerhosts allowlist accepts. For LAN destinations it must match an entry the operator added to SSHForwardSockets or otherwise widened the destination allowlist for.
The caller owns the returned conn — close it (or the parent Client) to tear down the channel.
func (*Client) Exec ¶
func (c *Client) Exec(ctx context.Context, opts ExecOptions) (*ExecResult, error)
Exec runs cmd on the remote host and returns its stdout/stderr + exit code. Suitable for the MCP `outpost_ssh_exec` tool and the CLI `outpost ssh exec <name> -- <cmd>` subcommand.
func (*Client) LocalForward ¶
func (c *Client) LocalForward(ctx context.Context, listener net.Listener, destHost string, destPort int) error
LocalForward opens a local listener and bridges every accepted connection to (destHost, destPort) on the remote outpost's reachable network via direct-tcpip. Blocks until ctx is canceled or the listener errors. The CLI tunnel subcommand owns lifecycle.
The listener is closed when LocalForward returns.
func (*Client) SFTP ¶
SFTP opens an SFTP subsystem channel and wraps it with pkg/sftp's client. Caller owns the returned *sftp.Client — call Close before closing the outer Client.
func (*Client) Shell ¶
Shell opens a session, requests a PTY, starts the user's login shell, and pipes I/O until the session ends. Handles:
- Local terminal raw mode (so keystrokes go through, not line- buffered) when Stdin is a TTY. Restored on return.
- SIGWINCH propagation: when the local terminal resizes, send a "window-change" SSH request to update the remote PTY size.
Exit code mapping mirrors Exec: the remote's exit status when one was sent; -1 for signal-only exits or transport errors.
type Config ¶
type Config struct {
// Transport is the byte pipe to the remote SSH server. Typically
// the result of websocket.NetConn(...) wrapping a *websocket.Conn
// returned by DialWS. The caller owns the underlying *websocket.Conn
// — we Close the net.Conn (which closes the WS) but don't reach
// into the WS API directly.
Transport net.Conn
// HostAlias is the canonical alias used for host-key pinning. The
// `outpost-<host>` form matches what `outpost ssh-config` emits.
HostAlias string
// User is the OS username to log in as on the remote host. Empty
// is rejected — let the caller resolve from cloudbox's
// /api/v1/ssh/hosts before getting here.
User string
// HostKeyCallback verifies (and on first connect, pins) the
// remote outpost's host key. Build via KnownHostsCallbackTOFU.
HostKeyCallback ssh.HostKeyCallback
// Auth supplies client-side SSH auth methods. Nil keeps the
// historical behavior (no methods): the cloudbox-vouched paths rely
// on the remote server flipping NoClientAuth=true, so the handshake
// completes without credentials. LAN-direct dials (plain TCP to an
// `outpost sshd` / SSHListenAddr listener) have no upstream vouching
// — the server runs its OS-password gate — so those callers pass a
// password method here (typically ssh.RetryableAuthMethod wrapping
// a TTY prompt).
Auth []ssh.AuthMethod
// HandshakeTimeout caps the SSH transport handshake. Default 30s.
HandshakeTimeout time.Duration
}
Config wires the dial-time parameters. Almost all callers will fill this from a conf.SSHTarget plus the FileConfig the daemon already has cached.
type DialOptions ¶
type DialOptions struct {
// WSURL is the full `wss://cloudbox/h/<host>/ssh` URL — build with
// BuildWSURL from the outpost's FileConfig.
WSURL string
// Bearer is the cloudbox access token. Resolved by the caller from
// $OUTPOST_SESSION_JWT, fc.AccessToken, or fc.Token — the same
// preference order ssh-proxy uses.
Bearer string
// Cookie is the currently-cached matrix_elev value (may be empty).
Cookie string
// PeerTicket, when set, swaps the dial onto the LAN-direct path:
// the only header attached is `Authorization: Bearer <PeerTicket>`,
// Cookie and the cloudbox Bearer are both omitted, and OnElevate
// is never invoked (peer-ticket auth doesn't have an in-band
// recovery path — the caller re-mints by re-running the
// cookie→ticket exchange at cloudbox). Used when WSURL targets a
// peer outpost's SSH-WS LAN listener directly, not cloudbox.
PeerTicket string
// Host is just the bare host name (the same value embedded in
// WSURL). Used in error messages and threaded into OnElevate.
Host string
// OnElevate, if non-nil, is invoked once on the first 401/403 to
// recover a fresh cookie. nil => surface EAuthRequiredError on the
// first auth failure (the non-interactive policy). Ignored when
// PeerTicket is set (no in-band recovery on the LAN-direct path).
OnElevate ElevationCallback
// DialTimeout caps each individual dial attempt. Default 30s.
DialTimeout time.Duration
}
DialOptions parameterizes the WS dial.
type EAuthRequiredError ¶
EAuthRequiredError is returned when the cloudbox elevation gate refused us and either no OnElevate was supplied (non-interactive caller) or the recovery attempt also failed. The structured shape lets agentic callers (MCP `outpost_ssh_exec`) report a precise "elevation required" condition instead of a opaque dial error.
func (EAuthRequiredError) Error ¶
func (e EAuthRequiredError) Error() string
func (EAuthRequiredError) Unwrap ¶
func (e EAuthRequiredError) Unwrap() error
Unwrap exposes the underlying dial error so errors.Is / errors.As work transparently for callers that want to inspect the cause.
type EHostOfflineError ¶ added in v0.5.4
EHostOfflineError is returned when cloudbox itself answered the WS upgrade but could not reach the target host through the matrix tunnel (gateway 502/503/504). Elevation state is irrelevant here — the remote outpost daemon is offline or its tunnel is down, and the only fix is bringing the machine back online.
func (EHostOfflineError) Error ¶ added in v0.5.4
func (e EHostOfflineError) Error() string
type ElevationCallback ¶
ElevationCallback is invoked when cloudbox replies 401/403 to the WS upgrade — the caller's matrix_elev cookie is missing or stale. Returns a fresh cookie value on success; an error to give up.
Interactive callers (the CLI) wire this to a /dev/tty password prompt that calls `runConnect`. Non-interactive callers (MCP tools, admincore.ExecSSH) pass nil — DialWS then surfaces a structured EAuthRequiredError instead of attempting recovery.
type ExecOptions ¶
type ExecOptions struct {
// Command is the literal command line (joined as the SSH server
// would see it). Quoting / escaping is the caller's job.
Command string
// Timeout terminates the exec if the remote takes longer. 0 means
// "use the parent context's deadline only."
Timeout time.Duration
// MaxStdout / MaxStderr cap the captured output. Anything past the
// limit is discarded and the corresponding Truncated flag is set.
// 0 means use the defaults (1 MiB stdout, 256 KiB stderr).
MaxStdout int64
MaxStderr int64
// Stdin is fed to the remote process. Nil = empty stdin (most
// agentic callers want this). Closed when copy completes.
Stdin io.Reader
}
ExecOptions tunes one-shot remote execution.
type ExecResult ¶
type ExecResult struct {
// Stdout is the (possibly truncated) standard output.
Stdout []byte
// Stderr is the (possibly truncated) standard error.
Stderr []byte
// ExitCode is the remote process's exit status. 0 on success;
// the actual non-zero code when the remote returned one;
// -1 when the remote signaled the process (no exit code) or
// when ssh.Session.Wait returned a non-ExitError.
ExitCode int
// StdoutTruncated / StderrTruncated indicate the corresponding
// stream hit the LimitReader cap. The cap is set via Exec's
// MaxStdout / MaxStderr fields.
StdoutTruncated bool
StderrTruncated bool
}
ExecResult is the structured outcome of a one-shot exec.
type ShellOptions ¶
type ShellOptions struct {
// Stdin/Stdout/Stderr wire the local terminal to the remote PTY.
// Typically os.Stdin / os.Stdout / os.Stderr. Stderr is merged
// into Stdout by the SSH PTY by default; we keep the field for
// callers that want to split (uncommon).
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// TermType is the TERM string sent in pty-req (default "xterm-256color").
TermType string
// Width / Height seed the remote PTY's dimensions. When zero and
// Stdin is a TTY, the dimensions are autodetected from the
// terminal. SIGWINCH-driven updates take effect regardless.
Width int
Height int
}
ShellOptions parameterizes an interactive shell session.