--- status: reviewed last_updated: 2026-10-28 --- # Client — client direction: driving vanilla SSH servers as family ops ## The door framing The client direction is a door *to vanilla servers*: family in, vanilla out. The motivating consumer is the **reverse-runner** shape (`docs/research/reverse-runner.md`, alkminer extraction): a family hub dials vanilla `sshd` on rented/near-ephemeral hosts — remote boxes expose plain sshd and hold nothing of ours; every op the hub drives maps family-side onto the same protocols the server direction serves. The asymmetry is honest (vision principle 2): the server direction *serves* family seam entries; the client direction *consumes* family types. Same features gate both (ADR-003 symmetry rule). **Type symmetry at the seam is the client contract:** a family app drives alkssh in family-protocol types; alkssh translates to vanilla wire ops (russh client surface, ADR-002). No vanilla-concept leaks up into the family surface. ## The dial surface Every dial needs: address, **key source** (ADR-005 — vault-backed blob, decrypt-at-dial, fail-closed on locked/absent — never env vars, never plaintext at rest), **host-key policy** (OQ-05 resolution — assembly-provided; crate default fail-closed reject-unknown, hard decline on mismatch; the OpenSSH "add to known_hosts?" moment is an assembly policy, e.g. learn-and-record / prompt / provider-metadata match — the crate is policy-blind), and the bounded-resource config (`keepalive_interval`/`keepalive_max` — 15 s / 3 default posture per the reverse-runner evidence; `inactivity_timeout`; window/packet knobs per `overview.md`). The identity the dial authenticates *as* is a registered fingerprint at the remote end — for family-run peers (alkforge runner) that's the alkcall `IdentityProvider`; for vanilla fleets it's whatever the vanilla server's `authorized_keys` holds — either way the *key* is ours from the vault and the host's key is checked by the policy. ## Op rows (family op → vanilla translation) | Family-facing op | Family protocol/types | Vanilla-wire translation | Feature | |---|---|---|---| | run a command / own a shell on the runner | alktty session types (pipe vs PTY; exit semantics) | `channel_open_session` + `exec`/pty-req/shell-req; data pump; drain `wait()` per the POC-3 §D.3 mechanics (`Channel::exec()` — no `request_exec`; Success/Failure arrive on the channel queue, not as `Err` from the request call) | `session` | | open a tunnel target | alktunnels `TunnelSession`/target types | `channel_open_direct_tcpip` per connection — the typed open resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6); no `wait()` before `into_stream` | `tunnel` | | the `-D` behavior (local SOCKS door) | alksocks' local door **composed at the assembly**, fed a `DialFn` backed by row 2's open | the wire sees only `direct-tcpip`; alkssh *does not depend on alksocks* on this path (ADR-003 §1a) | `tunnel` | | listen + ingest (`-R` leg) | alktunnels consumer/`ForwardedHandler` shape | `tcpip_forward(address, port) -> bound` global request (no mpsc sink param) + **accept the server-initiated `forwarded-tcpip` opens** via `server_channel_open_forwarded_tcpip`, routing each to the family handler | `tunnel` | | upstream git consumption | alkgit client/service types | the same exec grammar the server side parses (`git-(upload,receive)-pack '/repo'` exec), pointed at an upstream `sshd` | `git` | | artifact/deploy transfer | alkfs client half (future) | `subsystem "sftp"` + the alkfs protocol | `sftp` (decline placeholder — OQ-13 resolved) | ## The acceptor: fail-closed client-side russh's client handler receives server-initiated channel opens; its default posture for a forwarded open is **accept-and-drop** — a half-capability, which the family forbids (ADR-003 symmetry + `reverse-runner.md` lesson 1). The client contract: - Server-initiated opens are served only when the assembly registered an acceptor **for that type and under its feature** (`forwarded-tcpip` under `tunnel` is the real case — the `-R` data path). - Unregistered/unfeatured accepted opens are **declined cleanly**, never silently dropped after accept. - Acceptor handlers may be installed across a live connection's lifetime (the alkminer `install_forward_sink` late-install dynamic — consumers turn capabilities on/off per connection; the shape is an assembly-mutable acceptor registry). ## Lifecycle primitives (OQ-12 resolution) - Forward listeners return the **actually-bound port** (russh `tcpip_forward -> u32`); every listener/handle is explicitly cancellable; disconnects are explicit (`Disconnect::ByApplication`). - **No reconnect or restore policy is owned by the crate.** Reconnect-and-restore (rebinding persisted tunnel ports — the alkminer flow) is assembly policy; the crate provides the primitives (bound ports, handles, cancellation, clean close) to make that possible. ## What the crate does *not* include (client direction) - **Job supervision** — background-job lifecycle (the alkminer start/stop/logs job model, pid files, host-side ledgers) is assembly work composed over the exec rows; the crate gives the channels and the exit semantics (including the pipe-mode pid-only signal caveat — `mapping.md`'s derivation contract). - **Credential storage policy** — assembly (the vault handle is injected; ADR-005 / OQ-06). - **Trust decisions** — the host-key policy is assembly-provided (OQ-05; the crate default rejects; a permissive case-2 policy for ephemeral fleets is the assembly's to choose — the crate carries no trust model of its own). - **sftp** — the `sftp` feature is the tested decline placeholder on both directions (OQ-13 resolved); the alkfs-protocol mapping arrives with alkfs, as its own ADR. ## Correctness facts to carry (POC-recorded) - `Channel::exec()` (no `request_exec` at this pin); pty/subsystem request failures surface as `ChannelMsg::Failure` via `wait()`, not `Err` from the call — the caller must drain until the expected message, `continue` past the rest. - `window_change`/`signal` are fire-and-forget and must be sent **before** `into_stream()` (the Channel is consumed by conversion); server-side they carry no SSH reply. - Do not consume the channel queue before `into_stream()` — a `wait()` on a fresh channel breaks it (POC-1 §6 caveat), both sides. - Keepalives on cloud fleets: 15 s / 3 (OpenSSH `ServerAliveInterval=15`/`CountMax=3` parity) — with russh's default (none), dead NAT'd connections sit unnoticed until an exec health check fails (`reverse-runner.md` lesson 3). - A rejected channel open is an `Err` from the typed open call (not a `wait()`-match); the connection stays usable after declines. ## Open Questions None open or deferred — see [open-questions.md](open-questions.md) (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-05 (the host-key policy — §The dial surface), OQ-12 (lifecycle primitives only — §Lifecycle primitives), OQ-13 (the sftp decline placeholder — §op rows and §not-include), OQ-07 (the adapter-side `Zeroizing` wrap — ADR-005 decision 3). ## References - ADR-002 (the client `russh` surface + config knobs), ADR-003 (§1a the alksocks composition; the symmetry rule), ADR-004 (fingerprint identity), ADR-005 (key sources), ADR-006 (route-1 bridge, both legs) - `docs/research/reverse-runner.md` (the consumer pattern + lessons), `poc-3-mapping-bakeoff.md` §D.3 (client mechanics), §C.4 (the fabric recipe), `poc-1-auth-bridge.md` §6/§7 (open-call `Err` semantics, plumbing), `poc-2-vault-keys.md` (dial lifecycle) - `mapping.md` (the server-direction mirror rows)