Repository navigation
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
Closed
feat(poc): browser min-core — SSH attach inside a WireGuard tunnel, built for wasm (non-member)#1350mitodrummer wants to merge 17 commits into
mitodrummer wants to merge 17 commits into
Conversation
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
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
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
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159ootRPxktgkfcByca684y
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 attachsequence on russh 0.63.1, beneath it a userspace WireGuard tunnel (boringtun'sTunn, the same crateminimaldembeds undernetworking-wg) with a smoltcp TCP/IP stack, and on top a tab-held credential: certificate auth on a non-extractable WebCrypto key. Built forwasm32-unknown-unknownwith a wasm-bindgen head; the same code runs natively under test.Where it stands (2026-09-04)
ghostty-web-poc,?transport=mesh; the PR now carries the whole Stage 0–3a series). Every exit criterion met in headless Chromium againstwg-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.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 againstwg-peer --auth stub, no divergence from the Node harness; cert-mode open → banner median 53.9 ms vs 54.9 msauth_none. Report in the #606 thread.minimaldsession in the tab72cfba2, no daemon change:wg-peer --session-socketsplices 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_noneon the daemon's socket, certificates on the stub path only.minimalditselfCertifiedallowlist, 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 onwasm32-unknown-unknown, so the patch now gives both it and sleepyinstant aweb-timeclock (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 itsnamesection.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_meshand thedpop_*/pkce_*/ key helpers), and the credential module: aRawSignerseam under russh'sSigner(the core does the SSHSignature{algorithm, blob}encoding; the tab returns the raw Ed25519 signature),authenticate_certificate_with, aHostPolicyincheck_server_key(Host CA anchors, expected principal, §5.3 per-node wildcards; bare host key refused), andverify_user_cert/verify_host_certwith the arch vectors' error codes.tests/— Stage 0/1b handshakes;cert.rsruns the sevenssh-certs/invalid/vectors at the manifest's fixed clock and each is refused with itsexpected_error;cert_handshake.rsruns the stub over the in-process tunnel: a valid mint attaches, all eight refusal cases are refused;stub_http.rscovers the routes and CORS. 14 native tests.examples/wg-peer.rs— the native stand-in for "minimald with a WireGuard-over-WebSocket ingress".--auth stubmints throwaway User/Host/rogue CAs, refusesauth_none, decides certificates with the same function the daemon would call, and servesGET /ssh/ca,POST /certify(acaseparam mints each refusal), no-op/tokenand/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 againstwg-peer.dist/— the built bundle withSHA256SUMS, vendored verbatim by the webapp.vendor/boringtun+patches/boringtun-0.7.1-wasm-clock.patch— aweb-timeclock 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
namesectionReference: 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_nonein the same run: 76 ms).Not proven
Firefox, Safari and a real phone (only Chromium and Chrome).
minimaldis 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/tokenis 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 theRUSTC_BOOTSTRAP=1 -Zbuild-stdstopgap. Native:cargo testinsidepoc/min-core-wasm.cargo run --example wg-peer -- 127.0.0.1:7691 /tmp/peer.jsonthennode js/headless-check.mjs /tmp/peer.json(orwg-peer --auth stubwithheadless-auth-check.mjs) exercises the shipped bundle without a browser.🤖 Generated with Claude Code
https://claude.ai/code/session_0159ootRPxktgkfcByca684y