- src/client_accept.rs: the assembly-mutable acceptor registry (per-type fixed slots under `tunnel`; refuse-never-replace install; payload = the `TunnelChannel` duplex + the open's echo/originator fields — the harness `ForwardedAcceptor` vocabulary grown), the reverse-listen rows (bound-port surfaced; the listen/cancel forms; the runtime-Unix gate at the listen), and the accepted-opens' spawned handoff tasks. - src/client_handler.rs: the `forwarded-tcpip` / `forwarded-streamlocal` hooks route through the registry at arrival time — registered + admitted → accept + handoff; unregistered/unfeatured/cap-exhausted → the clean `AdministrativelyProhibited` decline (never accept-and-drop); pre-install arrivals decline, never buffered. - src/client.rs: the dial wires the empty registry into the handler + the connection (`SshConnection::acceptors`, the late-install accessor) + the listener budget's dedicated counter. - src/error.rs: `OpError::NoAcceptor` / `ListenRefused` / `ListenCapExceeded`; `RequestDenied` → `ListenRefused` (the listen/cancel forms' wire decline class; the port-0 mint rides alone at this pin — an explicit port's bound *is* the requested one, surfaced). - tests/common/sshd_double.rs: the fake `sshd`'s `-R` half — the listen/cancel hooks bind real sockets (the out-param mint is the real bound port), the accept loops open the server-initiated forwarded channels + pump per accepted connection, cancels match by the raw wire strings and tear the listener down. - tests/client_reverse_listen.rs: the contract rows (listen + data leg, the mint, the pre-wire gates, the declines, late-install on/off, the cancels, the ceiling, the unix twins). - tests/tunnel_mapping.rs: the full-circle client ↔ server-direction smoke (both `-R` legs through the alkcall fabric double); real- `sshd` `-R` stays wave-5 e2e (recorded). - client.md: the acceptor's landing record (shape, cap relation, gates, unix-gate placement, port-0 parity). - fix(w4-op-rows, test cfg): the test mod's `family_exit_code` import gated to the exit-consuming builds (`--features tunnel` did not compile its tests before). Verification: cargo test (default / each feature / --all-features) green; cargo clippy --all-targets -- -D warnings (none + --all-features) clean; cargo fmt --check clean; cargo doc --no-deps clean (default build; all-features carries only the pre-existing client_ops private-link warnings).
11 KiB
status, last_updated
| status | last_updated |
|---|---|
| reviewed | 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 — 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-tcpipundertunnelis the real case — the-Rdata 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_sinklate-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-Rdata-leg types the client direction can serve (forwarded-tcpip,forwarded-streamlocal, both undertunnel; no map, no generic key), one acceptor trait per type; install is refuse-never-replace (theListenRouter::registerrecorded posture, mirrored client-side); the slots are read at arrival time by the dial handler's hooks — 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-Lrows'TunnelChannelbyte 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) — 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 sameLimits::max_concurrent_channelsceiling (the server-side listener/ListenerCap posture mirrored — independent counts, one ceiling; the listens live at the peer, 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::NoAcceptorpre-wire), plusOpError::ListenRefused(the peer'sRequestDenied— denied listens and unknown-listener cancels, sshd raw-string parity) andOpError::ListenCapExceeded(the ceiling, pre-wire). The runtime-Unix gate rides the unix listen rows only (the bind is the platform-bound op; theforwarded-streamlocalingestion is platform-neutral byte pumping). Thetcpip_forwardreply's mint rides only forport = 0requests (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.
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
sftpfeature 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 drainingwait()), neverinto_stream()— the conversion's reader side swallows exactly the non-Datamessages the exit reports ride on, so a wait-pump is the only shape that can consume them (the same reasonw4-git-exit-closepinned the split-halves shape server-side). Only the exit-less rows (the tunnel directs) useinto_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_INout, stdout/ stderr in, the family exit chunk in-band, last —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,−signumthrough the one table, the−1sentinel never widened). The op surface carries no russh types:OpErrormirrors the typed open's decline classes (OpenDeclineReason), thewait()-drain'sFailurereply isRequestRefused, and the sftp placeholder declines in every build before any wire op (OQ-13).
Channel::exec()(norequest_execat this pin); pty/subsystem request failures surface asChannelMsg::Failureviawait(), notErrfrom the call — the caller must drain until the expected message,continuepast the rest.window_change/signalare fire-and-forget and must be sent beforeinto_stream()(the Channel is consumed by conversion); server-side they carry no SSH reply.- Do not consume the channel queue before
into_stream()— await()on a fresh channel breaks it (POC-1 §6 caveat), both sides. - Keepalives on cloud fleets: 15 s / 3 (OpenSSH
ServerAliveInterval=15/CountMax=3parity) — with russh's default (none), dead NAT'd connections sit unnoticed until an exec health check fails (reverse-runner.mdlesson 3). - A rejected channel open is an
Errfrom the typed open call (not await()-match); the connection stays usable after declines.
Open Questions
None open or deferred — see 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
russhsurface + 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-callErrsemantics, plumbing),poc-2-vault-keys.md(dial lifecycle)mapping.md(the server-direction mirror rows)