wire swarm-authelia-bridge: systemd unit, oidc client, controller auth env

This commit is contained in:
damocles 2026-08-16 21:59:02 +02:00 committed by mara
commit c1eb6b9834
4 changed files with 215 additions and 37 deletions

View file

@ -11,16 +11,18 @@
#
# 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.
# IdP and one auth path. The users store is written by a program
# (`swarm-authelia-bridge`, see that option's doc comment), not
# maintained by hand: agents are created and destroyed continuously, so
# the subject set is *dynamic*. That is also why the file backend is
# right here, not a placeholder for LDAP: what makes a directory
# necessary is the size of the subject set, bounded by one swarm.
#
# Two roles: a **session** provider always, an **OIDC** provider when
# `oidc.clients` is non-empty (derived, not flagged — authelia will not
# start with a clientless provider). Secrets map: docs/swarm/sso.md.
# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC
# is always on now: the bridge needs its own machine-client identity for
# its introspection calls, contributed unconditionally, not behind
# `oidc.hiveIdentities`. Secrets map: docs/swarm/sso.md.
#
# Per-service integration — putting authelia's `auth_request` in front
# of the gateway's existing `auth_basic` locations — is deliberately NOT
@ -94,17 +96,33 @@ let
redirectUris = [ ];
}) hyperhiveCfg.swarm.hives;
# `swarm-authelia-bridge`'s own identity — distinct from
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
# resource server introspecting a token proves its OWN identity to the
# IdP (RFC 7662), separately from whichever principal's token it is
# checking, so the bridge needs a client even though it never presents
# a token itself. Contributed unconditionally below (not gated behind
# `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the
# bridge is a core, always-present part of this module, not an opt-in
# consumer — see `usersFile`'s doc comment.
bridgeClientId = "swarm-authelia-bridge";
bridgeClient = {
id = bridgeClientId;
description = "HyperHive swarm-authelia-bridge (users-database writer)";
kind = "machine";
redirectUris = [ ];
};
# authelia refuses to start with an OIDC provider that has no clients,
# so the provider is derived from the client list rather than carrying
# its own `enable`: one fact, and it cannot contradict itself. An empty
# list is the default, which makes every hive that has not opted in
# byte-identical to before.
# its own `enable`: one fact, and it cannot contradict itself.
#
# ⚠️ The derived hive identities are definitions of this same option,
# so they can turn the provider on by themselves. That is only
# reachable where the queue is already enabled (`hiveIdentities`
# defaults to it) — and a queue-enabled hive already contributes a
# client, so no existing deployment flips.
# ⚠️ In practice this is now unconditionally `true` whenever the module
# is enabled: `bridgeClient` above is an unconditional definition of
# `oidc.clients` (see the `config` block), so the list is never empty.
# Kept as a derived boolean rather than simplified to a literal `true`
# so the OIDC-gated code below stays self-documenting about WHY it is
# conditional, not just that it happens to always be on today.
oidcEnabled = cfg.oidc.clients != [ ];
# Secrets that are 64 random bytes of hex and nothing more. The OIDC
@ -335,12 +353,15 @@ in
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.
Written by `swarm-authelia-bridge`, not by hand: agents come and
go continuously, so the subject set is dynamic and belongs to a
program. `swarm-controller` cannot write this file itself a
different uid owns it so the bridge is the only writer,
running inside this same container as this file's actual owner.
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.
'';
};
@ -538,11 +559,58 @@ in
`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.
Published for callers that only ever need to *read* the file
(e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge`
itself never uses this path it runs inside the container, as
the file's own owner, and writes the in-container path directly.
'';
};
bridgePackage = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge";
description = ''
`swarm-authelia-bridge` package the only process allowed to
write `usersFile`. Wired by default from this flake's own
package set (see `flake.nix`); override to run a different
build.
'';
};
bridgePort = lib.mkOption {
type = lib.types.port;
default = 9092;
description = ''
TCP port `swarm-authelia-bridge` listens on, loopback-bound
(`127.0.0.1:''${bridgePort}`) one above authelia's own default
`port` (9091), outside hyperhive's other claimed ranges.
Reachable directly from this host's other processes (this
container shares the host netns, same as authelia's own `port`)
without going through the gateway this is an internal
service-to-service endpoint, not something meant to be exposed
publicly.
'';
};
bridgeUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
readOnly = true;
default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
description = ''
Where `swarm-authelia-bridge` answers, **as seen from this
host** correct only when a caller (`swarm-controller`) also
runs on this host, the same co-location assumption
`swarm.nix`'s `clientSecretFile` documents for its own
cross-host case. `null` when this host doesn't run
`swarm-authelia` at all.
A split-host swarm has no automated delivery for this address:
the operator points `swarm-controller`'s own option at wherever
this host has made the bridge reachable (a firewall rule, a
different bind address), the same manual-copy shape used
throughout this codebase's other cross-host cases.
'';
};
};
@ -550,11 +618,16 @@ in
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
# The derived half of the client list, declared the same way an
# operator declares one. Everything downstream then reads a single
# uniformly-typed `cfg.oidc.clients` and cannot tell the two apart —
# uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart —
# including the assertions below, which is why a hive named `x`
# colliding with a declared `hive-x` is caught rather than rendered
# twice.
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf cfg.oidc.hiveIdentities hiveClients;
# twice. `bridgeClient` is unconditional (plain list concatenation,
# not `mkIf`-gated like `hiveClients`): the bridge is always present
# wherever this module is, so `oidc.clients` is never actually empty
# — see `oidcEnabled`'s comment above.
services.hyperhive.swarm.authelia.oidc.clients =
lib.optionals cfg.oidc.hiveIdentities hiveClients
++ [ bridgeClient ];
# A redirect URI on a machine client is not harmless-but-unused: it
# means whoever wrote it believes a browser is involved. Failing here
@ -753,6 +826,68 @@ in
'';
};
# The only process allowed to write `cfg.usersFile` — see that
# option's doc comment, and the crate's own README for the full
# "why does an unprivileged swarm-controller need a bridge at
# all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE
# point of this whole unit: it is that same account, so it
# owns the file it writes and needs no elevated privilege.
# Ordered after authelia's own secrets generator (needs its
# own client secret, minted by that unit's `mint` loop) and
# after authelia itself (introspects against its local
# `/api/oidc/introspection`, so needs it answering — not
# load-bearing at start, since nothing calls the bridge yet at
# boot, but a clean dependency order beats a would-be-transient
# failure on the first real request).
systemd.services.swarm-authelia-bridge = {
description = "swarm-authelia-bridge: the only writer of authelia's users database";
wantedBy = [ "multi-user.target" ];
after = [
"${unitName}-secrets.service"
"${unitName}.service"
];
wants = [
"${unitName}-secrets.service"
"${unitName}.service"
];
serviceConfig = {
ExecStart = "${cfg.bridgePackage}/bin/swarm-authelia-bridge";
User = unitName;
Group = unitName;
Restart = "on-failure";
RestartSec = "5s";
};
environment = {
SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}";
# Own canonical store, alongside authelia's own state —
# NOT `swarm-controller`'s state dir: the two processes
# aren't guaranteed to be on the same host, and this store
# has to live wherever its writer (this bridge) does. See
# `swarm-authelia-bridge/README.md`'s "known limitation"
# section for the resulting `swarmctl`-owns-a-second-store
# seam.
SWARM_AUTHELIA_BRIDGE_STORE = "${stateDir}/swarm-authelia-bridge-users.json";
SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile;
# The CONFIGURED authelia, not whatever is on `PATH`: the
# argon2 parameters baked into a hash have to match the
# verifier's — same reasoning as `swarmctl`'s own
# `SWARMCTL_AUTHELIA_BIN`.
SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${cfg.package}/bin/authelia";
# Local loopback, not the public HTTPS vhost: this process
# runs right next to authelia (same container, same netns),
# so there is a faster, simpler path than round-tripping
# through the gateway's nginx for a call nothing external
# ever needs to see.
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "http://127.0.0.1:${toString cfg.port}/api/oidc/introspection";
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
# Minted by `${unitName}-secrets`'s `mint` loop (it iterates
# every entry in `cfg.oidc.clients`, which now always
# includes `bridgeClient`) — same file this container's own
# `renderClient` reads the digest half of.
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
};
};
services.authelia.instances.${instance} = {
enable = true;
package = cfg.package;

View file

@ -87,6 +87,14 @@ let
SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token";
};
# Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check
# is satisfied by THIS daemon's existing queue OIDC identity
# (`queueEnv` above): "one identity per principal" already covers this,
# so there is nothing new to mint or copy, just the bridge's address.
authBridgeEnv = lib.optionalAttrs (cfg.authBridgeUrl != null) {
SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl;
};
# Wrapped rather than documented: every one of these values is derived
# from an option this deployment already set, so making the operator
# re-supply them on the command line would be asking them to repeat the
@ -325,6 +333,30 @@ in
graceful-absence shape the queue coordinates already use.
'';
};
authBridgeUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = if autheliaCfg.enable then autheliaCfg.bridgeUrl else null;
defaultText = lib.literalExpression ''
authelia's own `bridgeUrl` when this host also runs
`swarm-authelia`, else null
'';
example = "http://127.0.0.1:9092";
description = ''
Where `swarm-authelia-bridge` (the only writer of authelia's
users database) answers see that option's own doc comment for
the cross-host caveat, since this default is only correct when
this host also runs `swarm-authelia`.
No new credential to configure: the bearer token presented to
the bridge is minted from THIS daemon's own existing queue OIDC
identity (`queue.*` above) "one identity per principal"
already covers it. `null` means no agent-identity support:
`CreateIdentity` jobs fail with a clear "no auth bridge
configured here" error rather than the daemon refusing to
start, the same graceful-absence shape `forgeTokenFile` uses.
'';
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
@ -480,15 +512,16 @@ in
];
};
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
# merge in last. The daemon refuses a PARTIAL set of either rather
# than treating it as absent, which is why each is built as one
# attrset and never assigned individually.
# Queue coordinates (`queueEnv`), forge coordinates (`forgeEnv`), and
# the auth-bridge address (`authBridgeEnv`) merge in last. The
# daemon refuses a PARTIAL set of any of them rather than treating
# it as absent, which is why each is built as one attrset and never
# assigned individually.
#
# They differ in how absence is prevented: the queue's is checked by
# the assertions above, because a controller without a queue is
# broken rather than lighter; the forge's is genuinely optional and
# stays gated on `forgeTokenFile` resolving.
# broken rather than lighter; forge's and the auth bridge's are
# genuinely optional and stay gated on their own option resolving.
environment = {
SWARM_CONTROLLER_SOCKET = cfg.socketPath;
# The swarm's hive directory, JSON-encoded — the full directory
@ -514,7 +547,8 @@ in
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
}
// queueEnv
// forgeEnv;
// forgeEnv
// authBridgeEnv;
};
# A systemd credential is a SNAPSHOT: it is materialised into `%d` once,