Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/host-modules/swarm-authelia.nix
atlas d30cb7d122 keep authelia's oidc client secrets in the store, not on disk
The mint creates each client's secret in the swarm secret store and writes only the digest to disk. It mints only when the store answers and holds none. A new unit moves the plaintext files an earlier release left into the store and removes each once the store holds the same value. The bridge reads its secret from the store into /run before each start. The secret publisher unit and its path unit are gone.
2026-10-30 16:57:58 +01:00

1587 lines
72 KiB
Nix

# The swarm's SSO provider: one authelia for the whole swarm, in a
# `swarm-authelia` nixos-container on the host with `deploy.authelia.enable`
# (`deploy.allSwarmServices` defaults it on, see
# ./swarm-required-services.nix). What every hive sees of it, `url` and the
# OIDC client register included, is in ./swarm-authelia-service.nix.
#
# The file backend is the right shape, not a placeholder for LDAP: what
# makes a directory necessary is the size of the subject set, and this
# one is bounded by a swarm. Who writes that store, and why it is a
# program rather than a config file, is in `docs/swarm/sso.md`.
#
# `docs/swarm/sso.md` owns the role model — session provider always, OIDC
# provider derived from the client list — and the secrets map.
#
# Per-service integration — the `auth_request` in front of a vhost —
# lives in the module declaring that vhost. What lives here is the
# `access_control` rule each of them relies on.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.swarm.authelia;
networkCfg = config.services.hyperhive.network;
hyperhiveCfg = config.services.hyperhive;
gatewayCfg = hyperhiveCfg.gateway;
swarmDomain = hyperhiveCfg.swarm.domain;
deployCfg = hyperhiveCfg.deploy;
# Group an account must hold to reach operator-only surfaces. Named
# here because this module writes the rule that enforces it and
# `swarmctl user add --group <this>` is what grants it — the two must
# agree, and one constant is how they stay agreeing.
#
# ⚠️ `admins` and not a new word, because `docs/getting-started/setup.md` and
# `docs/swarm/sso.md` have been telling every operator to create
# exactly that group since the bootstrap step existed. This is the
# first rule that CONSUMES a group name; picking a different one would
# have meant every account created by following the guide silently
# failing the check it was supposed to pass.
operatorGroup = "admins";
# `target`, for `operatorGroup` and nobody else. The `deny` is what makes
# the group rule exclusive, for the reason `collectorOnly` gives: a subject
# outside the group skips the first rule and would otherwise land on
# `default_policy`.
operatorsOnly = target: [
(
target
// {
policy = "one_factor";
subject = [ "group:${operatorGroup}" ];
}
)
(target // { policy = "deny"; })
];
# Whether the swarm collector's client is actually registered here.
#
# Asked of the client list rather than re-derived from the conditions
# that produce it (a non-empty audience set, authelia
# being co-located). A second copy of that predicate is a second thing
# to keep in step, and the two drifting is not a build failure: a rule
# naming an unregistered client is refused by authelia's *startup*
# validator, so the whole SSO service fails to restart.
#
# Reading the registration itself also makes the rule correct for a
# registrar this module has never heard of — an operator registering
# the collector by hand against a provider that is not co-located with
# it gets the same rule, from the same expression.
# ⚠️ `swarm.otel` named in full, not through a let binding: there are two
# otel options one word apart, and only this one is the swarm's collector.
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
# `target`, for the collector's client and nobody else.
#
# The `deny` is the protection, and it has to be a rule of its own. A rule
# with a `subject` does not match a subject outside it: authelia skips it and
# moves on, and with nothing after it lands on `default_policy`, which is
# `one_factor` — *any* authenticated subject, every operator today, every
# agent once they hold authelia accounts. Nor does the audience stand
# between a browser session and this data: `authn_strategies` on the authz
# endpoint also accepts `CookieSession`, and a cookie carries no audience.
# Until a collector is registered, the `deny` is the only rule.
collectorOnly =
target:
lib.optional collectorRegistered (
target
// {
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
}
)
++ [ (target // { policy = "deny"; }) ];
# The metrics store's name, every path. Authelia is consulted there for the
# collector's push route and `/metrics`, and for an operator's session on
# the read API and vmui; Grafana's bearer on the read API is checked by
# `swarm-metrics-auth`, never here.
#
# The collector's client is admitted anywhere on the name. Every other
# subject is refused the collector's two paths, then operators are admitted
# to the rest, then everything else is refused. Prefixes rather than exact
# paths for the collector's two, so no spelling of them reaches the
# operator rule. No rule takes a narrower `resources` than that, so no path
# or query form falls through to `default_policy`.
metricsStoreDomain = config.services.hyperhive.swarm.victoriametrics.domain;
metricsStoreRules =
lib.optional collectorRegistered {
domain = metricsStoreDomain;
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
}
++ [
{
domain = metricsStoreDomain;
policy = "deny";
resources = [
"^/opentelemetry/.*"
"^/metrics.*"
];
}
{
domain = metricsStoreDomain;
policy = "one_factor";
subject = [ "group:${operatorGroup}" ];
}
{
domain = metricsStoreDomain;
policy = "deny";
}
];
# The log store's ingest routes: the collector's client, nobody else. A
# prefix rather than the one route the vhost serves, so no spelling of it,
# and no route added under `/insert/`, reaches `default_policy`. The rest of
# the name (read API, vmui) stays open to any authenticated caller; its
# `/metrics` is covered by `metricsRules`.
logsStoreRules = collectorOnly {
domain = config.services.hyperhive.swarm.victorialogs.domain;
resources = [ "^/insert/.*" ];
};
# One pair per `swarm.otel.publishedScrapeTargets` entry, read off the same
# URL the collector requests and its token is minted for, so a target
# cannot be published without the rules that confine it to the collector.
# An entry on the metrics store's name is covered by `metricsStoreRules`.
metricsRules = lib.filter (r: r.domain != metricsStoreDomain) (
lib.concatLists (
lib.mapAttrsToList (
job: url:
let
# The split `parsePublished` in ./swarm-otel.nix makes for the scrape
# job; the two must agree on what the URL's host and path are.
parts = builtins.match "https://([^/]+)(/.*)" url;
in
if parts == null then
throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived."
else
collectorOnly {
domain = builtins.elemAt parts 0;
# ⚠️ Authelia matches `resources` against the cleaned path PLUS the
# query string, while nginx's `= <path>` location ignores the query.
# A bare `^<path>$` therefore misses `<path>?x`, which the location
# still serves, and that request falls through to `default_policy`.
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ];
}
) config.services.hyperhive.swarm.otel.publishedScrapeTargets
)
);
# Upstream's `services.authelia.instances.<name>` derives the unit,
# user, group and StateDirectory from the instance name
# (`authelia` + `-<name>`). Both are read back from `swarm.authelia.unit`,
# so the instance declared below is the one that option names.
unitName = lib.removeSuffix ".service" cfg.unit;
instance = lib.removePrefix "authelia-" unitName;
stateDir = "/var/lib/${unitName}";
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
refreshConsumer = import ./lib/refresh-consumer.nix { };
storeSeededSecret = import ./lib/store-seeded-secret.nix { };
rootfsCheck = import ./lib/container-rootfs-check.nix { inherit config; };
caTrust = import ./lib/hive-ca-trust.nix {
inherit lib gatewayCfg;
tlsCfg = deployCfg.hive-controller.tls;
};
# `swarm-authelia-bridge` verifies the gateway when it introspects by name.
# The store's `pki` root is a runtime file no build-time option can name, so
# without this bundle an https call out of here fails `UnknownIssuer`.
caBundleModule = caTrust.trustBundle {
inherit pkgs;
name = cfg.machine;
consumers = [
"swarm-authelia-bridge"
]
# Go, for the swarm collector's name and authelia's own. Only where the
# forwarder runs: naming it would otherwise define an empty unit.
++ lib.optional publishes "opentelemetry-collector";
};
# The container's journal forwarder pushes to its own receiver on the swarm
# collector, the route `serviceProducers.swarm-authelia` in
# ./swarm-otel-service.nix declares, as a client of its own.
forwarderClientId = cfg.otel.clientId;
forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}/${cfg.machine}";
# Loopback port of the forwarder's own metrics, inside the container's netns:
# no other collector shares it. The module requires a value.
forwarderTelemetryPort = 8901;
# A client this container registers and mints for itself. Its secret goes
# through the store like every other forwarder's: the publisher carries it
# there and the forwarder reads it back under the same identity.
forwarderClient = {
id = forwarderClientId;
description = "HyperHive authelia journal forwarder";
kind = "machine";
redirectUris = [ ];
# Its own id as its own permitted audience, the one the swarm collector's
# receiver for this container checks.
audience = [ forwarderClientId ];
# The receiver verifies the token offline against `/jwks.json`;
# authelia's default opaque handle fails there.
accessTokenSignedResponseAlg = "RS256";
};
# The secret publisher runs in this container, beside the mint whose secrets
# it copies. It needs a store identity, and a reader is defined by holding a
# certificate the store accepts, never by standing next to it — the rule
# ./swarm-bao.nix's `deploy.bao` options block states in full.
# `deploy.bao.enable` here would be the co-location assumption itself.
publisherCfg = deployCfg.swarm-secret-publisher;
publishes =
publisherCfg.enable
&& publisherCfg.baoClientCertFile != null
&& publisherCfg.baoClientKeyFile != null;
migrateUnit = "${unitName}-client-secrets";
forwarderFetchUnit = "swarm-journal-forwarder-oidc";
# The publisher's store identity, handed into the container as system
# credentials (credential id → host source). The forwarder's fetch presents
# it too: one identity for the whole container.
storeFiles = lib.optionalAttrs publishes (
lib.filterAttrs (_: source: source != null) {
publisher-bao-cert = publisherCfg.baoClientCertFile;
publisher-bao-key = publisherCfg.baoClientKeyFile;
publisher-bao-server-ca = deployCfg.bao.serverCaFile;
}
);
# nixos-container expands `extraFlags` unquoted, so a source path must be
# one word.
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
# The store options both units take, with the credentials as each loads them.
storeIdentityIn =
unit:
let
credential = id: "/run/credentials/${unit}.service/${id}";
in
{
package = deployCfg.bao.package;
addr = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}";
clientCertFile = credential "publisher-bao-cert";
clientKeyFile = credential "publisher-bao-key";
caFile =
if storeFiles ? publisher-bao-server-ca then credential "publisher-bao-server-ca" else null;
};
# 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` (./swarm-authelia-service.nix) 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 in
# ./swarm-authelia-service.nix 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;
# One machine client per hive in the roster. The model — why identity is
# per hive rather than per service, why `audience` is the client id, and
# why these tokens are signed rather than opaque — is in
# `docs/swarm/sso.md`, § Machine clients.
#
# Fed to the option as a DEFINITION in the config block below, rather
# than appended to the declared list downstream. That is what puts it
# through the submodule: one list, one type, every option's default
# present. Appending a raw attrset instead left the list half-typed —
# and a field later added to the submodule then existed on the
# declared entries and not on these, which is an eval error reachable
# only once hive identities are on.
hiveClients = lib.mapAttrsToList (name: _: {
id = "${cfg.hiveClientPrefix}${name}";
description = "HyperHive hive ${name}";
kind = "machine";
redirectUris = [ ];
audience = [ "${cfg.hiveClientPrefix}${name}" ];
accessTokenSignedResponseAlg = "RS256";
}) 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 = [ ];
};
# `swarmctl login`: an operator's own account, from wherever they run the
# CLI. The id is the one `swarmctl/src/remote.rs` sends. The audience is
# the controller's API on the swarm apex, which authelia matches as a URL
# prefix; the redirect matches a loopback callback on any port.
swarmctlClient = {
id = "swarmctl";
description = "swarmctl (swarm operator CLI)";
kind = "native";
redirectUris = [ "http://127.0.0.1/callback" ];
audience = [ "https://${swarmDomain}/api/" ];
};
# Derived from the client list rather than carrying its own `enable`;
# `docs/swarm/sso.md` says why that is one fact instead of two.
#
# ⚠️ 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
# hmac key joins them; the issuer key does not (see below — it is RSA).
randomKeys = [
"jwt"
"session"
"storage-encryption"
]
++ lib.optional oidcEnabled "oidc-hmac";
# Per-client material lives beside the rest of authelia's state, one
# file per half: the relying party needs the PLAINTEXT, authelia keeps
# only a DIGEST. Splitting them is what lets the clients file below be
# re-rendered on every boot from a secret that was minted once.
clientsDir = "${stateDir}/oidc-clients";
clientsFile = "${stateDir}/oidc-clients.yml";
# The users database an empty user set is replaced with. authelia exits at
# startup on `users: {}`, so the file always holds at least this subject,
# and it can never log in: authelia answers a `disabled` user as "not
# found" before checking any password, and the digest decodes but its key
# is all zeros, which no known password hashes to.
#
# The `.` keeps the name out of agent names (`[a-z0-9-]`), and once this
# file is written `swarmctl user add` refuses the name as already taken.
# The writers round-trip `disabled`, so it survives every later write.
placeholderUser = "swarm.placeholder";
placeholderUsersFile = pkgs.writeText "authelia-users-placeholder.yml" ''
users:
${placeholderUser}:
displayname: placeholder (cannot log in)
email: ${placeholderUser}@hyperhive.local
disabled: true
password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
'';
# Rendered at RUNTIME, not evaluated: the digest is read from disk by
# the script, so nothing secret ever enters a nix expression (and
# therefore the store). Everything else here is public metadata that
# nix is the right place for.
# A machine client is not an interactive one with the redirect list left
# empty: authelia derives the permitted grant from what is declared, and an
# omitted `grant_types` means authorization-code ONLY. Measured against
# authelia 4.39.20 — a client rendered without it answers a
# `client_credentials` request with
# unauthorized_client: The OAuth 2.0 Client is not allowed to use
# authorization grant 'client_credentials'
# so the two shapes have to be told apart here rather than inferred from an
# empty list.
#
# `openid` is deliberately absent from a machine client's scopes: authelia
# REFUSES the combination outright ("the values 'openid' are not allowed"
# with `client_credentials`), because a daemon receives an access token and
# never an id-token. There is no user to identify.
renderClient =
c:
''
printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id}
printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description}
''
# A native client is public: it has no secret, so none is minted for it.
+ (
if c.kind == "native" then
''
printf -- ' public: true\n'
''
else
''
printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})"
''
)
+ ''
printf -- ' authorization_policy: one_factor\n'
''
# Read plainly, and that is a property of the list rather than of
# this line: every entry reaching here is a definition of
# `oidc.clients`, so the module system has applied the submodule and
# each option's default is present. A derived entry that names only
# the fields it cares about still arrives with the rest filled in.
+ lib.optionalString (c.kind != "native" && c.tokenEndpointAuthMethod != null) ''
printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod}
''
# Flow-style YAML, matching `scopes` below. The values are client ids
# and hive names, which `Ident` already constrains to `[a-z0-9-]` — no
# character in that set needs quoting in a YAML flow sequence.
+ lib.optionalString (c.audience != [ ]) ''
printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)}
''
+ lib.optionalString (c.accessTokenSignedResponseAlg != null) ''
printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg}
''
+ (
if c.kind == "machine" then
''
printf -- ' grant_types: ["client_credentials"]\n'
printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n'
''
# What authelia 4.39.20 requires of a public client holding
# `authelia.bearer.authz`; its validator refuses the client without
# any one of these lines.
else if c.kind == "native" then
''
printf -- ' token_endpoint_auth_method: none\n'
printf -- ' require_pkce: true\n'
printf -- ' pkce_challenge_method: S256\n'
printf -- ' require_pushed_authorization_requests: true\n'
printf -- ' consent_mode: explicit\n'
printf -- ' response_types: [code]\n'
printf -- ' response_modes: [form_post]\n'
printf -- ' grant_types: [authorization_code, refresh_token]\n'
printf -- ' scopes: [offline_access, authelia.bearer.authz]\n'
printf -- ' redirect_uris:\n'
${lib.concatMapStrings (u: ''
printf -- ' - %s\n' ${lib.escapeShellArg u}
'') c.redirectUris}
''
else
''
printf -- ' scopes: [openid, profile, email, groups]\n'
printf -- ' redirect_uris:\n'
${lib.concatMapStrings (u: ''
printf -- ' - %s\n' ${lib.escapeShellArg u}
'') c.redirectUris}
''
);
oidcClientSecrets = import ./lib/oidc-client-secrets.nix { };
# Every client with a secret to mint: a native client is public and has none.
secretClients = lib.filter (c: c.kind != "native") cfg.oidc.clients;
# Where a client's plaintext lives in the store: the path its reader fetches
# it from, which is `deploy.swarm-secret-publisher.secrets` for every client
# something reads. The rest, the bridge's own among them, sit with the
# services.
clientLeaf =
id:
let
listed = lib.findFirst (s: s.clientId == id) null publisherCfg.secrets;
in
if listed == null then "secret/swarm/services/${id}/oidc/client" else listed.path;
# Each secret is kept as two halves: the plaintext its relying party
# authenticates with, and the digest authelia compares against. The two live
# in different containers, so neither side can generate it alone, and this is
# the only place that sees both.
#
# `--random` is why no plaintext reaches an argv: authelia generates the
# password itself and prints it beside its digest.
#
# Without a store identity the plaintext has nowhere else to go, so it stays
# in a file beside the digest.
fileMintScript = ''
mint() {
sec="$clients/$1.secret"
dig="$clients/$1.digest"
if [ -s "$sec" ] && [ -s "$dig" ]; then
chmod 0600 "$sec" "$dig"
return
fi
out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random)
printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec"
printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig"
chmod 0600 "$sec" "$dig"
# Fail closed. An empty secret is a client that can never
# authenticate, and it surfaces three layers away as an opaque
# 401 from the token endpoint — refusing to start is by far the
# cheaper failure to diagnose.
if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then
echo "authelia crypto hash generate produced no secret/digest for $1" >&2
exit 1
fi
echo "minted client secret for $1"
}
${lib.concatMapStrings (c: ''
mint ${lib.escapeShellArg c.id}
'') secretClients}
'';
# The plaintext goes straight into the store and only the digest is written
# to disk. A digest on disk is enough for authelia to start while the store is
# down; a client with no digest cannot be rendered, so that run fails and
# retries (./lib/store-retry.nix). A status of 3 is a state a retry does not
# change, and `RestartPreventExitStatus` leaves the unit failed for an
# operator.
storeMintScript = ''
${oidcClientSecrets}
${lib.concatMapStrings (c: ''
mint_client_secret ${lib.escapeShellArg c.id} ${lib.escapeShellArg (clientLeaf c.id)} || exit $?
'') secretClients}
'';
# The OIDC half of the first-boot generator, kept out of the script
# body so neither is read through the other's indentation.
oidcGenScript = lib.optionalString oidcEnabled ''
${lib.optionalString (!publishes) ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
''}
clients=${lib.escapeShellArg clientsDir}
mkdir -p "$clients"
chmod 0700 "$clients"
${if publishes then storeMintScript else fileMintScript}
# Re-rendered every boot, deliberately: the secret is minted once,
# but the metadata around it (a new redirect URI, a renamed client)
# comes from nix and has to be able to change without disturbing
# the secret. Written through a temp file so a crash mid-write
# cannot leave authelia half a file to parse.
{
printf -- 'identity_providers:\n'
printf -- ' oidc:\n'
printf -- ' clients:\n'
${lib.concatMapStrings renderClient cfg.oidc.clients}
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
${lib.optionalString publishes ''
# authelia reads its clients once, as it starts.
if [ -n "''${MINTED-}" ]; then
refresh_unit ${lib.escapeShellArg cfg.unit} ${lib.escapeShellArg clientsFile}
fi
''}
'';
storeRetry = import ./lib/store-retry.nix { };
# The bridge's own client secret, in the memory-backed runtime directory of
# its unit. While a plaintext file from an earlier release exists the bridge
# keeps using it, since it is the value the digest was made from; the file
# goes once the store holds the same value, and the store's takes over.
bridgeSecretFile = "/run/swarm-authelia-bridge/client-secret";
bridgeSecretFetch = ''
set -euo pipefail
${atomicWriteSecret}
${storeSeededSecret}
${oidcClientSecrets}
legacy=${lib.escapeShellArg "${clientsDir}/${bridgeClientId}.secret"}
store_init
store_login
if [ -s "$legacy" ]; then
value="$(< "$legacy")"
elif client_secret_read ${lib.escapeShellArg (clientLeaf bridgeClientId)}; then
value="$CLIENT_SECRET_VALUE"
else
echo "the store holds no readable secret for ${bridgeClientId}" >&2
exit 1
fi
atomic_write_secret 0600 "" ${lib.escapeShellArg bridgeSecretFile} "$value"
'';
# authelia's keys, one leaf each under its container's name. The nix half of
# `autheliaKeyLeaves` in ./swarm-bao.nix, which grants the publisher
# `create` and `read` on exactly these.
keyLeaf = name: "secret/swarm/services/${cfg.machine}/keys/${name}";
# The keys as a file-per-key script, for a container with no store identity:
# each is generated once and never rotated here. The session and storage
# keys are load-bearing for data already written (sessions, the encrypted
# database), so replacing one is an operator action, not a boot action.
fileKeysScript = ''
for f in ${lib.concatStringsSep " " randomKeys}; do
p=${lib.escapeShellArg stateDir}/"$f".key
if [ ! -s "$p" ]; then
value=$(head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
atomic_write_secret 0600 "" "$p" "$value"
echo "generated $p"
fi
chmod 0600 "$p"
done
'';
# The same keys from the store, at the same paths. The store's value wins,
# then the file's, which seeds the store when it holds none, so a key never
# changes value by moving; a new one is minted only where neither exists.
# The issuer key is an RSA pair rather than a random blob because it signs
# id tokens, which relying parties verify against the public half.
storeKeysScript = ''
${storeSeededSecret}
${refreshConsumer}
store_init
store_login
mint_hex() {
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
mint_issuer() {
openssl genrsa 4096
}
# <name> <mint command…>. A changed file reaches a running authelia by
# restarting it; it reads its keys once, as it starts.
keep_key() {
local name="$1" p value
p=${lib.escapeShellArg stateDir}/"$name".key
shift
value="$(store_seeded ${lib.escapeShellArg (keyLeaf "")}"$name" "$p" "$@")" || exit 1
if secret_differs "$p" "$value"; then
atomic_write_secret 0600 "" "$p" "$value"
echo "wrote $p"
refresh_unit ${lib.escapeShellArg cfg.unit} "$p"
fi
chmod 0600 "$p"
}
for f in ${lib.concatStringsSep " " randomKeys}; do
keep_key "$f" mint_hex
done
${lib.optionalString oidcEnabled "keep_key oidc-issuer mint_issuer"}
'';
# A netns of its own on the hive bridge: the container dials the store, the
# swarm collector and its own public name through the bridge, and the
# gateway reaches the three listeners below over sockets, so authelia binds
# no TCP port on any interface.
privateNetwork = true;
# Fixed, not an option: the directory's mode is what keeps the sockets
# private, so it holds these three sockets and nothing else.
socketDir = "/run/swarm-authelia";
mainSocket = "${socketDir}/authelia.sock";
metricsSocket = "${socketDir}/metrics.sock";
bridgeSocket = "${socketDir}/bridge.sock";
# `upstream` forwards the request's own URI, `upstreamAt` replaces it.
upstream = socket: "http://unix:${socket}";
upstreamAt = socket: path: "http://unix:${socket}:${path}";
# The gid the gateway's nginx runs as. Only the number crosses into the
# container, which knows it as `gateway-nginx`; that group is the primary
# group of every process that creates a socket, so nginx can connect.
nginxGid = config.ids.gids.nginx;
socketGroup = "gateway-nginx";
in
{
# What authelia IS to every hive, where it answers (`url`), the OIDC
# register every service checks itself against, is
# `swarm.authelia` in ./swarm-authelia-service.nix. What the host running
# the container decides is here — which two builds it
# runs, and two filesystem paths that only exist on the machine running
# `swarm-authelia`. A hive that does not run it has nothing at any of those
# paths. `enable` already lives in ./deploy.nix, which also carries the
# renames.
#
# ⚠️ `usersFile` is a path *inside* the container and still belongs here:
# a path's scope is the scope of the filesystem it names, and that
# filesystem is this host's container root.
options.services.hyperhive.deploy.authelia = {
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.
'';
};
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.
'';
};
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-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.
When the user set is empty this module writes a single
disabled placeholder subject, `${placeholderUser}`, so authelia
starts and serves its login page; nobody can log in until
`swarmctl user add` creates the first account.
'';
};
hostUsersFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${deployCfg.authelia.usersFile}";
description = ''
`usersFile` as seen from the **host** — the container's root
prefixed onto the path authelia sees.
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.
'';
};
};
config = lib.mkIf deployCfg.authelia.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 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. `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
forwarderClient
]
++ lib.optional (swarmDomain != null) swarmctlClient;
# A redirect URI on a machine client is not harmless-but-unused: it
# means whoever wrote it believes a browser is involved. Failing here
# is how that belief gets corrected at the point it was expressed,
# rather than at a token endpoint months later.
assertions =
map (c: {
assertion = c.kind != "machine" || c.redirectUris == [ ];
message =
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
+ "client has nobody to redirect; drop the URIs or make it "
+ "kind = \"interactive\".";
}) cfg.oidc.clients
# Two clients sharing an id renders two YAML entries under one name.
# Newly reachable now that part of the list is DERIVED: a hive called
# `x` and a service client called `hive-x` never met before. Authelia
# would reject it, but three layers away and at boot — naming both
# sources here is the cheaper failure.
++ [
{
assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients;
message =
"services.hyperhive.swarm.authelia: duplicate OIDC client id(s): "
+ lib.concatStringsSep ", " (
lib.unique (
lib.filter (id: lib.count (x: x == id) (map (c: c.id) cfg.oidc.clients) > 1) (
map (c: c.id) cfg.oidc.clients
)
)
)
+ ". Hive identities are named `hive-<name>` from "
+ "services.hyperhive.swarm.hives; rename the hive or the "
+ "colliding client.";
}
# The two obligations `authelia.bearer.authz` carries. Authelia
# enforces both itself — but in its `preStart` validator, so a
# violation produces a green `nixos-rebuild switch` and an authelia
# that then refuses to come back up, taking swarm SSO with it.
# Asserting here moves the same failure to evaluation, where a
# wrong value costs a build and nothing else.
{
assertion = lib.all (
c: !(c.bearerAuthz || c.kind == "native") || c.audience != [ ]
) cfg.oidc.clients;
message =
"services.hyperhive.swarm.authelia.oidc.clients: "
+ lib.concatStringsSep ", " (
map (c: "client '${c.id}'") (
lib.filter (c: (c.bearerAuthz || c.kind == "native") && c.audience == [ ]) cfg.oidc.clients
)
)
+ " sets bearerAuthz or kind = \"native\" but declares no audience. The audience is "
+ "what authorises a bearer token at a given URL, so without "
+ "one the scope grants access to nothing and authelia refuses "
+ "the configuration outright.";
}
{
assertion = lib.all (
c:
!c.bearerAuthz
|| lib.elem c.tokenEndpointAuthMethod [
"client_secret_basic"
"client_secret_jwt"
"private_key_jwt"
]
) cfg.oidc.clients;
message =
"services.hyperhive.swarm.authelia.oidc.clients: "
+ lib.concatStringsSep ", " (
map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") (
lib.filter (
c:
c.bearerAuthz
&& !lib.elem c.tokenEndpointAuthMethod [
"client_secret_basic"
"client_secret_jwt"
"private_key_jwt"
]
) cfg.oidc.clients
)
)
+ ". A confidential client carrying authelia.bearer.authz must "
+ "authenticate with client_secret_basic, client_secret_jwt or "
+ "private_key_jwt. Notably client_secret_post is refused, and "
+ "it is what an OAuth2 client library may reach for first. "
+ "Leaving it null is refused too: authelia requires the method "
+ "to be STATED under this scope rather than defaulted.";
}
# Nothing else stops `bearerAuthz` on an interactive client, and it
# would silently do nothing: `renderClient` reads the flag only in
# the `machine` branch, so the scope is simply never emitted and the
# client authenticates fine while being authorised for nothing.
#
# That is this module's own failure mode one level up — a green
# build and a grant that does not exist — and `kind` defaults to
# `interactive`, so it is reached by FORGETTING a field rather than
# by writing a wrong one.
{
assertion = lib.all (c: !c.bearerAuthz || c.kind == "machine") cfg.oidc.clients;
message =
"services.hyperhive.swarm.authelia.oidc.clients: "
+ lib.concatStringsSep ", " (
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.kind != "machine") cfg.oidc.clients)
)
+ " sets bearerAuthz with kind != \"machine\". The scope is only "
+ "emitted for machine clients, so this grants nothing while "
+ "evaluating and deploying cleanly. A browser client has a user "
+ "to authorise and does not need it.";
}
{
assertion = lib.all oneWord (lib.attrValues storeFiles);
message = ''
The secret publisher's store identity paths must not contain
whitespace: nspawn's --load-credential receives them through
nixos-container's unquoted extra flags.
'';
}
{
assertion =
(hyperhiveCfg.swarm.otel.serviceProducers.${cfg.machine}.clientId or null) == forwarderClientId;
message = ''
services.hyperhive.swarm.otel.serviceProducers has no entry
"${cfg.machine}" with clientId "${forwarderClientId}", so the swarm
collector has no receiver for authelia's journal forwarder. Declare
it in swarm-otel-service.nix.
'';
}
];
# Authelia's own gateway surface: the vhost that fronts it and the
# name the hive resolver answers for. Both live here rather than in
# the gateway, and both are inside `deployCfg.authelia.enable` — that guard is the
# load-bearing part.
#
# ⚠️ Every hive in a swarm knows `authelia.url`, but only the host
# that RUNS the container may claim the name. A client hive
# declaring this vhost would answer for a service it does not run,
# and publishing the DNS record would point every agent on its
# bridge at that wrong answer.
services.hyperhive.gateway.localNames = [ cfg.domain ];
# This host serves the vhost, and the container behind it resolves
# through the hive's dnsmasq.
services.hyperhive.gateway.enable = lib.mkDefault true;
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
# `server_name = authelia.domain`, all of `/` → authelia.
#
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
# authelia validates `authelia_url ⊂ session cookie domain` at
# STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves.
#
# ⚠️ And deliberately NO `auth_request` here: it would put the login
# page behind the login it serves.
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
locations."/" = {
proxyPass = upstreamAt mainSocket "/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
# authelia decides by the ORIGINAL request, not by the hop it
# sees — the login redirect and the session cookie's domain
# both derive from these. Without them every request looks
# like it arrived over a local socket in plain http.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# A dead upstream here means "not bootstrapped" far more often
# than "misconfigured proxy", and a bare 502 says the opposite.
proxy_intercept_errors on;
error_page 502 503 504 = /__hive_sso_unavailable;
'';
};
locations."= /__hive_sso_unavailable" = {
extraConfig = ''
internal;
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
default_type text/html;
'';
};
# Authelia's MACHINE surface, routed without the error interception
# above. A longer prefix wins over `/` in nginx, and
# `proxy_intercept_errors` / `error_page` are set inside that
# location rather than at server level, so they do not reach here.
#
# ⚠️ THIS IS A SECURITY BOUNDARY, not a tidy-up. Do not fold it back
# into `/`: the friendly error page answers **200**, and
# `auth_request` reads any 2xx as ACCESS GRANTED. Full consequence
# list in `docs/swarm/sso.md`, § Machine callers must fail closed.
#
# The split is by AUDIENCE, not by path list — including the login
# page's own XHR. Enumerating individual endpoints would leave the
# next one added silently intercepted.
locations."/api/" = {
proxyPass = upstream mainSocket;
};
locations."/.well-known/" = {
proxyPass = upstream mainSocket;
};
# `swarm-authelia-bridge`, a separate process from authelia, also a
# machine surface. The trailing slash on `proxyPass` strips the
# prefix, so `/bridge/requests` reaches the bridge as `/requests`.
# The bridge itself refuses every client but swarm-controller's.
locations."/bridge/" = {
proxyPass = upstreamAt bridgeSocket "/";
};
# Authelia's own metrics, for the swarm collector wherever it runs.
# The listener (`metricsSocket`) is unauthenticated and serves at `/`;
# this location is the only way in from off the machine. `= /metrics`
# outranks the `/` prefix, so its auth is the one that runs.
#
# The collector requests exactly this URL, declared as
# `publishedScrapeTargets.authelia` in ./swarm-otel-service.nix; the two
# must agree, since that string is also the token's audience.
locations."= /metrics" = {
proxyPass = upstreamAt metricsSocket "/";
extraConfig = ''
auth_request /__authelia_metrics_authz;
'';
};
# The subrequest, same shape as the forge's `= /__forge_metrics_authz`
# (./hive-forge/default.nix), and machine-facing for the same reason: no
# `error_page 401 =302`, so a scraper gets the 401 rather than a login
# page parsed as metrics.
#
# Authelia authorising a request for itself does not loop: the
# subrequest lands on `/api/` above, which carries no `auth_request`.
# An authelia that is down answers the subrequest 502, which
# `auth_request` turns into a 500 for the scrape — closed, not open.
locations."= /__authelia_metrics_authz" = {
proxyPass = "https://${cfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which would
# override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo cfg.domain}
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
# Order the container after the host CA generator, so the bind source
# exists before nspawn sets the mount up. nspawn also fails to start it
# when a credential source is missing, so where this host mints the
# publisher's leaf the container pulls the minting unit in and waits for
# it, as ./swarm-grafana.nix's does. The minting is local: the store need
# not be up.
systemd.services."container@${cfg.machine}" = lib.mkMerge [
caTrust.containerOrdering
(rootfsCheck.warnOnly cfg.machine)
(lib.mkIf publishes {
after = [ "swarm-bao-pki.service" ];
wants = [ "swarm-bao-pki.service" ];
})
];
# The socket directory, created host-side before the container starts:
# nixos-container refuses to start when a bind source is missing.
#
# Root-owned and group nginx, `0770`: the listeners create their sockets
# through the group (the authelia user's uid is allocated inside the
# container, so the host cannot name it), and the gateway traverses it the
# same way. Everything else is kept out.
systemd.tmpfiles.rules = [
"d ${socketDir} 0770 root ${toString nginxGid} - -"
];
# The hive bridge carries the container's DHCP lease and its resolver.
services.hyperhive.network.enable = lib.mkDefault true;
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;
# No `--link-journal=host`: the journal stays in the guest, where the
# container's own forwarder reads it (../container-modules/swarm-journal-forwarder.nix).
extraFlags = lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
inherit privateNetwork;
# Public trust bundle only, read-only. Empty when the gateway is not
# self-signed, so the whole trust path drops out cleanly.
hostBridge = networkCfg.bridgeName;
bindMounts = {
# A bind mount rather than a path both sides happen to know: a
# container's `/run` is an nspawn tmpfs the host cannot see into.
${socketDir} = {
hostPath = socketDir;
isReadOnly = false;
};
}
// caTrust.bindMount;
config =
{ ... }:
{
imports = [
../container-modules/swarm-container.nix
../container-modules/swarm-journal-forwarder.nix
(import ../container-modules/swarm-container-resolver.nix {
inherit (networkCfg) bridgeIp;
# The forwarder dials the swarm collector and authelia by name,
# and its fetch, the bridge's fetch, the key and secret units the
# store.
dnsConsumers = [
cfg.unit
]
++ lib.optionals publishes [
"opentelemetry-collector.service"
"${forwarderFetchUnit}.service"
"${migrateUnit}.service"
"${unitName}-secrets.service"
"swarm-authelia-bridge.service"
];
})
caBundleModule
];
services.hyperhive.swarmContainer = { inherit privateNetwork; };
# The bridge address comes from the gateway's DHCP. `swarm-bridge-dns`
# owns resolv.conf, so dhcpcd must not write it over that unit.
networking.interfaces.eth0.useDHCP = true;
networking.dhcpcd.extraConfig = "nohook resolv.conf";
# The sockets' group is the primary group of the processes that create
# them, so that group is nginx's gid. A static group, so every unit
# that binds a socket can name it.
users.groups.${socketGroup}.gid = nginxGid;
# Authenticated as the forwarder's own OIDC client, its secret read
# from the store with the publisher's identity: one identity for the
# whole container. With no identity there is nothing to read it with,
# and the forwarder is off.
services.hyperhive.swarmJournalForwarder = lib.mkIf publishes {
enable = true;
serviceName = "authelia";
endpoint = forwarderEndpoint;
clientId = forwarderClientId;
tokenUrl = "${toString cfg.url}/api/oidc/token";
telemetryPort = forwarderTelemetryPort;
store = storeIdentityIn forwarderFetchUnit // {
secretPath = "secret/swarm/services/${forwarderClientId}/oidc/client";
};
};
systemd.services.${forwarderFetchUnit} = lib.mkIf publishes {
serviceConfig.LoadCredential = lib.attrNames storeFiles;
};
# Moves the plaintext files an earlier release left in `clientsDir`
# into the store and removes each once the store holds the same
# value. With no file left it does nothing, store or no store. Nothing
# orders authelia after it: authelia starts from its digests.
systemd.services.${migrateUnit} = lib.mkIf publishes {
description = "move authelia's plaintext OIDC client secrets into the swarm secret store";
wantedBy = [ "multi-user.target" ];
after = [ "${unitName}-secrets.service" ];
path = [
pkgs.coreutils
pkgs.gnugrep
deployCfg.bao.package
];
startLimitBurst = storeRetry.startLimitBurst;
startLimitIntervalSec = storeRetry.startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
User = unitName;
Group = socketGroup;
UMask = "0077";
LoadCredential = lib.attrNames storeFiles;
TimeoutStartSec = 120;
RestartPreventExitStatus = 3;
SyslogIdentifier = migrateUnit;
};
environment =
let
store = storeIdentityIn migrateUnit;
in
{
BAO_ADDR = store.addr;
BAO_CLIENT_CERT = store.clientCertFile;
BAO_CLIENT_KEY = store.clientKeyFile;
}
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; };
script = ''
set -uo pipefail
${storeSeededSecret}
${oidcClientSecrets}
clients=${lib.escapeShellArg clientsDir}
known=${lib.escapeShellArg (lib.concatMapStrings (c: " ${c.id}") secretClients + " ")}
pending=0
for f in "$clients"/*.secret; do
[ -e "$f" ] || continue
id="$(basename "$f" .secret)"
case "$known" in
*" $id "*) pending=1 ;;
*) echo "left $f in place: no client $id is registered here" >&2 ;;
esac
done
if [ "$pending" = 0 ]; then
echo "no plaintext client secret files to move"
exit 0
fi
store_init
store_login
status=0
${lib.concatMapStrings (c: ''
rc=0
migrate_client_secret ${lib.escapeShellArg c.id} ${lib.escapeShellArg (clientLeaf c.id)} || rc=$?
if [ "$rc" != 0 ] && [ "$status" != 1 ]; then
status="$rc"
fi
'') secretClients}
exit "$status"
'';
};
# 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 = [ deployCfg.authelia.package ];
# authelia's own keys: jwt, session, storage encryption, OIDC HMAC
# and the OIDC issuer. Nothing outside this container reads them.
# With the publisher's store identity the swarm secret store holds
# them and this unit copies them into place, seeding the store from
# the files an earlier release generated here when it holds none;
# without it the unit generates them on first boot, as before.
#
# 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" ];
# `deployCfg.authelia.package` is here for its CLI, not its
# daemon: the client secrets are minted with `authelia crypto
# hash generate`, which is the only way to produce a digest in
# the exact form authelia will later verify.
path = [
pkgs.coreutils
]
++ lib.optionals oidcEnabled [
pkgs.openssl
deployCfg.authelia.package
]
++ lib.optionals publishes [
deployCfg.bao.package
pkgs.gnugrep
pkgs.systemd
];
# A store that cannot answer, with a key missing on disk, is a
# failed run that retries (./lib/store-retry.nix).
startLimitBurst = lib.mkIf publishes storeRetry.startLimitBurst;
startLimitIntervalSec = lib.mkIf publishes storeRetry.startLimitIntervalSec;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = unitName;
Group = socketGroup;
StateDirectory = unitName;
StateDirectoryMode = "0700";
UMask = "0077";
SyslogIdentifier = "${unitName}-secrets";
}
// lib.optionalAttrs publishes (
storeRetry.serviceConfig
// {
LoadCredential = lib.attrNames storeFiles;
TimeoutStartSec = 120;
# A secret that has no digest, or a digest that has no secret,
# is for an operator; minting again would rotate it.
RestartPreventExitStatus = 3;
}
);
environment = lib.optionalAttrs publishes (
let
store = storeIdentityIn "${unitName}-secrets";
in
{
BAO_ADDR = store.addr;
BAO_CLIENT_CERT = store.clientCertFile;
BAO_CLIENT_KEY = store.clientKeyFile;
}
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; }
);
script = ''
set -euo pipefail
${atomicWriteSecret}
${if publishes then storeKeysScript else fileKeysScript}
${oidcGenScript}
# authelia exits at startup on an empty user set, so an empty
# database gets the disabled placeholder subject instead. A
# file holding exactly `users: {}` is an empty set too; neither
# writer ever produces one, so replacing it drops no user.
users=${lib.escapeShellArg deployCfg.authelia.usersFile}
if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then
install -m 0600 ${placeholderUsersFile} "$users"
echo "seeded $users with the disabled placeholder ${placeholderUser}"
fi
chmod 0600 "$users"
'';
};
# The only process allowed to write `deployCfg.authelia.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).
#
# With the store identity its client secret is read from the store
# into `bridgeSecretFile` before each start (`bridgeSecretFetch`). A
# store that does not answer fails the start, and the unit retries.
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"
]
++ lib.optional publishes "${migrateUnit}.service";
wants = [
"${unitName}-secrets.service"
"${unitName}.service"
];
path = lib.optionals publishes [
pkgs.coreutils
pkgs.gnugrep
deployCfg.bao.package
];
preStart = lib.mkIf publishes bridgeSecretFetch;
startLimitBurst = lib.mkIf publishes storeRetry.startLimitBurst;
startLimitIntervalSec = lib.mkIf publishes storeRetry.startLimitIntervalSec;
serviceConfig = {
ExecStart = "${deployCfg.authelia.bridgePackage}/bin/swarm-authelia-bridge";
User = unitName;
Group = socketGroup;
Restart = "on-failure";
RestartSec = if publishes then storeRetry.serviceConfig.RestartSec else "5s";
}
// lib.optionalAttrs publishes {
RuntimeDirectory = "swarm-authelia-bridge";
LoadCredential = lib.attrNames storeFiles;
};
environment = {
SWARM_AUTHELIA_BRIDGE_SOCKET = bridgeSocket;
# ONE file, and this is it. The bridge keeps no second,
# private users store: a store of its own treated as canonical,
# with `users.yml` written as a rendering of it, would be two
# canonical stores for one physical file, and would make
# `swarm agent create` refuse to start on a hive whose
# `users.yml` already held users.
SWARM_AUTHELIA_BRIDGE_USERS_FILE = deployCfg.authelia.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 = "${deployCfg.authelia.package}/bin/authelia";
# By name through the gateway, not loopback. A loopback
# literal encodes "authelia is in my netns" at the call site,
# and authelia's OIDC endpoints are https-only in effect —
# reached directly they answer 400, because the forwarded
# headers nginx injects for every other consumer are what let
# it determine its own issuer. Same URL `swarm-nats-auth`
# uses, so there is one idiom rather than two.
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection";
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
# Minted by `${unitName}-secrets` (it iterates every entry in
# `cfg.oidc.clients`, which always includes `bridgeClient`).
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE =
if publishes then bridgeSecretFile else "${clientsDir}/${bridgeClientId}.secret";
# The one client the bridge answers. Every other active token
# (each hive and its agents hold one) is refused, on every
# request, since `/bridge/` is reachable from any host.
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
}
// lib.optionalAttrs publishes (
let
store = storeIdentityIn "swarm-authelia-bridge";
in
{
BAO_ADDR = store.addr;
BAO_CLIENT_CERT = store.clientCertFile;
BAO_CLIENT_KEY = store.clientKeyFile;
}
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; }
);
};
# Upstream's unit is `ProtectSystem=strict`, so the socket directory
# is writable only by name. A socket left by a killed process would
# fail the next `bind`, so both are removed before each start.
systemd.services.${unitName}.serviceConfig = {
ReadWritePaths = [ socketDir ];
ExecStartPre = [ "${pkgs.coreutils}/bin/rm -f ${mainSocket} ${metricsSocket}" ];
};
services.authelia.instances.${instance} = {
enable = true;
package = deployCfg.authelia.package;
# Primary group of the unit and its user: the group the sockets are
# created in (see `socketGroup`).
group = socketGroup;
# Merged at RUNTIME alongside the nix-generated config, which
# is the whole reason the client digests can exist at all:
# `settings` below is rendered into the world-readable nix
# store, and a client secret's digest may not go there.
# Empty (and inert) on a hive with no clients declared.
settingsFiles = lib.optional oidcEnabled clientsFile;
secrets = {
jwtSecretFile = "${stateDir}/jwt.key";
sessionSecretFile = "${stateDir}/session.key";
storageEncryptionKeyFile = "${stateDir}/storage-encryption.key";
}
// lib.optionalAttrs oidcEnabled {
# Both are `LoadCredential`-delivered by upstream's module,
# so they reach authelia as `AUTHELIA_*_FILE` env and never
# as values. Same by-path discipline as the three above —
# which is what lets the provider's own secrets stay
# in-container: nothing outside this container reads them.
#
# ⚠️ The issuer key is NOT passed as the deprecated
# `issuer_private_key`: on 4.38+ the config key is the
# `jwks` *list*, and a list element cannot take the
# `_FILE` env treatment. Upstream's module bridges that
# by generating a small settings file that templates the
# PEM in (`{{ secret "<path>" }}`) and prepending it to
# `settingsFiles`. So handing it a path here is the
# modern shape, not the legacy one — checked against the
# pin (nixpkgs `services/security/authelia.nix`), because
# the option name alone reads like the old key.
oidcHmacSecretFile = "${stateDir}/oidc-hmac.key";
oidcIssuerPrivateKeyFile = "${stateDir}/oidc-issuer.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 = "unix://${mainSocket}?umask=0007";
# Let a machine present an OAuth2 access token to the same
# `auth_request` endpoint browsers use, so a scraper can be
# authenticated by the gateway instead of every service
# growing its own static bearer.
#
# ⚠️ `authn_strategies` REPLACES the defaults rather than
# adding to them, so `CookieSession` is listed explicitly.
# Dropping it does not fail to evaluate and does not fail to
# start — it silently ends every operator session on the
# swarm UI, which rides this same endpoint.
#
# Unconditional, and not keyed to whichever service is
# currently scraped: this only makes a *scheme* available.
# Authorisation is the audience — authelia refuses a token
# that carries no audience for the requested URL, and a
# client may only be issued audiences it is registered for.
# So enabling the scheme grants nobody anything until a
# client is registered for a specific URL.
server.endpoints.authz.auth-request = {
implementation = "AuthRequest";
authn_strategies = [
{
name = "HeaderAuthorization";
schemes = [ "Bearer" ];
}
{ name = "CookieSession"; }
];
};
log.level = "info";
# Prometheus exposition for the swarm collector to scrape.
# A socket only the gateway can reach, like the main listener above
# and for a stronger reason: this endpoint has no authentication of its
# own, and it reports request volumes and outcomes for every
# SSO login on the swarm. The collector reaches it through the
# vhost's gated `= /metrics` location.
telemetry.metrics = {
enabled = true;
address = "unix://${metricsSocket}?umask=0007";
};
# `watch` is load-bearing, not a convenience: authelia reads
# this file once at startup, and `swarm-authelia-bridge` writes
# it to create agent identities while being unable to restart
# authelia — running unprivileged is the whole reason it may
# write the file at all. Without this, an identity it creates is
# real on disk and invisible until something unrelated restarts.
authentication_backend.file = {
path = deployCfg.authelia.usersFile;
watch = true;
};
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
# user", which is authentication, not authorisation. The
# swarm UI is operator-only, and agents are getting
# authelia accounts of their own — so the day that lands,
# a session alone would be enough to open it. The
# `operatorsOnly` pairs below are what make the distinction
# real; without them the vhost's `auth_request` is a check
# nobody fails.
#
# The group is a constant rather than an option: it is the
# value an operator types into `swarmctl user add --group`,
# and a configurable name is one more way for the rule and
# the account to disagree silently.
access_control = {
default_policy = "one_factor";
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
# The metrics rules are listed first so they cannot be
# shadowed by a broader domain rule added later. A `deny` with
# no `resources` ends its domain: a rule for that domain
# listed after it is never reached.
rules =
# The collector's client where one is registered, then
# `deny` — see `collectorOnly` above, which is where the
# reasoning for both halves lives.
metricsRules
++ metricsStoreRules
++ logsStoreRules
# The swarm UI on the apex. Not keyed to a deploy flag: its
# vhost is on the swarm controller's host, which need not
# be this one. Guarded on the domain being set: without it
# a null apex would render a rule matching the string "null".
++ lib.optionals (swarmDomain != null) (operatorsOnly {
domain = swarmDomain;
})
# The store's browser UI. Not keyed to a deploy flag: its
# vhost is on the store's host, which need not be this
# one, and without these rules any session passes it.
++ lib.optionals (swarmDomain != null) (operatorsOnly {
domain = hyperhiveCfg.swarm.bao.ui.domain;
})
# Every hive's dashboard, each on its own hive's host.
++ lib.optionals (swarmDomain != null) (
lib.concatMap (domain: operatorsOnly { inherit domain; }) (
lib.attrValues hyperhiveCfg.swarm.dashboardDomains
)
);
};
# 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.
#
# Each hive's dashboard answers on `swarm.dashboardDomains`,
# under this domain, so the cookie reaches it whatever the
# hive's own domain is.
session.cookies = [
{
domain = cookieDomain;
authelia_url = "https://${cfg.domain}";
}
];
storage.local.path = "${stateDir}/db.sqlite3";
notifier.filesystem.filename = "${stateDir}/notification.txt";
};
};
};
};
};
}