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

@ -154,6 +154,9 @@
services.hyperhive.swarm.nats.authPackage = services.hyperhive.swarm.nats.authPackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
services.hyperhive.swarm.authelia.bridgePackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;
services.hyperhive.gateway.swaggerUiTheme = services.hyperhive.gateway.swaggerUiTheme =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;

View file

@ -11,16 +11,18 @@
# #
# Operator and agents are both subjects of the same provider, # Operator and agents are both subjects of the same provider,
# differentiated by roles/claims rather than by mechanism — there is one # differentiated by roles/claims rather than by mechanism — there is one
# IdP and one auth path. The users store is therefore written by a # IdP and one auth path. The users store is written by a program
# program (swarm-controller), not maintained by hand: agents are created # (`swarm-authelia-bridge`, see that option's doc comment), not
# and destroyed continuously, so the subject set is *dynamic*. That is # maintained by hand: agents are created and destroyed continuously, so
# also why the file backend is the right one here and not a placeholder # the subject set is *dynamic*. That is also why the file backend is
# for LDAP: what makes a directory necessary is the size of the subject # right here, not a placeholder for LDAP: what makes a directory
# set, and this deployment's is bounded by one swarm. # necessary is the size of the subject set, bounded by one swarm.
# #
# Two roles: a **session** provider always, an **OIDC** provider when # Two roles: a **session** provider always, an **OIDC** provider when
# `oidc.clients` is non-empty (derived, not flagged — authelia will not # `oidc.clients` is non-empty (derived, not flagged). In practice OIDC
# start with a clientless provider). Secrets map: docs/swarm/sso.md. # 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 # Per-service integration — putting authelia's `auth_request` in front
# of the gateway's existing `auth_basic` locations — is deliberately NOT # of the gateway's existing `auth_basic` locations — is deliberately NOT
@ -94,17 +96,33 @@ let
redirectUris = [ ]; redirectUris = [ ];
}) hyperhiveCfg.swarm.hives; }) 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, # authelia refuses to start with an OIDC provider that has no clients,
# so the provider is derived from the client list rather than carrying # so the provider is derived from the client list rather than carrying
# its own `enable`: one fact, and it cannot contradict itself. An empty # its own `enable`: one fact, and it cannot contradict itself.
# list is the default, which makes every hive that has not opted in
# byte-identical to before.
# #
# ⚠️ The derived hive identities are definitions of this same option, # ⚠️ In practice this is now unconditionally `true` whenever the module
# so they can turn the provider on by themselves. That is only # is enabled: `bridgeClient` above is an unconditional definition of
# reachable where the queue is already enabled (`hiveIdentities` # `oidc.clients` (see the `config` block), so the list is never empty.
# defaults to it) — and a queue-enabled hive already contributes a # Kept as a derived boolean rather than simplified to a literal `true`
# client, so no existing deployment flips. # 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 != [ ]; oidcEnabled = cfg.oidc.clients != [ ];
# Secrets that are 64 random bytes of hex and nothing more. The OIDC # Secrets that are 64 random bytes of hex and nothing more. The OIDC
@ -335,12 +353,15 @@ in
description = '' description = ''
Path (inside the container) of authelia's file users database. Path (inside the container) of authelia's file users database.
Written by swarm-controller, not by hand: agents come and go Written by `swarm-authelia-bridge`, not by hand: agents come and
continuously, so the subject set is dynamic and belongs to a go continuously, so the subject set is dynamic and belongs to a
program. This module only guarantees the file *exists* and is program. `swarm-controller` cannot write this file itself a
valid YAML at first boot, so authelia starts with no subjects different uid owns it so the bridge is the only writer,
rather than failing to start a provider with nobody in it yet running inside this same container as this file's actual owner.
is the correct state before anything has provisioned users. 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 `usersFile` as seen from the **host** the container's root
prefixed onto the path authelia sees. prefixed onto the path authelia sees.
The distinction is load-bearing: the users database is written Published for callers that only ever need to *read* the file
from the host by a program that does not live in this container, (e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge`
while authelia only ever sees the inner path. Handing the wrong itself never uses this path it runs inside the container, as
one to either side yields a file nobody reads rather than an the file's own owner, and writes the in-container path directly.
error. '';
};
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) { config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
# The derived half of the client list, declared the same way an # The derived half of the client list, declared the same way an
# operator declares one. Everything downstream then reads a single # 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` # including the assertions below, which is why a hive named `x`
# colliding with a declared `hive-x` is caught rather than rendered # colliding with a declared `hive-x` is caught rather than rendered
# twice. # twice. `bridgeClient` is unconditional (plain list concatenation,
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf cfg.oidc.hiveIdentities hiveClients; # 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 # A redirect URI on a machine client is not harmless-but-unused: it
# means whoever wrote it believes a browser is involved. Failing here # 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} = { services.authelia.instances.${instance} = {
enable = true; enable = true;
package = cfg.package; package = cfg.package;

View file

@ -87,6 +87,14 @@ let
SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token"; 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 # Wrapped rather than documented: every one of these values is derived
# from an option this deployment already set, so making the operator # 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 # 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. 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) { config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
@ -480,15 +512,16 @@ in
]; ];
}; };
# Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`) # Queue coordinates (`queueEnv`), forge coordinates (`forgeEnv`), and
# merge in last. The daemon refuses a PARTIAL set of either rather # the auth-bridge address (`authBridgeEnv`) merge in last. The
# than treating it as absent, which is why each is built as one # daemon refuses a PARTIAL set of any of them rather than treating
# attrset and never assigned individually. # 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 # They differ in how absence is prevented: the queue's is checked by
# the assertions above, because a controller without a queue is # the assertions above, because a controller without a queue is
# broken rather than lighter; the forge's is genuinely optional and # broken rather than lighter; forge's and the auth bridge's are
# stays gated on `forgeTokenFile` resolving. # genuinely optional and stay gated on their own option resolving.
environment = { environment = {
SWARM_CONTROLLER_SOCKET = cfg.socketPath; SWARM_CONTROLLER_SOCKET = cfg.socketPath;
# The swarm's hive directory, JSON-encoded — the full directory # The swarm's hive directory, JSON-encoded — the full directory
@ -514,7 +547,8 @@ in
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
} }
// queueEnv // queueEnv
// forgeEnv; // forgeEnv
// authBridgeEnv;
}; };
# A systemd credential is a SNAPSHOT: it is materialised into `%d` once, # A systemd credential is a SNAPSHOT: it is materialised into `%d` once,

View file

@ -156,6 +156,12 @@ in
# rather than every hive's. # rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
# The only process allowed to write swarm-authelia's users database —
# same "runs *inside* a container, not on the host" placement as
# `swarm-nats-auth` above (this one lives in `swarm-authelia`'s
# container, as authelia's own user, not the host's closure).
swarm-authelia-bridge = mkBinPackage "swarm-authelia-bridge" "hyperhive swarm-authelia users-database write bridge";
# The swarm operator's CLI, out of `daemonBins` for the same reason as # The swarm operator's CLI, out of `daemonBins` for the same reason as
# the daemon above: it is installed by the swarm-controller module on # the daemon above: it is installed by the swarm-controller module on
# the one host that runs the controller, and belongs in that hive's # the one host that runs the controller, and belongs in that hive's