8000 feat(poc): browser min-core — SSH attach inside a WireGuard tunnel, built for wasm (non-member) by mitodrummer · Pull Request #1350 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

feat(poc): browser min-core — SSH attach inside a WireGuard tunnel, built for wasm (non-member) - #1350

Closed
mitodrummer wants to merge 17 commits into
mainfrom
feat/min-core-wasm-poc
Closed

mitodrummer wants to merge 17 commits into
mainfrom
feat/min-core-wasm-poc

Conversation

@mitodrummer
@mitodrummer mitodrummer commented Sep 3, 2026 •
Copy link
Copy Markdown
Member 8000

Draft, non-member. poc/min-core-wasm has its own [workspace] table and is not in the workspace's members, so just ci never builds or tests it; nothing in crates/ changes. It is here to be read: the carve-out cost (Cargo.lock), the boringtun patch, and the direction plan. It moves into crates/ only under the spec.

What this is

The browser min-client POC for gominimal/inbox#606: a browser tab as a WireGuard mesh node that terminates SSH itself, so the provider is out of the data path. The min session attach sequence on russh 0.63.1, beneath it a userspace WireGuard tunnel (boringtun's Tunn, the same crate minimald embeds under networking-wg) with a smoltcp TCP/IP stack, and on top a tab-held credential: certificate auth on a non-extractable WebCrypto key. Built for wasm32-unknown-unknown with a wasm-bindgen head; the same code runs natively under test.

Where it stands (2026-09-04)

Stage State
0 — SSH attach in wasm Done. Handshake against an in-process russh server enforcing minimald's shell preconditions.
1b — WireGuard + smoltcp beneath russh Done. Same handshake over an in-process tunnel and over a loopback WebSocket carrying WireGuard datagrams.
1 — in a browser Done. Web side on gominimal/webapp#735 (ghostty-web-poc, ?transport=mesh; the PR now carries the whole Stage 0–3a series). Every exit criterion met in headless Chromium against wg-peer; Karl ran it in Chrome and mobile Chrome over the tailnet and over a Cloudflare quick tunnel. Findings and numbers in the #606 thread.
2 — tab-held credential Done. Core at 44e8dab (HANDOFF-webapp-stage2.md); web side on gominimal/webapp#735 (ghostty-web-poc, which now carries Stages 0–3a; #762 was merged into it). Every exit criterion met in headless Chromium against wg-peer --auth stub, no divergence from the Node harness; cert-mode open → banner median 53.9 ms vs 54.9 ms auth_none. Report in the #606 thread.
3a — a real minimald session in the tab Done at 72cfba2, no daemon change: wg-peer --session-socket splices the in-tunnel SSH stream onto a host-native daemon's UDS. Karl has a real session shell in Chrome over two quick tunnels; auth_none on the daemon's socket, certificates on the stub path only.
3b — minimald itself Not started: WireGuard-over-WebSocket ingress, own-address termination at its SSH server, certificate auth + Certified allowlist, host certificate, rejection delay. Spec-first (S3/S4).

What the browser run sent back into this branch: boringtun's TAI64N stamp read SystemTime::now(), which does not exist on wasm32-unknown-unknown, so the patch now gives both it and sleepyinstant a web-time clock (c4bbdd8); the Rust owners of a browser WebSocket detach their JS handlers on drop; the JS adapter queues messages until the caller's handler exists; a dead WebSocket now reaches the SSH layer instead of hanging the terminal (8d08b9d, page notices in 0.3 s); Nagle is off on the tunnel sockets (277efef); the stand-in ends a line on \r (4022f37); the shipped module keeps its name section.

