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.
This commit is contained in:
1 parent
06f546c72a
commit
9fab51d4a8
4 files changed
+422
-8
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 <cmd>` 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)
|
||||
+16
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user