From 9fab51d4a862122bddc4844d6c8719a3b2aa73f4 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 25 Oct 2026 14:44:35 +0000 Subject: [PATCH] =?UTF-8?q?docs(research):=20POC-3=20findings=20=E2=80=94?= =?UTF-8?q?=20mapping=20bake-off=20validated=20end=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/research/mapping-table.md | 20 +- docs/research/poc-3-mapping-bakeoff.md | 375 +++++++++++++++++++++++++ docs/research/pocs.md | 17 +- docs/research/russh.md | 18 ++ 4 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 docs/research/poc-3-mapping-bakeoff.md diff --git a/docs/research/mapping-table.md b/docs/research/mapping-table.md index 81f7384..1d7c5c6 100644 --- a/docs/research/mapping-table.md +++ b/docs/research/mapping-table.md @@ -1,12 +1,18 @@ -# Mapping table — SSH channel types → family protocol seams +# Mapping table — SSH channel types × family protocol seams -Status: Draft (phase 0 research). The crate's spine. Every row maps a +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 @@ -30,10 +36,10 @@ request-level extras. | 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` → `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). `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 | +| `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) | @@ -83,8 +89,8 @@ spec detail deferred to phase 1. | `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) | +| `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 diff --git a/docs/research/poc-3-mapping-bakeoff.md b/docs/research/poc-3-mapping-bakeoff.md new file mode 100644 index 0000000..0cc45bd --- /dev/null +++ b/docs/research/poc-3-mapping-bakeoff.md @@ -0,0 +1,375 @@ +# POC-3 findings — the mapping bake-off: channel bridge through real sibling seams + +Status: Complete (phase 0). POC source: `/workspace/alkssh-poc3` +(standalone crate, disposable — never merges). All four stages ran +against **vanilla clients** (OpenSSH 9.6 `ssh -L`/`-R`, real `git` +2.43, real `curl` 8.5) and the **published crates.io forms** of every +sibling — no path dependencies, no in-family forks. The doc records +the evidence: three corrections to `mapping-table.md`, the russh +0.64.1 runtime facts phase 1 must code around (the inline-dispatch +rule), the identity/scope live check, and the OQ seeds. + +## Setup exercised + +- russh **0.64.1** (crates.io, ring backend — the same published tree + POC-1/POC-2 pinned; re-verified still the newest release). Same + dependency tree as both prior POCs across all four stages. +- Sibling seams, published forms: **alkgit 0.1.0 + + alkgit-substrate 0.1.0** (the shipped backend composition, with + `alkstore-mem` engine + `alkblobs::Store::mem_only()` object pool — + the substrate's own mem-assembly shape, `SubstrateAssembly::mem`), + **alksocks 0.3.1** (`local` feature — the shipped `tcp_dial`), + **alktunnels 0.4.1** (`local` feature — the shipped `local_dial`), + **alktty 0.8.1** (`local` feature — the shipped `LocalTtyBackend` + over portable-pty), **alkcall 0.10.1** (channels + identity types). +- Servers per stage were built as **feature-shaped builds**: stage A's + server serves only git-parseable exec (a git-only build), stage B's + only SOCKS-on-session (a dynamic-forward shape), stage C's only + direct-tcpip (an egress-tunnel shape that also declines the `-R` + global request), stage D's only pty/shell/pipe-exec (a session + shape). The auth loopback is POC-1's verbatim shape (fingerprint + registry fed from `ssh-keygen -lf`, `auth_publickey_offered` + accept-and-decide, all other methods declined). +- POC mechanics: single binary, stage functions returning check + reports (POC-1's harness); full clean runs reproduced twice. + +## Stage A — git exec mapping (24/24 PASS) + +### 1. Vanilla git works through the seam with zero seam fixups + +`git push` → `git clone` → rev parity → second push (have/star +negotiation on the populated repo) all succeeded through: + +``` +vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server + → exec_request parse (the alkssh-shaped grammar) + → serve_duplex_resolved(repo_id, identity, service, channel.into_stream(), + Limits, registry, refs, packgen, ingest, false, pipeline) +``` + +with the **real alkgit-substrate mem composition** as the backend — +registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an +in-memory object pool. The published alkgit seam needed no wrapper, no +adapter glue beyond the command parse, and no dialect nudges. The +hypothesis that phase-1's mapping ADR gated on (stage A gates the +ADR work) is confirmed. + +### 2. The SSH exec-command grammar (the alkssh-side parse) + +What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'` +— single-quoted repo path with a leading `/` (the parse strips quotes +and the leading slash; no `\0host=` rides the SSH exec — the +mapping-table's "same grammar as preamble.rs, without `\0host=`" is +exactly right). The POC grammar table (all rows verified): + +| exec command | verdict | +|---|---| +| `git-upload-pack '/r'` / `git-receive-pack '/r'` / `git-upload-pack /r` | git parse, dispatched | +| `git-send-pack '/r'`, `git-upload-archive '/r'` | declined (unknown service, honest surface) | +| `git-upload-pack` (service-only, no repo) | declined — SSH exec must carry the repo | +| `uptime` (non-git) | declined parse-then-fail, no resource alloc | + +### 3. Subsystem mapping row — corrected at the detail level + +`mapping-table.md` says git subsystem names "arrive as either" (exec +or subsystem). On the SSH wire a subsystem request carries **only the +name** — no repository is available to resolve. The POC proves the +forced decline: `subsystem("git-upload-pack")` and `subsystem("sftp")` +both fail cleanly with a channel failure, with the finding recorded: +*over SSH, git always uses exec; a subsystem git request can never +dispatch (no repo id), so the correct mapping is decline-with-reason*. +The mapping-table row needs the detail fix. + +### 4. russh runtime facts phase 1 must code around (new, load-bearing) + +- **Server handler callbacks run inline inside the session's packet + loop.** Blocking inside `exec_request`/`pty_request`/`shell_request` + (or any handler) suspends packet processing for the *entire + connection* — the awaited channel data is only routed by that same + loop, so an inline serve **deadlocks** (receive-pack pushes bytes + immediately after the exec request). Confirmed by construction: the + serving entry is spawned and the handler returns immediately. This + is the binding implementation constraint for the alkssh adapter: + **every serving path is `tokio::spawn` + handler returns fast**. +- The spawned task gets exit reporting through + **`Session::handle()`** — a cloneable server-side `Handle` + carrying `data` / `extended_data` / `eof` / `close` / + `exit_status_request` / `exit_signal_request`. The exit-status + mapping row is implementable exactly as the table prescribes: send + `exit-status` before `close` from the serving task. +- The accepted-but-held `Channel` can be stored in the handler and + taken later in the request callback (`channels.remove(&id)` → + `into_stream()` in the spawned task) — no `wait()` anywhere + (POC-1's caveat holds server-side too). + +### 5. Exit-status fidelity, denial fidelity, vanilla diagnostics + +- Vanilla git paths exit 0 (the exit-status request rode the wire); + an unknown repo produces the collapsed generic denial *before any + protocol byte* (no existence oracle) and vanilla git reports + `fatal: remote error: access denied: owner/absent`, exit 128. +- A russh client exec against the absent repo observed + **`ExitStatus{1}`** after the denial band — the exit-status flow is + proven in both directions, including the failure leg. +- The git-only build's decline matrix held under vanilla ssh: + `ssh host uptime` failed with the parse-then-fail decline logged; + `pty_request`/`shell_request`/`env_request` declined with failure + replies (russh client observed `ChannelMsg::Failure`); the fail- + closed feature matrix of the mapping table is live end to end. + +## Stage B — SOCKS5-in-channel (6/6 PASS) + +### 1. `serve_socks5_over` works over a russh session channel + +Construction: server accepts the session channel, spawns the +**SOCKS-detect** (first-byte sniff via `BufReader::fill_buf`: `0x05` += RFC 1928 greeting), and routes to +`serve_socks5_over(stream, dial, None, None, false)` with the shipped +`alksocks::tcp_dial()` (the `local` feature's DialFn, resolving +domain-form targets). Client side: a local bridge listener pumps each +accepted TCP socket bidirectionally into one authenticated session +channel. **Real `curl --socks5-hostname` through the whole chain** +returned the endpoint body byte-identically — CONNECT over the SSH +wire, dialed at the server through the family seam. + +### 2. OpenSSH's own `-D` never speaks SOCKS on the wire — mapping row corrected + +This is a **mapping-table correction**: vanilla `ssh -D` performs the +SOCKS5 handshake *client-side* and then opens plain **`direct-tcpip` +channels** per connection. The `session` → SOCKS-in-channel row is +therefore the family-native client shape (an embedder/alktty-style +client speaking SOCKS5 on the channel), not what a vanilla `ssh -D` +emits — vanilla `-D` traffic takes the stage-C row (direct-tcpip → +egress). Both rows are real; the table should say which client shape +each serves. (POC evidence: this stage's proxy uses the session +channel; stage C's `-L`-style row serves forwarded and SOCKS-originated +direct-tcpip alike.) + +### 3. The dispatch split is real — and needs a bounded detect window + +Parse-then-route (stage A) and SOCKS-detect (this stage) are the two +session-channel dispatch modes the mapping table predicted, and both +work. The detect runs in a spawned task with a bounded window (500 ms +in the POC): the first byte decides the route; a silent channel is +declined. Two consequences for the ADR: (a) the detect must not block +inline (the §A.4 deadlock rule applies — the sniffed byte arrives via +the same loop that awaits it); (b) the *detect window* is a design +shape to pin — an OQ seed: whether the dispatch is a first-byte sniff, +an in-band preamble, or an exec-request marker chosen by the assembly. + +### 4. Refusals are RFC-faithful through the seam + +A CONNECT to a dead port: the dial seam returns +`SocksDialError::Refused` → alksocks replies the mapped RFC code → +curl exits **97** (proxy-connection-not-allowed class) with no data +crossing. Backpressure/throughput: **8 MiB through the session-channel +proxy in ~585 ms** (in-process route; the 1 MiB channels-reassembly +leg is not on this path). Non-SOCKS first byte on a session channel → +declined, logged — no HTTP serving happens on the SOCKS route. + +## Stage C — direct-tcpip via alktunnels route 1 (8/8 PASS) + +### 1. The bridged route works end to end with the published crates + +Route 1 (adapter-side bridge) exercised as the mapping table's route +note describes: the russh server holds a **real `ChannelClient`** into +a family fabric — an in-process `alk/channels` connection whose +serving side (the `ChannelsAdapter` accept end) registers +`ChannelOperations` + `register_tunnel_direct_openable` with the +shipped `alktunnels::local::local_dial()` injected. Per vanilla `ssh +-L` channel: + +``` +channel_open_direct_tcpip → TunnelSession::open_direct( + &client, SubstrateAddr::HostPort{host, port}, Substrate::Tcp) + → take_halves() → pump_bidi(channel.into_stream(), read, write) +``` + +Vanilla `ssh -L` + `curl` to the bridged port returned the endpoint +body byte-identically; **8 MiB through the bridge in ~333 ms**. The +`direct_egress` precedent in alksocks' `composed` feature is the same +shape — the family precedent survives contact with the SSH door. + +### 2. Failure shapes at both ends of the bridge + +- Fabric open failure → the SSH channel is rejected **explicitly** + (`ChannelOpenHandle::reject(ConnectFailed)`); accept is possible + only after `open_direct` succeeds because `accept(mut self)` + consumes the handle — so the handle moves into the spawned task. + A vanilla client dials a dead target and gets a clean curl failure + (exit 56) instead of a hang. +- The `-R` ingress direction is **declined** on this egress-only + build: `Handler::tcpip_forward` returns false (the global request + refuses), vanilla `ssh -R` dies with `remote port forwarding + failed for listen port` (exit 255), and the event log records the + refusal — honest advertisement, gate-before-wire. +- Unfeatured session-channel opens drop (auto-reject) unchanged from + POC-1 on this and the other stage builds. + +### 3. Identity/scope propagation observed live + +The direct establisher's identity witness recorded **`u-writer`** — +the SSH-authenticated identity rides the transport connection into +the channels layer (CF-005 propagation), and the establisher's +per-call auth (CF-006) sees it. The visible-surface invariant's +alkcall half (opener identity resolved from the authenticated +transport) is confirmed inside the mapping, not just documented. + +### 4. Wiring recipe worth carrying into phase 1 + +The in-process fabric leg (worth recording because it was the POC's +only real lift, and the alkforge assembly will build exactly this): +`OperationRegistry` + `InstallChannelZero` hook (spawn: accept channel +0's `accept_bi`, `split_single_stream`, `set_identity(caller)`, +`CallConnection::new_single_stream`, register ops + openables, +`Dispatcher::serve_single_stream`) on the accept side; +`CoreConnection::from_bidi` on both ends of a `tokio::io::duplex` +transport; `ChannelClient::from_connection` on the alkssh side with +the identity attached before dialing. + +## Stage D — session/pty mapping (13/13 PASS) + +### 1. The mapping is a translation bridge, not a pump — mapping-table note confirmed with a shape + +alktty's data plane is **chunk-framed in both directions** +(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload +— `alktty::wire`). The SSH mapping therefore wraps the channel with +two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF +sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH +`extended_data(1)`, ctrl-out exit chunk → SSH `exit-status` request + +close. `drive_session_pre_negotiated(writer, reader, NegotiateRequest +built locally, backends, ownership, identity)` — the exact producer +construct the mapping table names — then runs the real session. Proven +against a **real PTY** (`LocalTtyBackend` over portable-pty): + +- `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/ + pixels; SSH modes → the reserved `{}` OQ-44 JSON) — live: bash on + the PTY answered `stty size` with the requested 24×80. +- Keystrokes ride in, PTY echo and shell output ride out (real bash + prompt captured in the run output). +- `window_change_request` → `ControlMessage::Resize` as a CTRL_IN + chunk → the PTY resized: `stty size` flipped to `40 120` after the + client sent `window_change(120, 40)` — the mapping row proven + behaviorally. +- Pipe-mode `exec_request`: stdout rode SSH data, stderr rode SSH + `extended_data(1)` (client received both distinctly), exit + chunk → `exit-status(0)`. The PTY/pipe stderr split the mapping + table describes (PTY merges, pipe separates) is exactly what the + local backend produces. +- `signal(TERM)` → `ControlMessage::Signal` CTRL_IN chunk → the + backend killed the session; the exit chunk carried the negative + code and the SSH reply was an **`exit-signal`** request — the + mapping-table's "only reachable in signal-termination cases" row, + now with live wire evidence. +- The git-parseable exec declined in the session-only build + (parse-then-route: the git parse is the `git` feature's gate) — + the feature-split failure matrix holds in this build too. + +### 2. The signal-identity gap (new OQ) + +The exit chunk is `{"type":"exit","code":N}` — a negative code says +*signal-terminated* but **carries no signal name**. The adapter cannot +map SSH `exit-signal` faithfully from the chunk alone (the POC's check +asserts TERM because the *request* was TERM, not because the chunk +said so). Options for the ADR: alktty widens the exit chunk with the +signal name, or alkssh fixes the exit-signal name by assembly policy. +Also recorded: pipe-mode signals are **pid-only** (a SIGTERM to `sh -c +"sleep 60"` leaves the orphaned `sleep` holding the stdout pipe — no +exit chunk until the whole group dies); PTY-mode signals reach the +process group (correct terminal semantics). The signal row's fidelity +therefore depends on the tty/pipe mode split the table already notes. + +### 3. Client-visible mechanics (russh 0.64 API facts from this stage) + +- russh client has `Channel::exec()` (there is no `request_exec`); + pty/subsystem request *failures* surface as `ChannelMsg::Failure` + via `wait()` — **not** as `Err` from the request call; the caller + must read the channel until Success/Failure. +- `window_change`/`signal` are fire-and-forget (no want-reply), and + must be sent **before** `into_stream()` (after the conversion the + Channel is consumed) — server-side they carry no SSH reply, so the + handler must not send one. +- The server handler loop dispatches `CHANNEL_SUCCESS`-style replies + only through explicit handler calls; request-level failure replies + in stage A/D all used `Session::channel_failure`. +- The channel message loop client-side delivers interleaved + Data/ExtendedData/Success/ExitStatus on one queue — drain to the + expected message, `continue` past the rest (the POC hit the + break-on-first-message trap twice; noted so the alkssh consumer + half doesn't). + +## Hypothesis verdicts (pocs.md §POC-3) + +- "one-seam-per-mapping hypothesis" — **confirmed** across all four + seams with zero seam fixups; the mapping glue is adapter-side + (command parse, chunk translation, handle plumbing). +- "parse-then-route dispatch" — **confirmed** (stage A), with the + SOCKS-detect variant confirmed in stage B plus the bounded-window + and spawn-not-block constraints. +- "EOF/close/signal fidelity end to end" — **confirmed**: exec → + serve → exit-status (0 and 1 legs), exit-signal for signal + termination, close mapping, refusal diagnostics at both bridge ends. +- "stderr semantics in pipe vs PTY mode" — **confirmed** exactly as + the table describes (separate extended_data in pipe; merged under PTY). +- "throughput sanity" — **confirmed** with margin: 8 MiB in ~0.3–0.6 s + per bridged route. +- "stage D may surface PTY-merge stderr surprises that reshape the + request-level mapping rows" — partially: no surprises in the + PTY/pipe split, but the **signal-identity gap** and the + **translation-bridge shape** (two adapters, not a pump) do reshape + the session row's implementation notes. + +## Corrections recorded against phase 0 research + +1. `mapping-table.md` §channel-type rows: the git **subsystem** claim + ("git-upload-pack etc. arrive as either") is wrong over SSH — + subsystem cannot carry the repo id, so even a git service name must + decline. Git over SSH is exec-only. +2. `mapping-table.md`: `ssh -D` from a vanilla client does **not** + produce SOCKS5-in-channel traffic (OpenSSH does SOCKS client-side + and opens `direct-tcpip`). The `dynamic-forward` row serves + family-native clients; vanilla `-D` rides the `tunnel` row. +3. `mapping-table.md` session/pty row implementation notes: the + alktty bridge is a **translation bridge** (chunk framing both + directions), not a byte pump; plus the exit-chunk signal-identity + gap above. + +## OQ seeds carried forward + +1. Session-channel dispatch shape for SOCKS (first-byte sniff vs + in-band preamble vs exec marker; the detect window's bound). +2. Exit-chunk signal identity (alktty exit chunk widening vs + assembly-policy mapping; pipe-mode pid-only vs PTY process-group + scope). +3. Shell command convention (already seeded; the POC's stand-in — + `bash` for shell, `sh -c ` for exec — is an explicit assembly + policy, not wire-derived). +4. The `-R` listen flow (forwarded-tcpip mapping): untouched by the + POC beyond the refusal shape; route choice (ingress via + `register_tunnel_forwarded_openable`) is phase 1 work with this + stage's route-1 recipe as the template. +5. The identity-attachment seam (SSH fingerprint → alkcall + `Identity` → transport identity → establisher witness) worked + in-POC; whether the assembly composes these as one configuration + surface is an assembly-layer question to keep explicit. + +## Reference run + +Full clean run at findings time: **51 checks across 4 stages — 24 + 6 ++ 8 + 13, all PASS, `overall=true`, exit 0** (reproduced twice; +second run byte-identical PASS set with different ephemeral ports). +Server-side event logs captured per stage in the run output (the +diagnostics quoted above are verbatim from that run). + +## References + +- `pocs.md` §POC-3 (plan), `mapping-table.md` (the rows under test — + three corrections recorded above), `poc-1-auth-bridge.md` (the auth + loopback everything rides), `russh.md` (API inventory) +- POC source: `/workspace/alkssh-poc3` (`src/stage_{a_git,b_socks, + c_tunnel,d_tty}.rs`; the fabric/serving recipes and the translation + bridge are the reusable shapes) +- russh 0.64.1, alkgit 0.1.0, alkgit-substrate 0.1.0, alksocks 0.3.1 + (`local`), alktunnels 0.4.1 (`local`), alktty 0.8.1 (`local`), + alkcall 0.10.1 (crates.io, ring backend); git 2.43.0, OpenSSH + 9.6p1, curl 8.5.0 (vanilla tooling) \ No newline at end of file diff --git a/docs/research/pocs.md b/docs/research/pocs.md index 0f6bb28..8847cdb 100644 --- a/docs/research/pocs.md +++ b/docs/research/pocs.md @@ -13,6 +13,10 @@ the point of running it. ## POC-1 — auth bridge + fail-closed declines end to end +Findings: `poc-1-auth-bridge.md` (complete — 31/31; all hypotheses +confirmed, one correction to the research docs' russh default-impl +assumption). POC source: `/workspace/alkssh-poc1`. + **Question:** does the russh 0.64-era server auth surface compose with alkcall's fingerprint-based identity model as cleanly as `auth-bridge.md` assumes, and does the fail-closed decline contract @@ -49,6 +53,9 @@ confirms or corrects. ## POC-2 — client-side at-rest key handling (vault-backed storage) +Findings: `poc-2-vault-keys.md` (complete — 39/39; OpenSSH-PEM-inside- +`EncryptedData` confirmed, Signer seam dismissed with evidence). + **Question:** how are held SSH keys stored and used — the vault as pure at-rest encryption (the key is just another bearer-token-class secret: opaque bytes, vault-encrypted at rest, decrypted at use), what @@ -89,6 +96,11 @@ wrapper — confirming the alkhttp ADR-014 analogy end to end. ## POC-3 — the mapping bake-off: channel bridge through a real sibling seam +Findings: `poc-3-mapping-bakeoff.md` (complete — 51/51 across stages +A–D; every hypothesis confirmed, three mapping-table corrections +recorded, plus the russh inline-dispatch rule). POC source: +`/workspace/alkssh-poc3`. + **Question:** does "russh channel duplex → family serve-over-a-stream entry" actually work against the real published seams — and which of the mapping-table's route choices survives contact with reality? @@ -138,7 +150,10 @@ reshape the request-level mapping rows. path alkforge phase 0 consumes first. 4. Phase 0 completes when the findings docs exist and the OQ list in `vision.md` §scope questions is either answered by findings or - carried into phase 1's `open-questions.md` as seeded OQs. + carried into phase 1's `open-questions.md` as seeded OQs. POC-1/2/3 + findings docs now exist; scope question 5 (mapping-table details) + is answered per `poc-3-mapping-bakeoff.md` §hypothesis verdicts, + and the POC-seeded OQs are listed in each findings doc. ## References diff --git a/docs/research/russh.md b/docs/research/russh.md index ea80274..592111c 100644 --- a/docs/research/russh.md +++ b/docs/research/russh.md @@ -98,6 +98,24 @@ inventory: `cancel_streamlocal_forward` (the `-R` listen flow), and `lookup_dh_gex_group`. +**Dispatch/runtime facts (POC-3, load-bearing for the adapter):** the +session's event loop invokes handler callbacks *inline* — a blocking +handler suspends all packet routing for the connection (channel data +is only routed by that same loop; an inline serve deadlocks against +a peer's follow-up bytes, e.g. a receive-pack push). Every serving +path must `tokio::spawn` and return fast. Exit reporting from spawned +tasks rides `Session::handle()` — a cloneable server-side `Handle` +with `data` / `extended_data(channel, ext, data)` / `eof` / `close` / +`exit_status_request` / `exit_signal_request` (send `exit-status` +before `close`). Client-side: `Channel::exec()` (no `request_exec`), +`window_change`/`signal` are fire-and-forget and must be sent before +`into_stream()`, and request-level failures surface as +`ChannelMsg::Failure`/`Success` on `wait()`, not as `Err` from the +request call; drain past unexpected messages (`continue`), a client +`Handler::check_server_key` default that refuses must be accepted +explicitly. Details and wire evidence: +`poc-3-mapping-bakeoff.md` §A.4/§D.3. + ### `server::Config` knobs (bounded-resources posture) `server_id`, `methods: MethodSet` (advertised auth methods — honest