keel serve binds loopback. That is the posture, and this document exists so that widening it
is a decision someone made on purpose rather than a flag someone found.
Status: incomplete. Nothing remote should be exposed yet. Four of #648's five requirements are met. The last one — re-verifying the PWA's secure-context behaviour over a real HTTPS origin — cannot be done from a checkout, and it is named at the bottom rather than waved past.
keel serve answers on 127.0.0.1, and a browser treats http://127.0.0.1 as a secure
context by specification, which is what makes the service worker and the manifest work over
plain HTTP. Binding loopback is not, by itself, protection: any page in the operator's browser
can issue requests to 127.0.0.1, and an attacker who points evil.example at 127.0.0.1
gets a connection that arrives on loopback with every network-level check satisfied. That is
DNS rebinding, and the Host: header is the only place the lie is visible.
So HostPolicy refuses any Host: the bind does not account for. evil.example is refused
even though the packet came from loopback, because the name is not one this server bound.
A Cloudflare Tunnel forwards to loopback and presents the app's public domain in Host:.
From inside the process that is indistinguishable from the rebinding attempt above: both are
names that resolve to a machine this server did not bind. Nothing in the request tells them
apart, and nothing can — only the operator knows which name is theirs.
That is why the name has to be typed:
keel serve --external-host keel.example.com
One specific name, repeatable for more than one. Wildcards are refused when the server
starts, not per request: *, *.example.com, .example.com, 0.0.0.0, ::, any, all
and the empty string all raise. A wildcard is not a wider expectation, it is the absence of
one — * answers evil.example exactly as readily as the operator's own domain, which is the
attack itself. A leading-dot suffix is refused for the same reason: it admits every subdomain
an attacker can provision, and a tunnel presents one name.
Naming a host extends the defence and never replaces it. Every loopback spelling still answers, every unlisted name is still refused, and the port must still match — a proxied request is not exempt from being addressed to this server.
Stated plainly, because "it's encrypted" is not a threat model.
| what protects the traffic | who can read it | what the operator is trusting | |
|---|---|---|---|
| Loopback only | nothing leaves the machine | anything running as the operator | the machine itself |
| WireGuard mesh (Tailscale/Headscale) | end-to-end between the operator's own devices | only those devices | the mesh's coordination server for key distribution — not for content |
| Cloudflare Tunnel | TLS from the browser to Cloudflare, then Cloudflare to the machine | Cloudflare, in the clear at its edge | Cloudflare with the plaintext of every page and every session token |
The third row is the one that needs saying out loud. Cloudflare terminates TLS. In that mode the operator is not merely trusting Cloudflare to route traffic; they are handing it the readable contents of a console that displays holdings, the timeline and attestations. That may be an acceptable trade for convenience. It is not a neutral one, and a document that let someone discover it later would have failed.
A mesh has no such property: WireGuard is end-to-end between devices the operator enrolled, and the coordination server distributes keys without being able to read what they protect.
There is no brute-force threat, and saying so is the point. The token carries 256 bits of
entropy (secrets.token_urlsafe(32)), a space of about 1.2 × 10⁷⁷. An attacker managing a
billion guesses a second — which no http.server on a laptop will serve — needs on the order of
10⁶⁰ years to cover a meaningful fraction. A rate limiter installed to stop guessing would be
theatre: state, a failure mode, and a false sense that something was closed. The arithmetic is
recorded beside the constant (TOKEN_ENTROPY_BITS) so it travels with the claim. If a limiter is
ever added it must be justified by bounding log volume or making probing visible — never by
brute force.
What a remote origin does change is who can use a token that leaked. On loopback that
population is software already running as the operator, and no session lifetime helps against
it. Through a tunnel it becomes anyone who can reach the origin — and the token has been in a
URL, in terminal scrollback, and in whatever got pasted while asking for help. The 30-day cookie
Max-Age is a browser hint that such an attacker ignores entirely.
So a server configured with --external-host enforces a 12-hour session lifetime on its own
side of the wire. It is checked before the token, so an expired session and a wrong one get the
same refusal. There are three exceptions, and all of them are for the stop. A halt that carries
the session cookie and the halt token is accepted past the lifetime (#983): a console left running
overnight must still be able to stop trading. A read of Pulse (/api/pulse) that carries this
session's cookie is refused as expired, but the refusal carries the HALT control's inputs, so a
console reloaded after the lifetime still shows HALT (#985). And the console's own files (the
page, its scripts, stylesheet and icons) are served past the lifetime to a request carrying this
session's cookie (#1006), so a browser with no cached console — or one that opens the address
keel serve printed, which sets the cookie — loads the page and reaches that HALT. Those files
are the package's own, unaltered: no data and no token. Only the holder of the real session token
sees any of the difference. Nothing else is reopened: no release, no setup write, no other read,
and the release token is still served nowhere. A browser without the cookie is refused the page
exactly as before the lifetime ended; it can stop trading with keel kill at the terminal. A
loopback-only server has no lifetime, and that asymmetry is the argument rather than an
exemption. Restarting keel serve remains the instant revocation gesture in both postures.
keel serve serves a read surface plus a small write surface: the setup steps, the HALT button
(/api/gates/halt, which engages the kill switch on its own halt token and is deliberately
allowed from a tunnel -- a stop must never be slower than a start), and two gated releases behind
a second gate (#781) -- releasing the kill-switch halt and clearing rail 16's consecutive-loss
halt. The releases take a different token from the halt's, and no endpoint serves it (#968): the
token the page holds to stop keel cannot restart it. The other
capability-increasing actions in keel/capabilities.py have no route at all.
keel.web.security.gated_action_permitted decides the two releases, and it requires three
things:
a loopback peer, no --external-host, and a loopback bind.
The third exists because the second is a declaration and two ordinary deployments never make it:
- A local reverse proxy the operator did not declare. nginx's documented default is
proxy_set_header Host $proxy_host, soproxy_pass http://127.0.0.1:8765;presentsHost: 127.0.0.1:8765-- a name this server did bind, from a proxy on this machine. Every check above passes and nothing was typed. --host 192.168.1.5, which needs no--external-hostbecause only wildcard binds are refused. The LAN is served, and the declaration is empty.
A bind is observable where a declaration is not: a server reachable from off-box is one an
operator had to bind off-box. So a tunnelled, proxied or LAN-bound deployment serves every read
and the halt, and refuses every gated release with a 403. The console draws no release control at
all -- releasing is done at the terminal -- so there is no button on the page that returns 403;
the refusal is what a direct request to /api/gates/resume gets.
ssh -L 8765:127.0.0.1:8765 is not closed by this, and that is deliberate. The peer is
genuinely loopback and the bind is loopback, so the gate opens for a remote human. Ordinarily
that grants nothing: -L implies SSH access, and an SSH session with a pty already satisfies the
CLI's TTY gate, so the same operator could run keel resume directly. It grants more only under
a restricted key (command=, no-pty, permitopen=) whose purpose is forwarding without a
shell. Narrow, real, and left open until #656 gives the browser an identity of its own rather
than inferring one from the transport.
This is a floor. Relaxing it belongs to #648 and #656, in their PRs, not in a quiet edit.
--host 0.0.0.0 (or ::) used to produce a server that refused every request: HostPolicy
would then expect Host: 0.0.0.0, which no browser sends. It failed closed — the right
direction, the wrong explanation, and the operator's conclusion was "keel is broken" rather than
"keel does not know which name to expect".
A wildcard bind is precisely the case where the name cannot be derived, because every interface
has a different one. So it is the one bind that requires stating it, and keel serve now refuses
to start on a wildcard with no --external-host — once, at the moment it can be acted on.
One requirement remains, and it is not a formality:
- Secure-context re-verification. The service worker and manifest work today because
http://127.0.0.1is a secure context by specification. Over an external origin that property comes from HTTPS instead, and the PWA behaviours have to be re-verified there rather than assumed from the loopback behaviour. That needs a real deployed origin, so it cannot be closed from a checkout — and until it is, an installed console reached through a tunnel is untested, not merely unsupported.
scripts/verify-tunnel-context.sh https://keel.example.com <session-token>
The token is the one keel serve printed in its URL. The script has two parts and only the
first one is automated, which is the honest shape of this requirement rather than a limitation
of the script:
Part 1 — what HTTP can answer. That the shell is served over the external origin; that the
token is exchanged for a SameSite=Strict session cookie there; that manifest.webmanifest and
sw.js are actually served; that responses carry nosniff; and — the one that matters most —
that a spoofed Host: is still refused. --external-host teaches the server one name, and a
deployment where Host: evil.example is answered is one where the allowlist has been widened
until it stopped being one. Exits non-zero on any failure.
It refuses a plain http:// origin outright. Every check in it is about what HTTPS provides, and
run against http:// it would report a pass for a page that is not a secure context.
Part 2 — what only a browser can answer. window.isSecureContext, whether the service worker
registered and with what scope, whether the cache is genuinely per-origin, whether the manifest
installs, and how the installed console behaves when the tunnel dies. The script prints these as a
checklist and does not pretend its exit code covers them: a script claiming
window.isSecureContext from curl would be reporting something it never looked at.
#648 closes when part 2 has been done on a device and recorded on the issue. Part 1 passing is not sufficient, and no amount of scripting will make it so.