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.
This commit is contained in:
parent
b94382b815
commit
316cef94d1
2 changed files with 269 additions and 0 deletions
|
|
@ -21,6 +21,7 @@
|
|||
./hive-priv.nix
|
||||
./hive-tls.nix
|
||||
./otel.nix
|
||||
./swarm-authelia.nix
|
||||
./swarm-ca.nix
|
||||
./swarm-controller.nix
|
||||
./swarm-snapshot-store.nix
|
||||
|
|
|
|||
268
nix/host-modules/swarm-authelia.nix
Normal file
268
nix/host-modules/swarm-authelia.nix
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# 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`, itself defaulting from
|
||||
# `enableAllLocalDefaults`). 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;
|
||||
swarmCfg = config.services.hyperhive.swarm;
|
||||
hyperhiveDomain = hyperhiveCfg.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}";
|
||||
|
||||
# Total on a null hive 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 hyperhiveDomain == null then "invalid" else hyperhiveDomain;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.authelia = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = swarmCfg.enableRequiredServices;
|
||||
defaultText = lib.literalExpression "services.hyperhive.swarm.enableRequiredServices";
|
||||
example = true;
|
||||
description = ''
|
||||
Run the swarm's authelia in a `swarm-authelia` container on this
|
||||
host. Defaults from
|
||||
`services.hyperhive.swarm.enableRequiredServices` — a swarm has
|
||||
one SSO provider, and this 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;
|
||||
# Total on a null hive domain so the required-domain assertion in
|
||||
# hive-network.nix is the thing that fires; see the comment there.
|
||||
default = if hyperhiveDomain == null then "auth.invalid" else "auth.${hyperhiveDomain}";
|
||||
defaultText = lib.literalExpression ''"auth.''${services.hyperhive.domain}"'';
|
||||
example = "login.example.com";
|
||||
description = ''
|
||||
Public hostname for the SSO provider — the sub-domain shape the
|
||||
forge and matrix already use. Doubles as the cookie domain's
|
||||
host, so it must be the name browsers actually visit.
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||||
containers.swarm-authelia = {
|
||||
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";
|
||||
|
||||
# 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 hive's domain, NOT authelia's
|
||||
# own host: the session cookie has to be sent to the apps
|
||||
# being protected (`<hive>`, `forge.<hive>`,
|
||||
# `matrix.<hive>`), and a cookie scoped to `auth.<hive>`
|
||||
# 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.
|
||||
session.cookies = [
|
||||
{
|
||||
domain = cookieDomain;
|
||||
authelia_url = "https://${cfg.domain}";
|
||||
}
|
||||
];
|
||||
|
||||
storage.local.path = "${stateDir}/db.sqlite3";
|
||||
notifier.filesystem.filename = "${stateDir}/notification.txt";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue