# Mapping table: SSH channel types × family protocol seams Status: Draft (phase 0). The crate's spine. Every row maps a russh server-side entry point onto exactly one family protocol seam plus exactly one cargo feature. Names below are *feature-name candidates*, not decisions; the feature graph is phase 1 ADR territory. All downstream surfaces verified against published sibling source (alktty 0.8.1, alktunnels, alksocks, alkgit 0.1.0, alkcall 0.10). **POC-3 validated the load-bearing rows against the published crates** (`poc-3-mapping-bakeoff.md`): git exec, SOCKS-in-channel, direct-tcpip (route 1), and session/pty all work end to end with vanilla clients and zero seam fixups. Three row corrections from that run are marked **[POC-3 corrected]** below. ## The shape that makes this work Every family protocol crate already exposes a generic duplex-stream or `Connection`-serving entry, so no crate needs to know SSH exists: | Crate | Serve-over-a-stream entry | Signature shape | |---|---|---| | alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` | | alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, T: AsyncRead+AsyncWrite, limits, backend traits)` | | alksocks | `serve_socks5_over` | `(T: AsyncRead+AsyncWrite+Unpin, dial, egress, accept, can_bind)` | | alktunnels | **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead | russh channels convert to duplex via `Channel::{into_stream, split, make_reader/make_writer}` (`ChannelStream`/com halves). So each mapping is: accept-or-decline the channel open (`ChannelOpenHandle`; dropping it auto-rejects, russh's default is already fail-closed), pump/serve the channel through the seam, map the request-level extras. ## Channel-type rows | SSH channel type | Vanilla use | Feature (candidate) | Family seam | Notes | |---|---|---|---|---| | `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY). **[POC-3 corrected]** the data-plane mapping is a *translation bridge*, not a byte pump: alktty's plane is chunk-framed both directions (stdin/stdout/stderr + CTRL_IN/CTRL_OUT chunks), so alkssh encodes SSH data→stdin chunks and decodes stdout→data, stderr→extended_data, exit chunk→exit-status/exit-signal. The exit chunk encodes pipe-path signal termination as the negative code `−N` (alktty ADR-004's convention, `pipe.rs::exit_code_from`); the PTY path reduces signal termination to a plain exit code (portable-pty's exit surface), so PTY-mode `exit-signal` fidelity is an alktty-backend question | | `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1; only valid without PTY | | `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). **[POC-3 corrected]** git over SSH is exec-only: a `subsystem` request carries the service *name* but no repo id, so even a git-service subsystem must decline, as vanilla git always executes `git-upload-pack '/repo'`. POC-3: vanilla clone/push/second-push through the published seam + substrate composition, exit-status in both directions | | `session` → SOCKS5-in-channel | (n/a, retired) | ~~`dynamic-forward`~~ **retired (ADR-003 §1a)** | was alksocks: `serve_socks5_over` | **Post-architecture retirement:** no consumer is ever the family-native client shape this row served. Vanilla `ssh -D` does SOCKS client-side and opens plain `direct-tcpip` (POC-3 correction 2 → the `tunnel` row); the family `-D` behavior is a *client-local* alksocks door over the alkssh client's `direct-tcpip` opens (assembly composition). POC-3 stage B remains the evidence that SOCKS-in-channel is feasible if a family client ever wants a remote SOCKS door (back via explicit ADR). **[ADR-003 amended]** | | `direct-tcpip` | `ssh -L` | `tunnel` | alktunnels (two routes, below) | Target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix | | `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape; in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below | | `direct-streamlocal` | unix-socket forward | `tunnel` | same `SubstrateAddr::Path` treatment | unix-only; decline on other platforms (compile-time feature + runtime decline) | | `x11` | X forwarding | **never** (no feature) | none | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) | | `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder: the feature name exists in the feature graph, decline until alkfs lands | | `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | none | decline; family holds keys in alkvault, since forwarding agents to remote sessions is the anti-pattern this posture rejects | Notes: the rows deliberately split one channel type (`session`) across three features, because vanilla SSH multiplexes everything through a session channel: a git command (`exec_request`), a SOCKS proxy client, a shell, a pipe-mode command. The failure matrix this produces: - git-only build: declines pty/shell, declines plain exec, declines SOCKS-in-channel; serves only the git-parseable exec/subsystem path. - session-only build: serves pty/shell/pipe-exec; the git-parseable exec path declines (parsing to a git service is gated on `git`). - No features: every session channel declines at open; every unfeatured channel type declines at open. Dispatch is parse-then-route by command shape: parsing to a git service and repo requires `git`; otherwise the alktty pipe/pty path requires `session`. Parsing happens before dispatch and before any resource allocation (a leaked channel alloc is a capability leak, so decline first).. (Post-architecture: the former SOCKS-detect dispatch arm is retired with the `dynamic-forward` feature, per ADR-003 §1a / ADR-006 amendment; request-less session channels decline.) Grammar/ precedence per ADR-006. ## Global-request rows (`tcpip_forward` / `streamlocal_forward`): the `-R` listen flow `Handler::tcpip_forward(address, &mut port) -> bool` + per-open `channel_open_forwarded_tcpip`. Consumer-side alktunnels: `TunnelListener`, `open_reverse_channel`; the forwarded data channel maps to the `ForwardedHandler` route above. Dispositions follow the `tunnel` feature (decline both listen and data when `tunnel` absent). `port = 0` (server picks) interacts with the `&mut u32` out-param; spec detail deferred to phase 1. ## Request-level mapping on a `session` channel | SSH request | Family mapping | |---|---| | `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{term, cols, rows, pixel_w, pixel_h, modes}` | | `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ: probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) | | `exec_request` | Parse command → git/service dispatch or alktty pipe `cmd`; unknown commands fail with `session.channel_failure` (parse-then-decline, no resource alloc) | | `subsystem_request` | `sftp` → decline until `sftp` feature serves alkfs; git subsystem names → same parse as exec; anything else → decline | | `env_request` | **Decline by default.** Wire-supplied env is not family identity config; `TtyParams.env` comes from assembly. (OQ: allowlist shape, e.g. `LANG`, later.) | | `window_change_request` | `TtyControlHandle::resize(cols, rows, pixel_w, pixel_h)` | | `signal` | `TtyControlHandle::signal(name)` (alktty's `signal_from_name` accepts the HUP/INT/TERM/... names verbatim) | | data / `extended_data(1)` | channel duplex in/out; stderr only meaningful in pipe mode (PTY merges) | | `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated on the pipe path; `pipe.rs::exit_code_from` maps Unix `ExitStatus::signal()` to `−sig`; the PTY path reduces signal termination to a plain exit code, see the exit_signal row), so alkssh sends the SSH `exit-status` request before channel close (POC-3: proven both directions via the cloned `Session::handle()` from the spawned serving task); `−1` (alktty's could-not-determine sentinel, alktty ADR-004 §4) → `exit-status(255)` | | `exit_signal` | derived server-side from the exit chunk's negative code, `−N` → name via a Unix signal-number→name table; `core_dumped` never asserted (alktty discards `ExitStatus::core_dumped()`); no chunk change is needed or proposed (the encoding is alktty's documented contract, alknet ADR-055 lineage). POC-3 observed `−15` → `exit-signal("TERM")` live on the pipe path. PTY path: portable-pty 0.9's `ExitStatus` conversion yields `code = status.code().unwrap_or(1)` (**1**) for signal-terminated children, keeping the signal only in a separate humanized (`strsignal`) string field alktty's waiter discards, so faithful `exit-signal` on the PTY row needs the alktty backend to conform to its own ADR convention (upstream ask: alktty/portable-pty trackers; the alkssh contract adapts automatically) | ## Data-plane / limits contract - russh windows: `Config::window_size`, `maximum_packet_size`, `channel_buffer_size` cap per-channel memory; `Limits` caps rekey volume; `inactivity_timeout` the session wall clock, and the family bounded-resources convention maps onto these knobs plus the crate's own max-concurrent-channels (per session) and the per-length parse limits on exec commands. - alkcall bridge leg (when the family half is remote): channels-layer reassembly cap 1 MiB/channel, chunk max 16 MiB, per-identity channel-count policy, so an SSH channel bridged over `alk/channels` rides one reassembled byte stream per SSH channel; SSH's own multiplexing is why ADR-034 grants `alk/ssh` only stream_types `[0, 1]` ("SSH multiplexes internally, including its own control"). - Honest advertisement: `Config::methods` mirrors implemented auth; an unfeatured channel type is *declined at open*, never half-opened. Vanilla clients see a clean failure, which is the contract. ## alktunnels absence note alktunnels has **no standalone `serve_*_over(stream)` producer entry** (unlike alktty/alkgit/alksocks). The `direct-tcpip` mapping therefore has two legitimate routes, and choosing between them is a phase 1 decision: 1. **Adapter-side bridge**: alkssh is itself an alk/channels *client* (it holds a `ChannelClient` into the family fabric) and calls `TunnelSession::open_direct(client, target, substrate)` / `TunnelSession::adopt(...)` directly, pumping `take_halves()`/ `stream_halves()` against the russh channel. (alksocks' `composed::direct_egress` is the family precedent for exactly this bridging.) 2. **Registry-side bridge**: within an alkcall serving endpoint, `register_tunnel_direct_openable` with a `DialFn`, but that puts alkssh-shaped work in the serving endpoint's registry, which inverts the door posture. Likely rejected on composability grounds; recorded here for the ADR to decide explicitly. ## Topology: where the `alk/ssh` ALPN itself lives Three alkcall ADR constraints collide productively: - ADR-004 reserves `alk/ssh` → `SshAdapter` (custom protocol). - ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]`; SSH multiplexes internally, the channels layer is byte-blind to it. - ADR-042/051: "The hub never runs a handler for `alk/tty`, `alk/ssh`, or `alk/tunnel`": `alk/ssh` channels *relay payload-blind* between spokes; ACL fires at `channel/open`. ADR-009 additionally sketches a browser route: a WebTransport session to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle` as a `Connection`, with a WASM SSH parser speaking SSH over it. Reading: `alk/ssh` is plausibly *SSH-over-channels*: the raw vanilla- SSH byte stream carried between alkssh instances (edge termination at one end, mapped serving at the other), reused by the browser route. Whether the edge-terminated topology (russh at the edge, family seams in-process or via `alk/channels` hops) ever needs to speak `alk/ssh` itself, and what `SshAdapter::handle(connection)` does for the browser route (it would be alkssh's russh wrapper serving an inbound alkcall connection), is the phase 0 topology question. POC-3's design will attempt the simplest composition; the decision records itself in the phase 1 ADR-001 (ALPN pin, ledger rule 1). ## Open questions seeded for phase 1 - Session-channel dispatch precedence + the exec-command grammar (git parse vs pipe cmd), a parse-then-fail-closed contract. - Shell command convention for `shell_request` (assembly policy vs wire-supplied; `wd`/`env` provenance). - `env_request` policy (decline-only vs allowlist). - OQ: OpenSSH certificate auth (`auth_openssh_certificate`): decline until a use case. - OQ: username disposition (ignored entirely vs a routing hint). - Feature-graph names + the `session`-vs-`git`-vs-`dynamic-forward` split inside one channel type. - The `alk/ssh` topology question above. - Windows: `direct-streamlocal` is unix-only; `pty` on Windows targets depends on alktty backend capability (decline rather than degrade). ## References - `russh.md` §server surface (handler inventory, Config) - `auth-bridge.md` (identity arrives before any channel opens) - alkcall ADRs 002, 004, 009, 034 (+035 chunk header), 037, 042, 051 - Sibling sources: alktty `src/adapter.rs`/`src/negotiation.rs`/ `src/backend.rs`/`src/channels.rs`/`src/session.rs`, alktunnels `src/consumer.rs`/`src/producer.rs`/`src/params.rs`, alksocks `src/producer.rs`/`src/composed/mod.rs`, alkgit `src/duplex.rs`/`src/preamble.rs`/`src/substrate.rs`/`src/session.rs`