What is in the tree

  • poc/min-core-wasm/README.md — what is proven per stage, how to build, the numbers.
  • src/{attach,wg,rt,web,credential,dpop}.rs (+ stub.rs, testing.rs, native only) — the attach handshake, the WireGuard + TCP stack, the runtime shims, the browser head (attach, attach_wg, attach_mesh and the dpop_* / pkce_* / key helpers), and the credential module: a RawSigner seam under russh's Signer (the core does the SSH Signature{algorithm, blob} encoding; the tab returns the raw Ed25519 signature), authenticate_certificate_with, a HostPolicy in check_server_key (Host CA anchors, expected principal, §5.3 per-node wildcards; bare host key refused), and verify_user_cert / verify_host_cert with the arch vectors' error codes.
  • tests/ — Stage 0/1b handshakes; cert.rs runs the seven ssh-certs/invalid/ vectors at the manifest's fixed clock and each is refused with its expected_error; cert_handshake.rs runs the stub over the in-process tunnel: a valid mint attaches, all eight refusal cases are refused; stub_http.rs covers the routes and CORS. 14 native tests.
  • examples/wg-peer.rs — the native stand-in for "minimald with a WireGuard-over-WebSocket ingress". --auth stub mints throwaway User/Host/rogue CAs, refuses auth_none, decides certificates with the same function the daemon would call, and serves GET /ssh/ca, POST /certify (a case param mints each refusal), no-op /token and /mesh/bind, GET /decisions, all with CORS.
  • js/min-socket.mjs, js/headless-check.mjs, js/headless-auth-check.mjs — the WebSocket-shaped adapter, and Node harnesses that drive the exact shipped bundle through the full attach and the full credential flow against wg-peer.
  • dist/ — the built bundle with SHA256SUMS, vendored verbatim by the webapp.
  • vendor/boringtun + patches/boringtun-0.7.1-wasm-clock.patch — a web-time clock backend for boringtun's sleep-aware timer and its TAI64N stamp (~40 lines, upstreamable). Everything else compiled unchanged.
  • plans/browser-min-client-direction.plan.md — the reviewed direction plan (three RALPH cycles, then the 2026-09-03 design interview): tab as mesh node, no backend in the session path, WireGuard control plane in Gatehouse. Its Open Questions section is a decision log: all 25 entries — the eleven plan questions and the fourteen design-interview decisions — are resolved; Stages 3 to 5 and what the spec must decide are there.

Numbers

Module (wasm-bindgen → wasm-opt -Os) raw gzip
SSH only (Stage 0) 909,229 B 355,877 B
SSH + WireGuard + smoltcp (Stage 1b) 1,092,803 B 446,597 B
Stage 1 as shipped, with name section 1,440,220 B 496,720 B
+ certificate path (Stage 2), stripped / with names 1,222,716 B / 1,595,601 B 501,083 B / 553,010 B

Reference: ghostty-web itself is 636,327 B raw / 184,359 B gzip. The network layer costs 183 KB raw / 91 KB gzip over SSH-only; the certificate path about 120 KB raw / 50 KB gzip over that.

Loopback, headless Chromium, Stage 1 bundle: WebSocket open → attach banner median 54.9 ms over 10 attaches; keystroke round trip 0.5 ms at the adapter boundary (the ~15 ms seen end to end is Chromium holding the message task behind ghostty's animation frame, page-side); 50 KB paste 12 ms with Nagle off; tunnel loss reported in 0.3 s. Node against wg-peer: attach with certificate auth and host-cert verification 69 ms (auth_none in the same run: 76 ms).

Not proven

Firefox, Safari and a real phone (only Chromium and Chrome). minimald is untouched: the stand-in is not the daemon (Stage 3). Interop is boringtun-to-boringtun, not wireguard-go or the kernel module. No relay and no NAT traversal (S12). The stub's /token is a no-op and a serial set stands in for a KRL codec. Nothing here is a spec; the plan lists what the spec must decide.

Build

The wasm build needs a wasm32 std and wasm-bindgen-cli, neither in the catalog yet (gominimal/webapp#755); the README documents the RUSTC_BOOTSTRAP=1 -Zbuild-std stopgap. Native: cargo test inside poc/min-core-wasm. cargo run --example wg-peer -- 127.0.0.1:7691 /tmp/peer.json then node js/headless-check.mjs /tmp/peer.json (or wg-peer --auth stub with headless-auth-check.mjs) exercises the shipped bundle without a browser.

🤖 Generated with Claude Code

https://claude.ai/code/session_0159ootRPxktgkfcByca684y

mitodrummer and others added 4 commits September 3, 2026 00:30
Milestone 1 probe for gominimal/inbox#606, as a standalone crate under
poc/ (own [workspace] table, not a workspace member): the `min session
attach` sequence on russh 0.63.1 with the ring backend, compiled for the
browser target with a wasm-bindgen head, plus the same sequence proven
natively against an in-process russh server that enforces minimald's
shell preconditions.

Not wired into the CLI and not for review yet; see poc/min-core-wasm/README.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…e tunnel

Stage 1b of gominimal/inbox#606: boringtun's Tunn (the crate minimald
already embeds under networking-wg) plus smoltcp beneath the russh
transport, so the browser is a mesh peer and SSH runs over TCP inside the
WireGuard tunnel. The host supplies only two datagram queues (a WebSocket
in the browser) and a clock.

Proven natively: two in-process nodes over in-memory datagram queues, and
the same over a real loopback WebSocket against examples/wg-peer, the
stand-in for a daemon with a WireGuard-over-WebSocket ingress. Both run
the full attach: handshake, banner, echo, resize, 20 KB payload, exit,
close. Builds for wasm32-unknown-unknown with a one-file boringtun patch
(a web-time clock backend; vendor/ + patches/).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…ontrol plane)

