--- status: reviewed last_updated: 2026-10-29 --- # 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; the 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, a vault-backed blob: decrypt-at-dial, fail-closed on locked/absent, never env vars, never plaintext at rest), **host-key policy** (the 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, and the crate is policy-blind), and the bounded-resource config (`keepalive_interval`/`keepalive_max`, a 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()`, not `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** (the real case is `forwarded-tcpip` under `tunnel`, 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). > **The landed acceptor shape (`w4-reverse-listen`, recorded at > landing):** the registry is **per-type fixed slots**, exactly the > two `-R` data-leg types the client direction can serve > (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`; > no map, no generic key), one acceptor trait per type; install is > **refuse-never-replace** (the `ListenRouter::register` recorded > posture, mirrored client-side); the slots are read **at arrival > time** by the dial handler's hooks, so a pre-install/unfeatured open > is declined at arrival, never buffered. The payload an accepted > open hands the assembly: the channel duplex (family-typed, the > `-L` rows' `TunnelChannel` byte duplex, one type both legs ride) + > the open's echo/originator fields; **the pump is the acceptor's** > from the handoff on (the family handler shape), and the crate keeps no > accepted-open ledger. Bounded bookkeeping, the cap relation: the > registry bounded by construction (two slots); the > **reverse-listen count** and the **accepted-open handoff count** > are two dedicated counters, each against the same > `Limits::max_concurrent_channels` ceiling (the server-side > listener/ListenerCap posture mirrored: independent counts, one > ceiling; the listens live at the peer, and the counter exists so a dial > cannot mint an unbounded listener set). The **forward-request rows > gate on the registered acceptor** (the issuing-side disposition: > a listen whose data leg cannot serve is never asked for; > install-first, `OpError::NoAcceptor` pre-wire), plus > `OpError::ListenRefused` (the peer's `RequestDenied`: > denied > listens and unknown-listener cancels, sshd raw-string parity) and > `OpError::ListenCapExceeded` (the ceiling, pre-wire). The > **runtime-Unix gate** rides the unix *listen* rows only (the bind > is the platform-bound op; the `forwarded-streamlocal` ingestion is > platform-neutral byte pumping). The `tcpip_forward` reply's mint > rides only for `port = 0` requests (russh/sshd parity: an explicit > port's bound *is* the requested port, which the row surfaces). ## 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. > **The landed lifecycle surface (`w4-lifecycle`, recorded at > landing):** the `-R` listens return **`ReverseListener` handles**, > with the `ReverseEndpoint` carried (the *actually bound* values: > the wire address + the bound port / the socket path, the > assembly's own record for a rebind policy it may compose) and the > explicit `cancel()` as the wire-honest teardown (the confirmed cancel > also releases the connection's listener count; a repeat rides the > wire and hits the unknown-listener refusal). The **stateless** > cancel forms (`reverse_listen_cancel` + the unix twin) ride beside > the handles 1:1 (sshd's global-request cancels are stateless, so a > cancel by the raw wire strings works from anywhere, including > after the handle itself is gone: the handle shares the > connection's internals through the arc, so its `cancel()` is the > same wire op). **No `Drop` hides a wire op** on any of these > handles: a cancellation/bundle of the wire ops is async and cannot > run in a sync `Drop`, and at this russh pin dropping the last > wire-stack handle ends the client runner, so the transport *breaks* > (the peer observes an error-class session end, never the clean > disconnect message). The primitives-not-policy posture is the > module-docs contract: `client.rs` §Lifecycle posture (the > primitives list + the abrupt-drop/clean-close boundary, > row-pinned both ways) and `client_accept`'s recorded decisions; > the alkminer reconnect-and-restore flow stays assembly > composition (reverse-runner fact 4). ## 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 from `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, since 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) > **The landed op-row shape (`w4-op-rows`, recorded at landing):** > the exit-*consuming* rows (session exec/pty, git exec) ride the > **split-halves wait-pump** (`Channel::split()` + a spawned task > draining `wait()`), never `into_stream()`: the conversion's reader > side swallows exactly the non-`Data` messages the exit reports ride > on, so a wait-pump is the only shape that can consume them (the > same reason `w4-git-exit-close` pinned the split-halves shape > server-side). Only the exit-less rows (the tunnel directs) use > `into_stream()`, straight after the typed open; no queue > consumption ever precedes a conversion, which is POC-1 §6's caveat > respected by construction. The session rows' family plane is the > alktty chunk framing both directions (stdin/`CTRL_IN` out, stdout/ > stderr in, the family exit chunk in-band, last, per `exit_code.rs`'s > two-arm contract: the server arm derives wire reports from family > codes, the client arm mirrors wire reports into family codes, > `−signum` through the one table, the `−1` sentinel never widened). > The op surface carries no russh types: `OpError` mirrors the typed > open's decline classes (`OpenDeclineReason`), the `wait()`-drain's > `Failure` reply is `RequestRefused`, and the sftp placeholder > declines in every build before any wire op (OQ-13). - `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 and `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), since 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 (closed upstream: the vault returns a `Zeroizing` from alkvault 0.2.0; ADR-005 decision 3 amended, the adapter-side wrap dropped). ## 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)