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)
13 KiB
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 |
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_sizecap per-channel memory;Limitscaps rekey volume;inactivity_timeoutthe 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/channelsrides one reassembled byte stream per SSH channel; SSH's own multiplexing is why ADR-034 grantsalk/sshonly stream_types[0, 1]("SSH multiplexes internally, including its own control"). - Honest advertisement:
Config::methodsmirrors 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:
- Adapter-side bridge: alkssh is itself an alk/channels client
(it holds a
ChannelClientinto the family fabric) and callsTunnelSession::open_direct(client, target, substrate)/TunnelSession::adopt(...)directly, pumpingtake_halves()/stream_halves()against the russh channel. (alksocks'composed::direct_egressis the family precedent for exactly this bridging.) - Registry-side bridge: within an alkcall serving endpoint,
register_tunnel_direct_openablewith aDialFn— 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/sshgets 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, oralk/tunnel" —alk/sshchannels relay payload-blind between spokes; ACL fires atchannel/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/envprovenance). env_requestpolicy (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-forwardsplit inside one channel type. - The
alk/sshtopology question above. - Windows:
direct-streamlocalis unix-only;ptyon 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, alktunnelssrc/consumer.rs/src/producer.rs/src/params.rs, alksockssrc/producer.rs/src/composed/mod.rs, alkgitsrc/duplex.rs/src/preamble.rs/src/substrate.rs/src/session.rs