ConduitDocs

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. Secure is added when the request arrived over TLS (the proxy's x-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_SECRET when 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 sid recorded 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's verified, 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.
My account , password, passkeys, second factor, API tokens and the SSH key, changed on the cluster that keeps the 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.

RoleHolds
adminEverything, including users, roles, credentials and updating Conduit
operatorThe fleet: servers, players, files, backups, monitoring. Not panel governance
developerAn operator's verbs minus nodes and destroying servers, inside assigned groups
viewerReads 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_AUTH is read only as an opt-out: 0, false or off lets 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 /secrets shows an amber "machine auth opted out" chip. scripts/deploy-panel.sh still 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/bans and /api/connector/action to 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-ssh unit) — 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's system 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 /account is 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-provisioned cli-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.

Secrets , every credential this cluster holds, what each one protects, its blast radius, and which of the three rotation classes it belongs to

Three rotation classes, because the difference decides whether you get a button or instructions:

classmeaning
self-servicethe panel changes it and the reconcile loop re-applies it everywhere
coordinatedconsumers must move together; a mismatch fails authentication with no useful error
externalit 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 Secure behind 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/grants shows 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 crossesWho reads itThe 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 peerits 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 keythe one peer it was issued tothe 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 forbearer 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 panelbearer on every tick to peer.url; https:// by default, an explicit http:// peer is marked "credentials in the clear"
Mesh identity: public keys, endpointsa peer with the mesh capgrant 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#

Port forwarding and node protection, applied per node as nftables rules
  • 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's CF-IPCountry header 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.

Edit on GitHubdocs/security.md 15 min readUpdated