Skip to content
Pages in Guides

Expose the console safely

Publishing the console on a name of its own or keeping it on loopback, the browser's protections, and the streams that stay open.

Guide Updated View as Markdown

Part of Security.

Exposing the console

The console acts as root, so how it is reached matters more than anything else on this page.

Setup Command
Loopback only (default). Reach it through an SSH tunnel. noust web start -d
TLS served by Noust noust web start -d --host 0.0.0.0 --tls-cert fullchain.pem --tls-key privkey.pem
TLS with a self-signed certificate noust web start -d --host 0.0.0.0 --self-signed
Behind a TLS-terminating reverse proxy noust web start -d --trusted-proxy 127.0.0.1
  • Beyond loopback, TLS is mandatory. Binding to any other address without a certificate and key (or --self-signed) is refused, both when the options are parsed and again where the socket is bound. --insecure-http overrides this, and says what it does: the access token and the session cookie then cross the network in clear.
  • --self-signed mints a certificate under /etc/noust/panel-tls/ and reuses it while it is valid.
  • --allow-ip ADDR/CIDR (repeatable) answers only those clients; everyone else gets 403 before authentication. It restricts who connects; it encrypts nothing, and does not lift the TLS rule.
  • --trusted-proxy ADDR/CIDR is the only way X-Forwarded-For, X-Real-IP and X-Forwarded-Proto are believed, and only from that peer. By default Noust trusts nobody's forwarding headers. Declaring the proxy is what makes the session cookie Secure and the client address in the audit log the real one.
  • Requests are rate limited. A request without a valid credential (the sign-in, the forge webhooks, anything anonymous) is counted per client address, 120 a minute by default (web.rate_limit_requests). A request with a valid credential is counted per credential instead (one sign-in, one API token, the master token), 1200 a minute by default (web.rate_limit_authenticated_requests): over an SSH tunnel every request comes from 127.0.0.1, and a per-address budget made every tab and script share one. A wrong credential buys nothing: it is counted as anonymous, and guessing is stopped by the lockout below, which is separate. An IPv6 client is counted by its /64, not by its address: a customer is given 2^64 of them, and counting each one gave every request a fresh budget (and a fresh lockout, and a way to fill the limiter's table with entries that pushed everyone else's out). The audit trail and the session's binding keep the exact address. The limiter is not a way to test a credential. It only looks at one on /api, /events and /ws, where an endpoint checks it too; /health and the forge webhooks, which never check one, are always counted by address, whatever they carry. A wrong credential it does look at counts towards the lockout exactly once, like one an endpoint refuses, even on a route that never checks it (a public endpoint, a 404). An address that is locked out, or already over the anonymous budget, is counted by address without its credential being checked at all, so a flood that is refused anyway costs no database query per request. The console's shell (index.html on any console address) and its hashed build assets spend no budget, so reloading the page cannot lock the operator out. A refusal is 429 with "error": "rate_limited" and a Retry-After of the seconds until the budget frees; every counted response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the UNIX time at which the budget is whole again) for the budget it was counted in. Both budgets share web.rate_limit_window (60 seconds) and web.rate_limit_enabled.
  • Request bodies are capped before authentication and before routing: 1 MiB for everything but the forge webhooks, which get 5 MiB. A larger body is refused with 413 and "error": "payload_too_large", from its Content-Length or, for a chunked body, as it is read. The API's largest legitimate bodies (a unit file, a site configuration, an .env) are a few kilobytes.
  • web.allowed_hosts (names, or *.example.com) makes the console answer only those Host headers, plus loopback and the hosts of web.public_url and web.hooks_url; any other host gets 400. Empty answers any host.

The browser

The console is a static build served by Noust itself, loads nothing from any other origin, and runs under a Content Security Policy with no unsafe-inline for scripts or styles and Trusted Types, which forbid string-to-DOM sinks. The end-to-end suite fails on any CSP violation, and every response carries the same headers whatever it answers: a page, the API, an asset, a 404, and a refusal made before routing (a host or an address that is not allowed, a rate limit, a body too large). Strict-Transport-Security is added when the request came over TLS (directly, or through a declared proxy); everything is Cache-Control: no-store except the content-hashed files under /assets/, which are immutable for a year. The exact values of every header are printed in SECURITY-MODEL.md, generated from the code that sends them and checked against it by a test.

Streams

The event stream (GET /events) and the WebSockets (/ws/...) are authenticated once, at the handshake, and then stay open for hours. So:

  • The credential is checked again every 30 seconds on a WebSocket and every 25 on /events. Revoking an API token, rotating the master token (noust web token --new or --regenerate), signing out, revoking a session or letting it expire ends every stream it opened: a WebSocket closes with 4401, the event stream ends. A session renewal does not; it is the same sign-in under a new id.
  • One credential holds at most 8 WebSockets at once. Every log socket is a journalctl -f running as root; the ninth handshake is refused with 4429 until one closes. The budget is per credential, so a script at its limit does not stop the console.
  • A WebSocket lasts at most 12 hours. It then closes with 4408, and the client reconnects, authenticating again.
  • One credential holds at most 16 event streams (GET /events, and a central's relay of a node's, /api/nodes/{node}/events), counted where every request passes and given back however the stream ends; the seventeenth is refused with 429 ("error": "rate_limited"). Each stream is a subscription and a periodic render of the machine, so without a cap one low-privilege token could open streams until the console had nothing left for anyone else. A stream ends after 12 hours as well, and the browser's EventSource reconnects by itself.
  • /ws/logs/{name} streams only a unit Noust manages, decided by the same ownership rule every other service operation goes through. A unit Noust did not create, ssh for example, is refused.

Something on this page is wrong or unclear? Report it on GitHub.

Help, contributing and the project’s policies are under Project.

Search the documentation

Type to search every page.

↑ ↓ to move, Enter to open, Esc to close