Files
alkssh/docs/research/mapping-table.md
T
glm-5.3-flash 9fab51d4a8 docs(research): POC-3 findings — mapping bake-off validated end to end
All four stages ran against vanilla clients (OpenSSH -L/-R, real git,
real curl) and the published crates.io seams with zero seam fixups
(51/51 checks, reproduced twice; POC source /workspace/alkssh-poc3):

- stage A: vanilla git push/clone/second-push through russh exec
  parse -> alkgit serve_duplex_resolved + alkgit-substrate mem
  composition; exit-status proven both directions; denial is
  repo-blind before any protocol byte
- stage B: alkgit serve_socks5_over over a session channel with the
  shipped tcp_dial; real curl through the proxy; 8 MiB throughput
- stage C: direct-tcpip via alktunnels route 1 (TunnelSession::
  open_direct over a real alkcall ChannelClient + local_dial);
  vanilla ssh -L + curl; -R global request declines (honest
  advertisement); opener identity rides the transport into the
  establisher witness (CF-005)
- stage D: session/pty mapping via a translation bridge (SSH bytes
  <-> alktty chunk plane) and drive_session_pre_negotiated over a
  real PTY: pty params, window-change resize, pipe stderr as
  extended_data, exit chunk -> exit-status/exit-signal

Corrections recorded in mapping-table.md (git subsystem over SSH is
exec-only; vanilla ssh -D speaks SOCKS client-side and opens
direct-tcpip; the alktty bridge is chunk-framed both ways with the
exit-chunk signal-identity gap), plus the load-bearing russh 0.64.1
runtime facts in russh.md (inline handler dispatch — serving paths
must spawn; Session::handle() exit reporting; client drain-to-target
message routing).

Verification: cargo test / clippy -D warnings / fmt --check / doc on
alkssh (docs-only change); POC crate builds warning-free and runs
overall=true.
2026-10-25 14:44:35 +00:00

13 KiB
Raw Blame History

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 — 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. New OQ: the exit chunk carries no signal identity (negative code only)
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 — 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 ssh -D dynamic-forward alksocks: serve_socks5_over(channel_duplex, dial, egress, accept, can_bind) alkssh embeds Socks5Producer-shaped config from the assembly; DialFn/EgressFactory injected at assembly. [POC-3 corrected] this row is the family-native client shape — a vanilla ssh -D does its SOCKS5 client-side and opens plain direct-tcpip channels (the tunnel row above), so the two rows split by client shape, not just feature. POC-3: SOCKS-detect (bounded first-byte sniff) + real curl through the session channel
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) — 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 — decline; family holds keys in alkvault — 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; SOCKS-in-channel requires dynamic-forward; 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 — decline first). Exact grammar/precedence is a phase 1 spec question (OQ candidate).

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) — 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)
exit_signal Only reachable in signal-termination cases; mapped from the same exit-code path (OQ: exact encoding). [POC-3] the alktty exit chunk is {"type":"exit","code":N} — a negative code implies signal termination but carries no name, so the faithful exit-signal mapping needs alktty to widen the chunk or an assembly-policy default

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 — 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 — 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) — 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