Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
218 lines
12 KiB
Markdown
218 lines
12 KiB
Markdown
---
|
||
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<String>` 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) |