Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
18de4b48c7 module-eval: pin authelia's rename and its repointed readers
Two arms with different jobs. `usersFile` is configured through its old
path, so dropping the rename entry fails the eval. `hostClientSecretDir`
cannot be configured at all — it is read-only and derived — so its arm
asserts that a consumer renders the derived path, which is what breaks
if a reader is left pointing at the namespace the option moved out of.

Seven of those reads went through an `autheliaCfg` alias rather than a
full path, where a path-shaped grep does not see them. That, not the
rename, was the failure this change could have shipped.

The two read-only options get no rename entry. A rename module
contributes a definition to its target and a read-only option refuses a
second one, so a shim for either makes every evaluation fail with "set
multiple times", naming the option's own declaration as the rival. Of
the 38 distinct leaves the rename table already targets, these are the
only two declared read-only.
2026-09-07 15:44:07 +02:00
atlas
9c09653603 deploy: move authelia's three host paths out of swarm.authelia
`usersFile`, `hostClientSecretDir` and `hostUsersFile` are filesystem
paths that only exist on the machine running the `swarm-authelia`
container. A hive that does not run it has nothing at any of them, so
they fail the swarm-wide test the namespace's header states.

`usersFile` is a path *inside* the container and still belongs on the
deploy side: a path's scope is the scope of the filesystem it names, and
that filesystem is this host's container root.

The two `host*` options are `readOnly` and derived, so nothing can set
them through the rename shims — those serve readers. The shims are still
required: `mkRenamedOptionModule` is what keeps an out-of-tree module
reading the old path resolving at all.

Seven reads went through an alias rather than a full path
(`autheliaCfg.hostClientSecretDir` in five modules, `.hostUsersFile` in a
sixth, `swarmCfg.authelia.hostClientSecretDir` in a seventh). Every one
of those files already binds `deployCfg`, so the repoint needed no new
alias. Four more sites were prose, including one in `docs/`, which no
grep restricted to `nix/` would have found.
2026-09-07 15:44:07 +02:00
13 changed files with 135 additions and 74 deletions

View file

@ -338,7 +338,7 @@ On a host that runs the queue and the IdP itself, all three default to
the local ones and there is nothing to set. Any other hive needs them
spelled out, and needs the secret to physically be there: the swarm does
not distribute it. Copy `hive-<hiveName>.secret` out of the swarm host's
`swarm.authelia.hostClientSecretDir` with whatever secret management the
`deploy.authelia.hostClientSecretDir` with whatever secret management the
deployment already uses.
The identity is not a choice — a hive authenticates as `hive-<hiveName>`

View file

@ -93,6 +93,19 @@ in
[ "services" "hyperhive" "swarm" "authelia" "enable" ]
[ "services" "hyperhive" "deploy" "authelia" "enable" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "authelia" "usersFile" ]
[ "services" "hyperhive" "deploy" "authelia" "usersFile" ]
)
# `hostClientSecretDir` and `hostUsersFile` moved in the same commit and
# deliberately have NO entry here. A rename module contributes a
# *definition* to its target, and both are `readOnly`, which refuses a
# second one — so a shim for either makes every evaluation fail with
# "set multiple times", naming the option's own default as the rival
# definition. Nothing could have set them anyway: both are derived and
# read-only, so the shim's only possible customer is a reader of the old
# path, and that reader now gets "option does not exist" instead, which
# says the same thing sooner.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "nats" "enable" ]
[ "services" "hyperhive" "deploy" "nats" "enable" ]

View file

@ -428,7 +428,7 @@ in
host, which copies the token out).
Same role for this token as
`services.hyperhive.swarm.authelia.hostClientSecretDir` plays
`services.hyperhive.deploy.authelia.hostClientSecretDir` plays
for the OIDC secret: a **host**-local path (not inside any
container), read directly by `swarm-controller.nix`'s
`LoadCredential` when the controller runs on this same host.
@ -1258,7 +1258,7 @@ in
script = ''
set -euo pipefail
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${forgeSecretPath}"}
# authelia's container is up, but its first-boot generator may

View file

@ -799,7 +799,7 @@ in
script = ''
set -euo pipefail
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"}
# authelia's container is up, but its first-boot generator may

View file