The RALPH-reviewed direction plan for gominimal/inbox#606 after the owner's
peer-to-peer decision: the tab is a WireGuard node, no backend in the
session path, WireGuard control plane in Gatehouse. Open questions at the
top need the owner; spec items S1-S13 feed the forthcoming spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
@coderabbitai
coderabbitai Bot commented Sep 3, 2026
Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

mitodrummer and others added 13 commits September 3, 2026 22:23
All resolved on the recommended option by the owner on 2026-09-03; the
decision log stays in the plan's Open Questions section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
Fourteen decisions on the no-BFF rule's scope, credential custody, origin
and code integrity, relay ownership and auth, inbound defaults, listing
liveness, mobile, sharing scope, outage behaviour and bundle budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
dist/ carries the wasm-bindgen + wasm-opt output with SHA256SUMS so the
web side can vendor a known artifact without a wasm toolchain;
HANDOFF-webapp.md is the Stage 1 brief for the webapp agent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…adapter ordering; headless harness

The webapp's browser run trapped 1-5 ms after the WebSocket opened:
boringtun's TAI64N handshake stamp calls std::time::SystemTime::now(),
which panics on wasm32-unknown-unknown. The vendored crate now takes its
wall clock from web-time too (std on native, Date.now() in wasm).

Also from that report: the Rust owners of a browser WebSocket now detach
their JS handlers on drop, so a late close event cannot invoke a dropped
closure on the connect error path; the JS adapter queues messages until
onmessage is assigned and fires onopen on a macrotask, so the caller's
handlers are in place; dist/ keeps the name section so traps name their
function.

js/headless-check.mjs drives the built bundle from Node against wg-peer
and passes the whole attach; it reproduces the trap on the previous dist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…all-clock patch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
When the datagram source ends (the WebSocket closed or errored), the
stack's driver marks the tunnel dead and aborts every TCP socket, which
wakes their registered wakers: reads return EOF, writes fail, russh
closes the channel, and the page's close callback fires instead of the
terminal hanging until a timeout. In the browser the close/error handlers
drop the inbound sender, which is what ends the driver.

The stand-in's pump tasks now die with its future, so an aborted server
actually closes the connection; that made the new test real. Headless
against wg-peer: the close reaches the page 1.7 ms after SIGKILL.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…d exit

From the Stage 1 browser report. The SSH sockets inside the tunnel now
run without Nagle, as OpenSSH does for interactive sessions, so a
keystroke is never held back waiting for the previous segment's ACK.
The fake daemon line-buffers input, so `exit` typed one keystroke at a
time ends the session, not only `exit\n` as a single write. The headless
check gains a 20-keystroke typing measurement (1.6 ms median on loopback)
and the docs say to measure keystroke latency at the adapter boundary,
not keydown-to-paint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…l works

A terminal sends \r on Enter and the fake daemon has no PTY line
discipline to translate it; `exit` + Enter now ends the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…ub CA

Client: a RawSigner seam (the tab's WebCrypto key behind a JS callback,
or an in-memory key natively) under russh's Signer, the core doing the
SSH Signature encoding; certificate auth via authenticate_certificate_with;
a HostPolicy (Host CA anchors + expected principal, per-node wildcards)
enforced in check_server_key instead of accept-any; PKCE and DPoP
helpers over the same signer. Decision functions verify_user_cert /
verify_host_cert refuse with the arch vectors' codes and pass all seven
invalid vectors at the manifest's fixed clock.

Stand-in: wg-peer --auth stub mints throwaway User/Host/rogue CAs,
presents a host certificate, refuses auth_none, decides certificates
with the same function, and serves /ssh/ca, /certify (with every refusal
case on request), /token, /mesh/bind and /decisions with CORS on the
WebSocket's port.

Browser head: attach_mesh(configJson, sign, onData, onClose) plus
ssh_public_key_from_ed25519_raw, dpop_jkt_ed25519, dpop_proof, pkce_*.
js/headless-auth-check.mjs runs the page's whole Stage 2 flow from Node
against the stub and passes. HANDOFF-webapp-stage2.md is the brief.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
…ion_time in the plan

Test tooling injects its own request headers on the cross-origin /ssh/ca
GET, which turned it into a preflight the fixed allow-list refused. A dev
stub can allow what is asked for. The plan's S4 now records that russh's
1 s auth_rejection_time (seen as ~1.3 s daemon-side refusals in the
browser) is a decision for the real daemon, not a default to inherit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
wg-peer --session-socket <ssh.sock> splices the in-tunnel SSH stream onto
a real daemon's UDS instead of the fake server; the stand-in sees only
ciphertext on that path. Proven against a host-native minimald from
`just up`: the harness in real-daemon mode attached to a session made by
`min session activate`, saw its attach-time output, ran a command in the
real shell and read the answer back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0