Security
The security model as implemented — auth, authorization, machine trust, credential handling, and audit. File references are the authoritative source.
Identity & sessions#
- Cookie:
conduit_s = v3.<uid>.<role>.<epoch>.<sid>.<exp>.<sig>— HMAC-SHA256 signed (lib/auth-core.ts), 7-day absolute TTL,httpOnly+SameSite=Lax.Secureis added when the request arrived over TLS (the proxy'sx-forwarded-proto: https, or an https URL); a panel reached over plain http on a LAN keeps a cookie the browser will store. - Signing key:
CONDUIT_SESSION_SECRETwhen it is set. Otherwise the key is derived from the agent token with HMAC-SHA256 (deriveSessionKey), so a cookie is signed with something that cannot be run back into the machine bearer. With neither variable set the key is random per boot and sessions die on restart, with a warning in the log. Every signature and every bearer is compared in constant time (safeEqual, edge-safe). - Per-device sessions: each login gets a
sidrecorded in the user's device registry (IP + UA + last-seen) — revoke one device, or bump the session epoch to invalidate every cookie at once ("sign out everywhere"). - Passwords: scrypt, format
s1.<salt>.<hash>(lib/auth.ts). - TOTP 2FA: RFC 6238 with sha256-hashed one-time recovery codes.
- Passkeys: WebAuthn via
@simplewebauthn(lib/passkeys.ts,api/auth/passkeys/*) — single-use 5-minute challenges, counters stored for replay protection, rpID derived from the request host. Passkey sign-ins skip TOTP: the authenticator's user verification is the second factor. - SSO: OAuth (Google / Discord / GitHub) with signed state, identities linked by
stable provider subject. An already linked identity (provider plus subject) always wins. An
existing account is matched by email only when the provider vouches for the address: Google's
email_verified, Discord'sverified, GitHub's verified-emails list. An unverified match is refused with the provider named, and auto-created accounts need a verified address too (lib/sso.ts), so a profile email anyone can type no longer hands over the account that owns it. - Login hygiene: uniform 400ms failure delay (no user-existence or timing oracle), full login history per account.
Authorization#
Every page and every endpoint declares the capability it requires, and the middleware enforces from
that declaration. A capability is a string like servers.power or backups.restore — about eighty
of them across twelve areas (lib/capabilities.ts), mapped to routes in lib/route-permissions.ts.
| Role | Holds |
|---|---|
admin | Everything, including users, roles, credentials and updating Conduit |
operator | The fleet: servers, players, files, backups, monitoring. Not panel governance |
developer | An operator's verbs minus nodes and destroying servers, inside assigned groups |
viewer | Reads only — every .view plus browsing files, and not the user list |
Roles are data. The four above are presets, merged in from the code rather than stored, so they
always mean what the running build says they mean — a stored copy would silently stop covering a
capability added in a later release, and nobody audits a role they never edited. /roles creates
and edits custom ones; a built-in can be duplicated but not changed.
A role can only grant what its editor holds. users.roles used to be admin in all but name: a
custom role holding it could PATCH its own grants to *. Now every grant on a role is checked
against what the editor's own role, extras and denies expand to (grantsBeyond in
lib/permissions.ts), and the answer is cannot grant what you do not hold: servers.jvm. The check
runs on what a grant expands to, so servers.* is out of reach for an account holding only
servers.view. An admin holds * and is never limited. Denies are not checked: taking a capability
away needs no standing to hold it. Across joined clusters a role is edited on the cluster that
keeps it, and the far panel re-checks with its own manifest.
Per-account extras. A user holds a role plus named grants and denies. A deny outranks
everything including a role granting *, because "admin minus one verb" has to be honestly minus
that verb or the setting is decoration. The cost is that "why can Alice not do X" has four possible
answers, so explain() sits beside the decision in lib/permissions.ts and returns the reason —
role, extra grant, denied, or not granted. A test asserts the two agree on every capability for
every shape; if they diverge the page lies. Denying the last account able to manage roles is
refused, the way the last admin cannot be deleted.
Scope reaches individual servers. An assignment names groups, individual servers, or both — "the
playnet group, plus that one lobby". An absent scope means unrestricted; an empty one means
nothing. Those look identical in a ?? [] and conflating them lets a developer assigned no groups
see the whole fleet, so the store distinguishes them. Clearing a scope in the UI stores no scope
rather than an empty one, because clearing a field means "no longer restricted".
Server settings are per-field. servers.edit used to cover all forty-odd fields on a task,
from its icon to its JVM flags. Somebody trusted to rename a server and put it in maintenance is not
automatically somebody who should hand the JVM arbitrary arguments — that is code execution in the
container. Six capabilities now, split by blast radius rather than one per field, which would be
forty-five checkboxes nobody reads: servers.edit (everyday knobs), .scale, .resources,
.jvm, .placement, .structure. Routing fields ask for network.edit, because that is what they
are; they live on the task for storage reasons. A field with no classification is refused, and
server-settings.test.ts walks the route's source and fails while any field it writes is
unclassified — which is what makes refusing safe rather than a way to break the form. It found one
on the first run that reading the route had missed.
SFTP reads the same source. lib/sftp-paths.ts filters the fleet by the same capabilities and
scope the panel gate uses. This matters more than it sounds: if the file tree and the panel ever
disagree, the file tree is the way around the panel — it reaches every container's disk. Verified
live with a real sftp client, not by unit test alone: one account scoped to a single server saw
exactly that server, and saw all seventeen once the scope was cleared, with nothing else changed.
Why not path patterns. What this replaced was two hand-kept lists of path prefixes, and its
failure mode was permissive: add a surface, forget the list, and it was open to everyone. Two tests
now walk src/app/api/**/route.ts and src/app/**/page.tsx and fail while any route or page is
undeclared, so an unmapped path is refused rather than waved through. That posture is only
defensible because those tests exist; without them it would trade a silent hole for a silent outage.
A third test asserts that every route exporting a mutating handler declares a write capability — added after refusing-by-default broke three working endpoints, log search among them, because a POST that only reads still needs a permission to name.
Capability and scope are separate questions. The capability says which verb; which servers is
answered in-route by lib/rbac.ts, because it needs the task behind the id in the URL, which the
middleware does not have. That separation is why "restart servers" and "restart these servers" can
both be expressed.
Denials name what was needed:
403 {"error":"you need \"users.view\" — see panel users and their roles"}
Sensitive vaults (mail, domains, system credentials) require sudo mode — a
password re-check that grants a 5-minute elevation cookie (api/auth/reauth).
API tokens (cu_…) are sha256-stored, shown once, and can be constrained by
expiry, read-only mode, and per-area scopes; scoped tokens can never reach
/api/account*.
Machine trust#
-
Three separate machine credentials, deliberately. They used to be one value, which meant the token a Minecraft server needs — sitting in a file inside every game container — was also the node agent's and the panel's machine-admin token. They are distinct now:
CONDUIT_AGENT_TOKEN— the node agent, and the panel's machine-admin credential. Never placed inside a container.CONDUIT_CONNECTOR_TOKEN— written to/etc/conduit/connector.env(mode 600) in each game container. It authenticates connector paths and only those; it is 401 on/api/nodes.- The container agent's token is derived per vmid,
sha256(agentToken + ":conduit-ctagent:" + vmid), from a value containers never see — so reading one container's token tells you nothing about any other, and it is refused from outside the cluster subnet regardless.
Machine paths require a bearer token by default, since 2026.8.304.
CONDUIT_ENFORCE_MACHINE_AUTHis read only as an opt-out:0,falseorofflets the connector paths answer without a token; anything else, including the variable being absent, enforces. While opted out the middleware warns on every tokenless hit it lets through and/secretsshows an amber "machine auth opted out" chip.scripts/deploy-panel.shstill writes=1, which means the same as leaving it unset. Why the default flipped is in security-review.md §1: both public installs were found answering/api/bansand/api/connector/actionto anyone. -
Derived service credentials: Redis / Postgres / MariaDB / MinIO passwords are
sha256(forwardingSecret:conduit-<service>)(truncated where the service requires) — never stored in the clear, re-asserted by every reconcile pass so drift self-heals, rotatable via per-service overrides. -
The forwarding secret itself is parity-watched. Provisioning writes it once, so a store-side rotation forks the fleet into islands that keep working pairwise until a fresh provision joins one and every login through the mixed pair dies — while TCP health checks see nothing wrong. A sweep compares every proxy's and backend's secret against the store every ten minutes (hashes only, node-side) and alerts on split-brain. It never auto-fixes: secrets must match pairwise, so convergence is a coordinated flip, not one-at-a-time. The Settings → Secrets inventory lists every secret the cluster runs on as fingerprints.
-
Inbound webhooks authenticate by unguessable URL token with optional HMAC signatures; outbound webhook deliveries are HMAC-SHA256 signed (
x-conduit-signature). -
Third-party integration keys (the GitHub token for source builds and packs, the Anthropic key for Ask Conduit) live in the replicated cluster config, never in a panel-CT env file — a redeploy rewrites those files and a secret pasted there would quietly vanish. Both are validated against their API before being stored, saving is admin-only, and the values are write-only in the UI (status is shown, the key is not echoed back).
-
SSH gateway: a dedicated console sshd on the panel CTs (default :2202, own host keys,
conduit-sshunit) — key-only, no passwords, no forwarding/sftp, and every accepted connection force-runs the console gateway; a system shell is unreachable by construction (the sole exception is the console'ssystem shell, admin-role-checked against the panel and confirmed). Key decisions are live per connection via an AuthorizedKeysCommand asking the panel, so revoking a key on/accountis instant. Unknown keys land in the username sign-in: browser approval (a device code the BOUND user confirms on/approve— nobody can approve someone else's code) or a TOTP typed in the terminal (gateway-only endpoint behind the CT-local secret, uniform failure delay + 5-attempt lockout). All exchange endpoints authenticate with the CT-local, root-provisionedcli-gateway.secret(0640) compared constant-time — dead from anywhere else. Session tokens appear (and are revocable) as "ssh session" under the account's API tokens; the workstation CLI signs in through the same browser-approval flow, so no credential is ever typed into a terminal.
Secrets#
Every credential the cluster holds is listed at /secrets, with what it protects, what a leak would cost, and what replacing it takes. The page shows an eight-character sha256 fingerprint and never a value — enough to confirm a rotation happened, or to compare the same secret across two replicas, and useless for anything else. Values are revealed only in Settings → Credentials, behind a fresh password re-auth on top of the admin session.
Three rotation classes, because the difference decides whether you get a button or instructions:
| class | meaning |
|---|---|
| self-service | the panel changes it and the reconcile loop re-applies it everywhere |
| coordinated | consumers must move together; a mismatch fails authentication with no useful error |
| external | it lives in another vendor's console — the panel can only record that you rotated it |
The register is checked against the store rather than maintained beside it: a test reads the
field declarations in lib/store.ts, takes every name that looks like a credential, and fails if one
is neither registered nor exempted with a stated reason. Adding a secret without saying what it
protects is not possible. That check earned its place immediately — the hand-written inventory it
replaced had no row for the webhook secrets, the per-service database logins, either Discord token,
or the Origin CA private key.
One caveat worth stating plainly for anyone operating this: the store is a single JSON document
and secrets sit inline beside ordinary configuration. network.domains holds DNS bookkeeping and
the Cloudflare API token and the Origin CA private key in the same object, and from 2026.8.319 the
Let's Encrypt account key and the certificate's private key as well (network.domains.acme,
network.domains.leCert). fleet.reach holds what this cluster minted for joined clusters and
fleet.peers[].reach what they minted for it. Reading a config subtree
is therefore not a safe operation the way it is in most systems — select the fields you want.
Hardened 2026-09-04#
Six changes shipped together. The measurements and the reasoning are in security-review.md §5; this is the list.
- The session key is no longer the machine bearer. Without
CONDUIT_SESSION_SECRET, cookies are signed with a key derived from the agent token, not with the token itself. A default install got one forced re-login on that release; an install with an explicit secret kept every session. - Every secret compare is constant-time: the machine token, the session and sudo signatures,
the connector bearer, the fleet services bearer (
safeEqual), and every fleet grant and invite code (timingSafeEqual). - Cookies carry
Securebehind TLS, decided per request from the forwarded protocol or the request scheme, so a plain-http LAN panel is not locked out of its own login. - SSO links an account only on a verified email (see Identity above).
- A role can only grant what its editor holds (see Authorization above).
- Fleet grants are listed and revocable.
GET /api/fleet/grantsshows every credential this cluster issued to another panel: the estate, the paths it may open, whether it may act here, an eight-character fingerprint and when it was last presented. "Never used" marks a leftover from a probe or a repeated join.DELETE /api/fleet/grants/{id}needs an admin session plus the sudo password; the far panel loses that credential on its next request, and there is no undo short of a new invite.
Credentials between joined clusters#
Two joined clusters exchange credentials in both directions. What crosses, who may read it and how the reader is authenticated, condensed from security-review.md §6 (the full table is there; how the link works is in federation.md):
| What crosses | Who reads it | The reader is authenticated by |
|---|---|---|
| The logical partition: groups, services, labels, blueprints, schedules, automation rules, and the identity projection (accounts with their password hash, API token hashes, role, extras, scope and public ssh keys; never sessions, passkeys or TOTP secrets) | any linked peer | its reach token from the mesh (node agent GET /v1/state?keys=), or its grant with the partition cap (GET /api/fleet/partition) |
Reach: a per-peer agent token, a Proxmox token on conduit-fleet@pve with PVEVMAdmin,PVEAuditor, the cluster CA, and while "share these credentials with joined clusters" is on, the zone's Cloudflare token and Origin CA key | the one peer it was issued to | the source address of the request lying inside that peer's mesh /24 (GET /v1/reach on any node); the anti-spoof rule in the conduit nft table is what makes the address a credential |
A grant secret (fleet.grants[].secret) | the estate it was minted for | bearer on /api/fleet/*, compared constant-time; its caps are recomputed every tick from the two switches on the Nodes page |
A peer token (fleet.peers[].token) | we present it to the far panel | bearer on every tick to peer.url; https:// by default, an explicit http:// peer is marked "credentials in the clear" |
| Mesh identity: public keys, endpoints | a peer with the mesh cap | grant plus cap (GET /api/fleet/mesh) |
Nothing else under network crosses through any declared path: not the TLS private key, the
forwarding secret, the database passwords or the SSO client secrets. That is not the whole story,
and the Secrets page says the rest: a cluster you let work together holds a reach token, and
the node agent runs POST /v1/exec as root on the host for that token, which reads the store
file. So every cluster with "manage together" on can read every value the register describes. The
page shows "Work together is on" with a chip per cluster that "can read all of this", and the
register lists every fleet credential by full path (fleet.reach[].agentToken,
fleet.reach[].proxmox.tokenSecret, fleet.peers[].token, fleet.grants[].secret, the learned
fleet.peers[].reach.* rows). Reach pairs are re-minted every 30 days with a one-day overlap;
switching manage off, or detaching the cluster, withdraws them at once and drops the replica and
the remote mesh entry. A bundle whose issuer is not the cluster it was read from is refused.
Still open, and the owner's call: edge-01 and edge-02 were cloned from one install and share the agent token and the session secret. Until one side rotates both, the grant boundary between them is nominal.
Network hardening#
- The panel should live on a private network or behind TLS (the domains system manages Cloudflare DNS + certificates).
- The dynamic firewall (
lib/firewall.ts) exposes only ingress-role services; node protection default-drops internet-sourced connections to node services while keeping private space, SSH, and established traffic open — lockout-safe by construction. - Public surfaces (status page, badges, pack downloads) are opt-in and stripped of internal identifiers.
Audit & privacy#
- Operator audit: every authenticated mutation is logged (actor, role, method, path) and can be forwarded to a SIEM.
- Player audit: session events (join / quit with cause / switch) are retained on a
configurable window with a GDPR erasure endpoint (
api/audit/erase). - Secrets never leave vault-gated admin routes; the Prometheus endpoint exposes no credentials.
- Hub telemetry is on by default and opts out in Settings → Conduit Hub. A ping is:
a random uuid minted once for the installation, the version and commit it runs, an
uptime, four integer counters (nodes, services, servers, players), the platform
string, the software mix as
kind:version → count, the list of Conduit features switched on, and hardware totals (cores, memory, containers, disk). Every value is a count, a boolean or a short enum; the software map is keyed by kind and version, never by what anything is called. No hostnames, no addresses, no group / service / world / domain / player names. It goes on a public dashboard, which is the standard for anything added to it later. The hub clamps every string it accepts to a boring charset and keeps source addresses only in an in-memory rate limiter, never on disk. Country is read from the edge proxy'sCF-IPCountryheader when there is one — the hub never geolocates, so an address is something the socket knows and we discard rather than something we process. - Crash reporting splits in two, and the split is the point. The signature — the exception class, the first stack frame that is neither the JDK nor the server itself, and the software version — is public, because it identifies a bug and not a reporter. The detail (the breadcrumb's log tail, task name, TPS, heap) is private: accepted only from a cluster that presents its secret, stored under that cluster alone, capped at 50 entries, and returned only to the same secret. Nothing in the public routes can reach it. The cluster secret is minted separately from the cluster id precisely because the id is semi-public — the dashboard prints its first bytes — and it lives in its own map on the hub rather than on the record the public stats route walks.
- Self-update trust: the panel installs only what the configured hub's feed names, and only after the artifact's sha256 matches on every node that will extract it. Publishing to a hub needs a bearer token that lives on the hub, not in this repository; a cluster never holds a credential that can publish.
Reporting a vulnerability#
Open a GitHub security advisory on this repository (preferred) or contact the maintainer directly — do not file public issues for exploitable problems. Include reproduction steps; you'll get a response as fast as a one-person project allows.