@ -137,6 +137,6 @@ in
# evaluation of a single-host swarm.
config.services.hyperhive.deploy.swarm-controller.queue.clientSecretFile =
lib.mkIf cfg.deploy.singleHostSwarm (
lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret"
lib.mkDefault "${config.services.hyperhive.deploy.authelia.hostClientSecretDir}/swarm-controller.secret"
);
}

View file

@ -234,11 +234,11 @@ in
if
config.services.hyperhive.deploy.authelia.enable && config.services.hyperhive.hiveName != null
then
"${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/"
"${config.services.hyperhive.deploy.authelia.hostClientSecretDir}/"
+ "${config.services.hyperhive.swarm.authelia.hiveClientPrefix}${config.services.hyperhive.hiveName}.secret"
else
null;
defaultText = lib.literalExpression ''"''${swarm.authelia.hostClientSecretDir}/''${swarm.authelia.hiveClientPrefix}''${hiveName}.secret" when this host runs the swarm's IdP, else null'';
defaultText = lib.literalExpression ''"''${deploy.authelia.hostClientSecretDir}/''${swarm.authelia.hiveClientPrefix}''${hiveName}.secret" when this host runs the swarm's IdP, else null'';
example = "/var/lib/secrets/hive-telemetry.secret";
description = ''
Absolute path to this hive's OAuth2 client secret, used to

View file

@ -421,25 +421,6 @@ in
'';
};
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.
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.
'';
};
oidc.hiveIdentities = lib.mkOption {
type = lib.types.bool;
default = deployCfg.nats.enable;
@ -718,43 +699,6 @@ in
'';
};
hostClientSecretDir = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}";
description = ''
Where the minted client secrets sit **as seen from the host** —
`<id>.secret` holds a plaintext, `<id>.digest` the hash authelia
itself reads.
Published for the same reason as `hostUsersFile`: the plaintext's
other reader lives in a **different container**, and containers
that share this host's network namespace still have separate
filesystem roots. The host is the only place both trees are
addressable, so the host is where a delivery step has to run.
⚠️ Nothing here exists until authelia's **first boot** has run.
A consumer must wait for it — it cannot be a `bindMounts` source,
because nixos-container refuses to start when a bind source is
missing, and that turns a fresh hive into a boot-order deadlock.
'';
};
hostUsersFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}";
description = ''
`usersFile` as seen from the **host** — the container's root
prefixed onto the path authelia sees.
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";
@ -804,6 +748,75 @@ in
};
};
# What stays above is what authelia IS to every hive: where it answers
# (`url`), the OIDC register every service checks itself against, its port.
# What the host running the container decides is here, and all three of
# these are the same kind of thing — a filesystem path that only exists on
# the machine that runs `swarm-authelia`. A hive that does not run it has
# nothing at any of them. `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 = {
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.
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.
'';
};
hostClientSecretDir = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}";
description = ''
Where the minted client secrets sit **as seen from the host** —
`<id>.secret` holds a plaintext, `<id>.digest` the hash authelia
itself reads.
Published for the same reason as `hostUsersFile`: the plaintext's
other reader lives in a **different container**, and containers
that share this host's network namespace still have separate
filesystem roots. The host is the only place both trees are
addressable, so the host is where a delivery step has to run.
⚠️ Nothing here exists until authelia's **first boot** has run.
A consumer must wait for it — it cannot be a `bindMounts` source,
because nixos-container refuses to start when a bind source is
missing, and that turns a fresh hive into a boot-order deadlock.
'';
};
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 (hyperhiveCfg.enable && deployCfg.authelia.enable) {
# The derived half of the client list, declared the same way an
# operator declares one. Everything downstream then reads a single
@ -1169,7 +1182,7 @@ in
# it. authelia refuses to start without one, and the
# alternative to an empty file is a placeholder account —
# which is a credential nobody meant to create.
users=${lib.escapeShellArg cfg.usersFile}
users=${lib.escapeShellArg deployCfg.authelia.usersFile}
if [ ! -s "$users" ]; then
echo "users: {}" > "$users"
echo "seeded empty users database at $users"
@ -1178,7 +1191,7 @@ in
'';
};
# The only process allowed to write `cfg.usersFile` — see that
# 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
@ -1217,7 +1230,7 @@ in
# canonical stores for one physical file, which is what
# made `swarm agent create` refuse to start on a hive whose
# `users.yml` already held users.
SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile;
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
@ -1336,7 +1349,7 @@ in
# 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 = cfg.usersFile;
path = deployCfg.authelia.usersFile;
watch = true;
};

