Skip to content

Latest commit

 

History

History
186 lines (146 loc) · 11.8 KB

remote-access.md

File metadata and controls

186 lines (146 loc) · 11.8 KB

Reaching the console from another device

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.

What the defence actually defends

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.

Why a tunnel is refused by default, and why that is correct

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.

What each transport actually guarantees

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.

The session, once a remote host is configured

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.

Capability-increasing actions are refused unless the bind is loopback

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, so proxy_pass http://127.0.0.1:8765; presents Host: 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-host because 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.

Binding every interface

--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.

⛔ Not done — do not expose the console yet

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.1 is 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.

Verifying it, once you have an origin

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.