Skip to content

Serve the real binary behind the browser page - #22

Merged
emilbob merged 1 commit into
masterfrom
feat/render-server
Jul 25, 2026
Merged

Serve the real binary behind the browser page#22
emilbob merged 1 commit into
masterfrom
feat/render-server

Conversation

@emilbob

@emilbob emilbob commented Jul 25, 2026

Copy link
Copy Markdown
Owner

The page re-implemented the checks in JavaScript because a browser cannot execute this program. It still can't — but that no longer has to be the only option.

What changed

  • probe() moves into a library, so the CLI and the server run the same code rather than two implementations that happen to agree today. ProbeRequest replaces the clap struct at that boundary, since a command line is only one way to ask.
  • serve — a small axum wrapper: GET /api/health, POST /api/probe, and the static page on everything else so the two share an origin. Feature-gated: a plain cargo build still produces only the CLI, and cargo tree confirms axum, tower and hyper are absent from the default graph.
  • The page detects a backend by asking /api/health on load, then offers a Rust/JavaScript switch. On static hosting there is nothing to switch to, so it stays in browser mode and says so. The one thing worse than a reimplementation is one that lets you think otherwise.
  • Dockerfile + render.yaml to deploy it.

Running a probe for strangers is not running one for yourself

An endpoint field that becomes an outbound connection is SSRF. guard.rs resolves the host and refuses anything not on the public internet — loopback, RFC1918, link-local (169.254.169.254, where cloud metadata lives), CGNAT, multicast, and the IPv4-mapped IPv6 form of each. The server also caps follow to 3, shortens the header wait to 30s, limits the body to 4 KB, and bounds the whole request.

None of it applies to the CLI, whose default target is ws://127.0.0.1:9944. The difference isn't the address, it's who is asking.

Writing that guard's tests found a real hole: host_str() returns an IPv6 literal still bracketed, so ws://[::1] parsed as neither an address nor a name and fell through to DNS — refused only because the lookup happened to fail. Right answer, wrong reason, and not a thing to rely on. Matching on url::Host judges it directly.

One limit stated plainly in the code and README: the guard resolves the name and the WebSocket client resolves it again, so DNS rebinding could still slip past. Closing it needs the connection pinned to the checked address, which the client doesn't expose. The service therefore holds no secrets and has nothing worth reaching.

Verified, not asserted

Container built and run locally:

health          {"engine":"rust","max_follow":3,"ok":true,"version":"0.1.0"}
static page     HTTP 200, 37125 bytes
live probe      {"ok":true,"chain":"Polkadot","peers":73,"best_block":32267796,"genesis_verified":true}
guard           ws://127.0.0.1:9944 -> HTTP 400
user            uid=10001(probe)  — non-root
image           159MB

Page driven in Chrome against the running server: engine detected, switch shown, real report rendered (71 peers, block 32,267,773, 3/3 heads). Every SSRF vector above rejected with 400, including ::ffff:127.0.0.1.

33 tests + 1 ignored. fmt and clippy clean under --all-features, which CI now uses throughout — optional features are exactly the code nobody compiles locally, so without it the server binary would rot while the default build stayed green. CI also builds the Dockerfile now, since a broken one otherwise surfaces as a failed deploy after it's already on master.

🤖 Generated with Claude Code

The page re-implemented the checks in JavaScript because a browser
cannot execute this program. It still can't — but it no longer has to
be the only option.

- Move probe() into a library so the CLI and the server run the same
  code rather than two implementations that agree today. ProbeRequest
  replaces the clap struct at that boundary, since a command line is
  only one way to ask.
- Add `serve`, a small axum wrapper: GET /api/health, POST /api/probe,
  and the static page on everything else so the two share an origin.
  Feature-gated, so a plain `cargo build` still produces only the CLI
  and pulls in no axum, tower or hyper.
- The page asks /api/health on load and offers a Rust/JavaScript switch
  only when something answers. On static hosting there is nothing to
  switch to, so it stays in browser mode and says so — the one thing
  worse than a reimplementation is one that lets you think otherwise.
- Dockerfile and render.yaml for deploying it.

Running a probe for strangers is not running one for yourself. An
endpoint field that becomes an outbound connection is SSRF, so the
server resolves the host and refuses anything not on the public
internet — loopback, RFC1918, link-local (cloud metadata), CGNAT,
multicast, and the IPv4-mapped IPv6 forms of each — then caps follow,
shortens the header wait, and bounds the body and the request. None of
it applies to the CLI, whose default target is 127.0.0.1:9944.

Writing that guard's tests found a real hole: host_str() returns an
IPv6 literal still bracketed, so `ws://[::1]` parsed as neither address
nor name and was refused only because the DNS lookup happened to fail.
Matching on url::Host instead judges it directly.

Verified: container built and run — health, static page, a live probe
through it (73 peers, block 32,267,796, TLS from the image), the guard
refusing loopback, and non-root at uid 10001. Page driven in Chrome
against the running server: engine detected, switch shown, real report
rendered. Every SSRF vector above rejected with 400. 33 tests + 1
ignored; fmt and clippy clean under --all-features, which CI now uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@emilbob
emilbob merged commit 9904ff7 into master Jul 25, 2026
1 check passed
@emilbob
emilbob deleted the feat/render-server branch July 25, 2026 21:09
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