Security model
What the panel protects, how, and what it does not claim to protect.
The panel grants access to RCON, which is complete control of the game server. What follows describes what it protects, how, and — just as usefully — what it does not claim to protect.
Roles and permissions#
One password per role, no usernames: the role follows from the password used to sign in.
| Role | Variable | Can |
|---|---|---|
viewer | VIEWER_PASSWORD | Status, players, version, seed, evolution, admins, ban list |
moderator | MODERATOR_PASSWORD | + kick, ban, mute, server message, private message |
admin | ADMIN_PASSWORD | + save, promote, custom commands, raw RCON console, audit log |
| Permission | viewer | moderator | admin |
|---|---|---|---|
status:read | ✓ | ✓ | ✓ |
action:info | ✓ | ✓ | ✓ |
action:moderate | — | ✓ | ✓ |
action:server | — | — | ✓ |
action:custom | — | — | ✓ |
rcon:raw | — | — | ✓ |
audit:read | — | — | ✓ |
Permissions are enforced server-side: the catalogue is
filtered by role before being sent, and /api/actions re-checks the
permission before executing anything. The panel never trusts what the browser
hands back.
Two execution paths#
| Route | Who | What reaches RCON |
|---|---|---|
POST /api/actions | Every role, according to the action's permission | A command built by the server from an id and validated fields. The client cannot craft an arbitrary one. |
POST /api/rcon | rcon:raw — administrator | The typed line, as-is. |
This split is what makes custom commands interesting: they take the first path, so a moderator can be given a precise capability without being given the second.
Sessions#
- The cookie only carries a signed id (HMAC); state is authoritative and lives in SQLite. Signing out therefore really revokes the session, even if the cookie was stolen — something a stateless token cannot do.
- The signing key comes from
SESSION_SECRET, required and independent from the passwords. There is no derived key: it conflated two unrelated rotations, and tied session signatures to a human-chosen secret. - The cookie is
httpOnly,SameSite=Lax, scoped to/, andsecureas soon as the request arrives over HTTPS. - The proxy only checks the cookie's presence — it has access to neither the key nor the database. It therefore only redirects in the direction where absence is conclusive; the real check is done by the pages and the routes.
Origin checking#
Every mutating request compares the Origin header against the
host being served. It is the second barrier behind SameSite=Lax, and
the one that survives a change of cookie policy. A request with no
Origin is accepted: it did not come from a browser, so no CSRF is
possible — which is what lets you call the API with curl.
Limits and shutdown#
- Sign-in limiting per IP when the IP is trustworthy, plus a global cap that is always active. Constant-time password comparison, with no short-circuit.
- Command limiting per session
(
RCON_MAX_PER_MINUTE): a legitimate account must not be able to saturate the queue. - Bounded RCON queue (
RCON_MAX_QUEUE): beyond it the panel refuses with a503rather than piling up in front of a single socket. - Terminal shutdown: on
SIGTERMthe service stops accepting, rejects whatever is still queued, and never reopens a socket. A queued command must not wake up after the signal and reconnect. - Bounded request body, 16 KiB, enforced on
Content-Lengthand while reading — the former is absent inchunkedand does not bind the sender anyway.
Headers and CSP#
The content security policy carries a fresh 128-bit nonce per
request on script-src, together with
'strict-dynamic': only scripts the server emitted run.
'unsafe-inline' becomes inert as soon as a nonce is present, which
is exactly the intended effect.
style-src deliberately keeps 'unsafe-inline': the
charts set style attributes on SVG elements, which
style-src would block without it. CSS injection does not carry the
reach of script injection — the trade-off is accepted, and it no longer
concerns JavaScript.
Alongside it: nosniff, Referrer-Policy,
Permissions-Policy, X-Frame-Options and HSTS. API
routes, which never render HTML, get default-src 'none'.
Audit log#
Every action is recorded, refusals included: who, what, from which IP, with which outcome and how long it took.
- Catalogue actions are recorded in full. The command is built by the server from validated fields: seeing what actually went out is precisely the log's value.
- Commands typed into the raw console are not. Only a
48-character prefix and a SHA-256 fingerprint are kept. A command typed
there may carry a token; the log must not become a second place where that
secret lives on, volume backups included.
AUDIT_FULL_COMMANDS=truerestores verbatim storage.
Container isolation#
The panel and docker-proxy both run confined: read-only root,
cap_drop: [ALL], no-new-privileges, memory and PID
caps.
docker-proxy service, which
holds the socket and only exposes GET /containers/…. Mounting the
socket into the panel would hand the whole host to anyone who compromised it
— and :ro would change nothing, the Docker API being a write
API.
The factorio service is deliberately left alone: it writes its
saves, mods and configuration, and a read-only root would break it.
Deployment assumptions#
- A single instance: 1 container = 1 Node process = 1 RCON connection. Rate limiters and the status cache live in process memory; behind a load balancer they would be bypassable.
- Restricted network access: the panel is published on
127.0.0.1by default. - The RCON port is only reachable by the panel in production. The protocol offers a single password, no rate limiting, and full server control to whoever gets through.
Out of scope#
The Lua command confirmation is an interface aid, not a protection: an administrator account has full RCON access by definition. The panel does not try to protect against that — it tries to make sure not everyone needs to be an administrator.