Commit graph hyperhive/nix/host-modules/swarm-authelia.nix
Author SHA1 Message Date
atlas
4a35e1229b feat(3149): mint each OIDC client's secret on first boot
A client secret has two holders in two containers: the relying party
authenticates with the plaintext, authelia compares a digest. Neither
side can generate it alone, and `settings` is rendered into the
world-readable nix store, so the value cannot be declared.

So it is minted here, once, as two files — `<id>.secret` and
`<id>.digest`. That split is what lets `oidc-clients.yml` be re-rendered
on *every* boot from the nix-declared metadata plus the stored digest: a
new redirect URI or a renamed client takes effect on rebuild without
rotating a credential another container is already holding.

`authelia crypto hash generate pbkdf2 --random` generates the password
itself and prints it beside its digest, so no plaintext is ever handed to
a second process on a command line. The clients file reaches authelia
through `settingsFiles`, which upstream merges at runtime — the same
mechanism it already uses for the issuer JWK.

Minting fails closed: an empty secret or digest aborts the unit, and the
unit is `requiredBy` authelia, so the provider refuses to start rather
than serving a client that can never authenticate. That failure would
otherwise surface three layers away as an opaque 401 from the token
endpoint.
2026-08-11 21:56:58 +02:00
atlas
36c5b68cc0 feat(3149): authelia grows an OIDC provider, derived from its clients
The swarm's authelia has been a session / `auth_request` provider only.
SSO for the forge (and matrix behind it) needs the second role: an OIDC
provider that issues tokens to relying parties.

The provider is derived from `oidc.clients` rather than carrying its own
`enable`, because authelia refuses to start with a provider that has no
clients — a separate flag would be a second fact free to disagree with
the first. The list defaults to empty, so a hive that has not opted in
renders exactly what it rendered before.

Its two secrets are generated in-container by the existing first-boot
unit, which is the same test that unit already applies to the jwt,
session and storage keys: nothing outside this container reads them. The
hmac key is 64 random bytes and joins that loop; the issuer key is an RSA
pair, because it *signs* id tokens and relying parties verify them
against the public half at `/jwks.json` — a symmetric secret cannot serve
that.

No client secret appears here, and that is the point: a client secret has
two holders in two containers, and `settings` is rendered into the world-
readable nix store. Minting it is the next commit's problem.
2026-08-11 21:56:58 +02:00
atlas
9e44efa01f feat(#3089): add swarmctl and a user-add verb for the swarm's SSO
The swarm-authelia module states that its users database is written by
swarm-controller, but nothing ever granted the means. This adds the tool
that does it.

swarmctl runs as root on the controller's host and acts directly. The
rootless alternative was examined and does not work: relocating the users
file into a directory the controller owns only turns a write problem into
a read problem, because authelia must then reach across the same boundary
in the other direction. Making that read work needs either a hand-pinned
gid or world-readable password hashes.

The user store is two files, one authoritative: users.json is canonical,
users.yml is a rendered artifact. That split is what lets the crate work
without a YAML parser -- the workspace has none, and adding one costs a
crates.io fetch, a lock update and a vendor hash for a schema we fully
control and only ever emit.

Passwords are generated by authelia rather than passed to it: argv is
world-readable, so a password on a command line is readable by any local
process for the lifetime of the call.

The three derived facts swarmctl needs about the authelia container --
machine, unit and the host-side users path -- become readOnly options on
the authelia module rather than literals repeated at the call site.
2026-08-10 21:48:45 +02:00
atlas
9a406c2046 feat(nix): authelia moves to auth.<swarm.domain>, cookie domain with it
Third of the three service moves, and the one that fails closed rather
than loudly. The vhost name and the session cookie's domain move in the
SAME commit because authelia validates `authelia_url` is a sub-domain of
the cookie `domain` at STARTUP -- move only the vhost and it does not
misbehave at first login, it refuses to boot.

No migration pin, unlike the forge and matrix: nothing depends on the
old name yet, so it moves outright.

Also gives the container the authelia binary, so an operator with a
shell can run `authelia crypto hash generate` for the users file. That
fix was written for a PR that was closed and is tracked nowhere else.

The accepted scope limit is recorded where the cookie is configured: a
hive keeping a domain outside the swarm's tree does not receive this
cookie, so SSO covers the swarm's services and not that hive's own
dashboard until its domain moves too.

⚠️ Nothing resolves `auth.<swarm.domain>` yet. The hive gateway is
deliberately NOT taught about it: swarm services get their vhost and
their name on the HOST, not inside a per-hive proxy container. Both
halves are the swarm-nginx work, and authelia has no vhost there either
way today.
2026-08-09 17:32:44 +02:00
atlas
048bdd29a8 refactor(nix): make all-local a deployment mode, not a default
mara: enableAllLocalDefaults is more of a deployment mode via settings
set, less a default setting.

That reframing is the change. A mode asserts values; an option declares
what it is when nobody asks. Written as default = <flag>, every service
option had to name a switch it has no relationship to, and the answer to
what does all-local turn on was spread across five files.

Two modules now hold the two tiers - local-defaults.nix for the mode and
what it asserts directly, swarm-required-services.nix for the
services-live-here switch and the per-service enables under it. Each
service module keeps its own declaration and loses only the line about
when a deployment wants it.

mkDefault is the right precedence rather than a compromise: it beats an
option default and yields to any explicit definition, so the mode fills
in for an operator who has not spoken and never argues with one who has.
Evaluated config is unchanged in both directions.
2026-08-05 19:41:11 +02:00
atlas
316cef94d1 feat(nix): the swarm's authelia, in a swarm-authelia container
One SSO provider per swarm, and a hive is a client of it by default:
`url` exists everywhere so a hive knows where to send people, while the
container only exists where `swarm.enableRequiredServices` says the
swarm's shared services live.

Operator and agents are both subjects of the same provider,
differentiated by roles/claims rather than by mechanism. The users store
is therefore written by swarm-controller rather than maintained by hand
— agents are created and destroyed continuously, so the subject set is
dynamic. This module only guarantees the file exists and parses, so
authelia starts with nobody in it instead of failing to start; a
placeholder account would be a credential nobody meant to create.

Small-deployment defaults, with scope as the justification rather than
preference: file users backend, local sqlite storage (redis buys shared
session state across replicas and there is one instance), filesystem
notifier (SMTP mails humans; provisioning is programmatic).

Secrets are generated in-container on first boot, sharing the authelia
unit's user/group/StateDirectory so systemd creates the directory owned
by the account that reads them. Nothing outside the container reads
these keys, which is what makes that right rather than merely easier —
hive-matrix generates its token host-side only because hive-c0re has to
read that one.

Per-service integration (authelia's auth_request in front of the
gateway's existing auth_basic locations) is deliberately not here:
standing an SSO provider up is reversible, cutting every operator-facing
vhost over to it is not.
2026-08-05 17:56:51 +02:00