docs(research): phase 0 research set — vision, russh, auth bridge, mapping table, POC plan

The phase 0 document set (documented only; no POCs run yet):

- docs/research/vision.md — guiding principles (russh owns the wire,
  door-not-backend, fail-closed everything, one seam per mapping,
  family identity not SSH identity); non-goals (no sshd, no sftp until
  alkfs, no password auth); the five scope questions phase 0/1 must
  answer
- docs/research/russh.md — 0.64.1-era inventory: server/client
  Handler method tables, Config knobs, keys module (ssh-key re-export,
  fingerprint API), the RC-pinned dependency tree (ssh-key, rsa,
  pkcs1), MSRV/edition/license posture; alknet-main referenced as a
  rough example only, explicitly not decision input
- docs/research/auth-bridge.md — the alkcall mapping: no
  resolve_from_signature exists; the actual surface is
  IdentityProvider::resolve_from_fingerprint fed by russh-verified key
  fingerprints in auth_publickey; auth-method disposition matrix
  (only publickey implemented, all defaults overridden); client side
  mirrors alkhttp ADR-014 (key sources, not static keys; vault
  client-side only); the vault-to-russh-key bridge named as the
  missing design piece
- docs/research/mapping-table.md — the spine: channel-type rows with
  feature candidates and the verified sibling serve/consume entries
  (alktty drive_session, alkgit serve_duplex_resolved, alksocks
  serve_socks5_over, alktunnels consumer wrappers + the no-serve-over-
  stream absence note); request-level rows; parse-then-route dispatch
  inside the session channel; the alk/ssh ALPN topology question
  (ADR-004 reserved / ADR-034 stream_types [0,1] / ADR-042 hub never
  runs it); alktunnels route choice flagged for ADR
- docs/research/pocs.md — POC-1 (auth bridge + fail-closed declines
  against vanilla OpenSSH), POC-2 (vault-to-russh key bridge:
  PrivateKey extraction vs vault-backed Signer), POC-3 (mapping
  bake-off stages A-D: git exec, socks -D, tunnel -L, pty); all
  standalone mode; findings docs are the deliverables

Verification: cargo doc --no-deps, cargo fmt --check, cargo test
(docs-only change; lib untouched)
This commit is contained in:
glm-5.3-flash committed 2026-10-25 10:13:17 +00:00
1 parent e0805cd438
commit ecdf836eeb
7 files changed
+830 -1

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
# Mapping table — SSH channel types → family protocol seams
Status: Draft (phase 0 research). 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).
## 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) |
| `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). `subsystem` names also map here (`git-upload-pack` etc. arrive as either); unknown subsystems decline |
| `session` → SOCS5-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 |
| `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 |
| `exit_signal` | Only reachable in signal-termination cases; mapped from the same exit-code path (OQ: exact encoding) |
## 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`