Files
alkssh/docs/research/mapping-table.md
T
glm-5.3-flash c7ab19ed8a docs(architecture): retire the dynamic-forward feature — the -D untangle
Review untangle (three layers: local door / wire protocol / fabric):
- vanilla ssh -D binds its SOCKS5 proxy client-side (the local door —
  alksocks' local-door shape) and opens plain direct-tcpip per
  connection: the server needs nothing SOCKS-shaped (tunnel row +
  route-1 bridge cover the leg; POC-3 correction 2 already said this)
- the family-native SOCKS5-in-channel server row served no consumer
  of ours: family apps compose alksocks' local door locally over the
  alkssh client's direct-tcpip DialFn — alkssh doesn't depend on
  alksocks on this path
- amendments: ADR-003 §1a (feature removed), ADR-006 point 3 (SOCKS-
  detect retired; session dispatch is parse-only; request-less
  session channels decline), OQ-02 resolution updated with the
  simplification, mapping-table.md rows annotated (research doc keeps
  POC-3 stage B as the re-add feasibility record)
2026-10-26 15:32:08 +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 (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) — 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; 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). (Post-architecture: the former SOCKS-detect dispatch arm is retired with the dynamic-forward feature — 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) — 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