hyperhive/nix/host-modules/swarm-authelia.nix
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

342 lines
14 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The swarm's SSO provider: one authelia for the whole swarm, in a
# `swarm-authelia` nixos-container.
#
# Two halves, and only one of them is conditional:
#
# - the CLIENT pointer (`url`) exists on every hive, because a hive
# that doesn't run authelia still has to know where to send people.
# - the CONTAINER only exists where the swarm's shared services live.
# `swarm.enableRequiredServices` asserts this module's `enable`
# (see ./swarm-required-services.nix); a hive is a client by default.
#
# Operator and agents are both subjects of the same provider,
# differentiated by roles/claims rather than by mechanism — there is one
# IdP and one auth path. The users store is therefore written by a
# program (swarm-controller), not maintained by hand: agents are created
# and destroyed continuously, so the subject set is *dynamic*. That is
# also why the file backend is the right one here and not a placeholder
# for LDAP: what makes a directory necessary is the size of the subject
# set, and this deployment's is bounded by one swarm.
#
# Per-service integration — putting 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.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.swarm.authelia;
hyperhiveCfg = config.services.hyperhive;
hyperhiveDomain = hyperhiveCfg.domain;
swarmDomain = hyperhiveCfg.swarm.domain;
# Upstream's `services.authelia.instances.<name>` derives the unit,
# user, group and StateDirectory from the instance name
# (`authelia` + `-<name>`). Naming them here rather than repeating the
# literal keeps the generator unit below and the module in step.
instance = "swarm";
unitName = "authelia-${instance}";
stateDir = "/var/lib/${unitName}";
# The SWARM's domain, because that is where the protected apps now live
# (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`). It moves in the
# same commit as `domain` below and cannot lag it: authelia validates
# `authelia_url ⊂ cookie domain` at STARTUP, so a half-move does not
# misbehave at login — it refuses to boot.
#
# Total on a null swarm domain for the same reason the option defaults
# below are: the required-domain assertion in hive-network.nix should
# be what an operator sees, not a coercion error from here.
cookieDomain = if swarmDomain == null then "invalid" else swarmDomain;
in
{
options.services.hyperhive.swarm.authelia = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the swarm's authelia in a `swarm-authelia` container on this
host. `services.hyperhive.swarm.enableRequiredServices` turns
this on a swarm has one SSO provider, and that says it lives
here.
With it off, this hive is a *client*: `url` below still points
at whoever runs it, and no container is created.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.authelia;
defaultText = lib.literalExpression "pkgs.authelia";
description = ''
authelia package to run in the container. Defaults to
nixpkgs's; override to pin a specific upstream.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 9091;
description = ''
TCP port authelia listens on. 9091 is upstream's default and
sits outside hyperhive's claimed ranges (dashboard 7000, forge
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
'';
};
domain = lib.mkOption {
type = lib.types.str;
# Under the SWARM domain, like the forge and matrix: a swarm has one
# SSO provider, and the session cookie has to reach the swarm's
# services.
#
# Total on a null swarm domain so the required-domain assertion in
# hive-network.nix is the thing that fires; see the comment there.
default = if swarmDomain == null then "auth.invalid" else "auth.${swarmDomain}";
defaultText = lib.literalExpression ''"auth.''${services.hyperhive.swarm.domain}"'';
example = "login.example.com";
description = ''
Public hostname for the SSO provider the sub-domain shape the
forge and matrix already use, under the swarm's domain because a
swarm has **one** SSO provider. Must be the name browsers
actually visit: it is the `authelia_url` the session cookie is
validated against.
Unlike the forge and matrix names, this one carries **no
migration pin**: nothing depends on the previous
`auth.''${services.hyperhive.domain}` yet, so it moves outright.
'';
};
url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = if cfg.enable then "https://${cfg.domain}" else null;
defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null'';
example = "https://auth.example.com";
description = ''
Base URL clients are sent to for authentication the half of
this module that exists on **every** hive, not just the one
running the container.
Defaults to this host's own instance **only when this module is
the thing running it**; in that case the URL is not a guess, it
is where this module just put the container. Otherwise `null`,
and a hive that federates with a swarm sets it explicitly to
wherever the swarm's authelia lives. Null means "no SSO
configured" and consumers say so rather than inventing an
address an endpoint baked in as a fallback is one that
resolves cleanly and points at the wrong machine.
'';
};
usersFile = lib.mkOption {
type = lib.types.str;
default = "${stateDir}/users.yml";
defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"'';
description = ''
Path (inside the container) of authelia's file users database.
Written by swarm-controller, not by hand: agents come and go
continuously, so the subject set is dynamic and belongs to a
program. This module only guarantees the file *exists* and is
valid YAML at first boot, so authelia starts with no subjects
rather than failing to start a provider with nobody in it yet
is the correct state before anything has provisioned users.
'';
};
# Derived facts, exposed for consumers that have to act on this
# container **from outside it** — `swarmctl` is the first, and it
# needs all three. Read-only options rather than literals repeated at
# the call site: the machine and unit names are derived from
# `instance` here, so a second copy elsewhere is a second thing to
# keep in step, and the one that drifts is the one nobody tests.
machine = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-authelia";
description = ''
Name of the nixos-container authelia runs in. Read-only: it is
what this module declares, published so callers of
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
have to hardcode it.
'';
};
unit = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "${unitName}.service";
description = ''
authelia's systemd unit *inside* the container. Read-only, and
derived from the instance name exactly like the unit itself.
'';
};
hostUsersFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}";
description = ''
`usersFile` as seen from the **host** the container's root
prefixed onto the path authelia sees.
The distinction is load-bearing: the users database is written
from the host by a program that does not live in this container,
while authelia only ever sees the inner path. Handing the wrong
one to either side yields a file nobody reads rather than an
error.
'';
};
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;
# Shared host netns, like the forge and matrix containers: the
# gateway reaches authelia at 127.0.0.1:<port>.
privateNetwork = false;
config =
{ ... }:
{
system.stateVersion = "26.05";
# The authelia binary itself, so an operator who gets a shell
# in here can run `authelia crypto hash generate` to make a
# password for the users file. Without it the container runs
# authelia and cannot invoke it: the unit's ExecStart resolves
# through the store path, and nothing puts the CLI on PATH.
environment.systemPackages = [ cfg.package ];
# This container shares the host netns, so its own
# firewall.service would rewrite the HOST ruleset at every
# boot. The host firewall owns all filtering.
networking.firewall.enable = false;
# Keep the host-copied /etc/resolv.conf intact — resolvconf's
# host-tracking would regenerate it to an empty file, since
# the host's copy doesn't cross the boundary after start.
networking.resolvconf.enable = lib.mkForce false;
# authelia's own secrets, generated in-container on first
# boot. They are jwt/session/storage keys — nothing outside
# this container ever reads them, which is what makes
# in-container generation right rather than merely easier.
# (hive-matrix generates its token host-side only because
# hive-c0re has to read that one.)
#
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
# so systemd creates the directory owned by the account that
# has to read the files and this unit can write nowhere else.
# No chown, no mode juggling: authelia opens these paths
# itself, as its own user, under `PrivateUsers=true`.
systemd.services."${unitName}-secrets" = {
description = "Generate authelia's secrets on first boot";
wantedBy = [ "multi-user.target" ];
before = [ "${unitName}.service" ];
requiredBy = [ "${unitName}.service" ];
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = unitName;
Group = unitName;
StateDirectory = unitName;
StateDirectoryMode = "0700";
UMask = "0077";
SyslogIdentifier = "${unitName}-secrets";
};
script = ''
set -euo pipefail
# Each is generated once and never rotated here: the
# session and storage keys are load-bearing for data
# already written (sessions, the encrypted store), so
# replacing one is an operator action, not a boot action.
for f in jwt session storage-encryption; do
p=${lib.escapeShellArg stateDir}/"$f".key
if [ ! -s "$p" ]; then
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
echo "generated $p"
fi
chmod 0600 "$p"
done
# A users database that exists and parses, with nobody in
# it. authelia refuses to start without one, and the
# alternative to an empty file is a placeholder account
# which is a credential nobody meant to create.
users=${lib.escapeShellArg cfg.usersFile}
if [ ! -s "$users" ]; then
echo "users: {}" > "$users"
echo "seeded empty users database at $users"
fi
chmod 0600 "$users"
'';
};
services.authelia.instances.${instance} = {
enable = true;
package = cfg.package;
secrets = {
jwtSecretFile = "${stateDir}/jwt.key";
sessionSecretFile = "${stateDir}/session.key";
storageEncryptionKeyFile = "${stateDir}/storage-encryption.key";
};
# Small-deployment defaults, and the scope is the
# justification: one swarm, one authelia, no replicas.
# - file users backend, written by swarm-controller
# - local sqlite storage: redis buys shared session state
# across replicas, and there is one instance
# - filesystem notifier: SMTP is for mailing humans, and
# provisioning is programmatic; a file is honest about
# where those messages go instead of implying a mail path
settings = {
theme = "dark";
server.address = "tcp://127.0.0.1:${toString cfg.port}";
log.level = "info";
authentication_backend.file.path = cfg.usersFile;
access_control.default_policy = "one_factor";
# The cookie domain is the SWARM's domain, NOT authelia's
# own host: the session cookie has to be sent to the apps
# being protected (`forge.<swarm>`, `chat.<swarm>`), and a
# cookie scoped to `auth.<swarm>` reaches none of them.
# authelia enforces the relationship from the other side
# too — `authelia_url` must be a sub-domain of `domain`, so
# setting both to the same host fails validation at startup
# rather than at first login.
#
# ⚠️ Known and accepted consequence while a hive keeps a
# domain outside the swarm's tree: this cookie is NOT sent
# to that hive's own surfaces (its dashboard), so SSO
# covers the swarm's services and not the hive's. It
# resolves when the hive domain moves under the swarm
# domain; until then it is a scope limit, not a bug to
# chase.
session.cookies = [
{
domain = cookieDomain;
authelia_url = "https://${cfg.domain}";
}
];
storage.local.path = "${stateDir}/db.sqlite3";
notifier.filesystem.filename = "${stateDir}/notification.txt";
};
};
};
};
};
}