View file

@ -29,7 +29,7 @@ let
# The CONFIGURED authelia, not whatever is on PATH: the argon2
# parameters baked into a hash have to match the verifier's.
SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia";
SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile;
SWARMCTL_AUTHELIA_USERS_FILE = deployCfg.authelia.hostUsersFile;
};
forgeCfg = config.services.hyperhive.swarm.forge;

View file

@ -478,7 +478,7 @@ in
script = ''
set -euo pipefail
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.oidc.clientId}.secret"}
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.oidc.clientId}.secret"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/${cfg.machine}${secretPath}"}
# authelia's container is up, but its first-boot generator may still

View file

@ -57,7 +57,7 @@ let
responderConfigured =
deployCfg.nats.autoGenerateCallout
|| (deployCfg.nats.calloutUserSeedFile != "" && deployCfg.nats.calloutIssuerSeedFile != "");
clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret";
clientSecretSource = "${deployCfg.authelia.hostClientSecretDir}/${cfg.clientId}.secret";
introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection";
# Where the responder's seeds actually come from. One name for two

View file

@ -654,7 +654,7 @@ in
script = ''
set -euo pipefail
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"}
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.clientId}.secret"}
dst=${lib.escapeShellArg "/var/lib/nixos-containers/${cfg.machine}${collectorSecretInContainer}"}
# authelia's container is up, but its first-boot generator may

View file

@ -483,8 +483,11 @@ in
clientSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default =
if queueLocal then "${swarmCfg.authelia.hostClientSecretDir}/hive-${cfg.hiveName}.secret" else null;
defaultText = lib.literalExpression ''"''${swarm.authelia.hostClientSecretDir}/hive-''${hiveName}.secret" when this host runs both the queue and the IdP, else null'';
if queueLocal then
"${deployCfg.authelia.hostClientSecretDir}/hive-${cfg.hiveName}.secret"
else
null;
defaultText = lib.literalExpression ''"''${deploy.authelia.hostClientSecretDir}/hive-''${hiveName}.secret" when this host runs both the queue and the IdP, else null'';
example = "/var/lib/secrets/swarm-queue-client.secret";
description = ''
Path to a file holding the plaintext client secret for this
@ -498,7 +501,7 @@ in
this host. On any other hive the secret has to get here somehow,
and the swarm does not distribute it — copy it out of the swarm
host's
{option}`services.hyperhive.swarm.authelia.hostClientSecretDir`
{option}`services.hyperhive.deploy.authelia.hostClientSecretDir`
with whatever secret management this deployment already uses.
'';
};

View file

@ -118,6 +118,24 @@ let
swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
};
# Seventh split slice. Only `usersFile` has a rename entry: the other two
# movers are `readOnly`, and a rename module contributes a definition, which
# a read-only option refuses — see ./host-modules/deploy.nix. So the two arms
# below have different jobs. `usersFile` tests the rename; the nats one tests
# that a reader repointed to the new namespace still renders the derived
# path, which is the failure this slice could actually have shipped — seven
# of those reads went through an alias a path-shaped grep cannot see.
autheliaOldPath = hive {
deploy.authelia.enable = true;
deploy.nats.enable = true;
swarm.authelia.usersFile = "/var/lib/test-authelia/users.yml";
swarm.nats.autoGenerateCallout = false;
swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed";
swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
};
controllerOldPath = hive {
deploy.swarm-controller.enable = true;
swarm.controller.socketPath = "/run/test-ctrl/ctrl.sock";
@ -351,6 +369,20 @@ let
rule: lib.hasInfix "/run/test-grafana-sock" rule
) grafanaOldPath.systemd.tmpfiles.rules;
}
{
name = "a config written against the pre-rename authelia usersFile still reaches the bridge";
ok =
autheliaOldPath.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment.SWARM_AUTHELIA_BRIDGE_USERS_FILE
== "/var/lib/test-authelia/users.yml";
}
{
# Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture
# cannot define it; what can break is a reader left pointing at the
# namespace it moved out of. Five modules read this through an
# `autheliaCfg` alias, where a path-shaped grep does not see it.
name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace";
ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script;
}
{
# The gateway's per-name issuer choice. If this ever collapses to a
# constant, every swarm-service vhost serves a certificate its CA