feat(3149): deliver the client secret between the two containers
The all-local case from the delivery ruling: when one host runs both the forge and the swarm's authelia, nothing should need an operator. Two containers, one secret, and the awkward part is that they share this host's network namespace but not its filesystem. They reach each other on 127.0.0.1, which makes them feel co-located — the forge still cannot open a path inside authelia's tree. The host is the only place both are addressable, so the copy runs there, and `hostClientSecretDir` publishes the outside view of the inside path exactly as `hostUsersFile` already does for the users database. Deliberately a copy rather than a `bindMounts` entry. nixos-container refuses to start when a bind source is missing, and this secret does not exist until authelia's first boot has minted it — binding it would make the forge wait on a file that waits on a container that starts after it. On a fresh hive that is a permanent stall presenting as "the forge is broken", several layers from its cause. The owning uid is discovered from the forge container's own state dir rather than assumed. Whatever uid maps to forgejo inside that container already owns the directory it was created with; writing a number here would be a second place for it to be wrong. The client entry is contributed to authelia's list by the forge module itself, from the same source-name constant the registration uses, so the redirect URI authelia allows and the one forgejo sends cannot drift. A mismatch there is a rejected login with no error text worth reading.
This commit is contained in:
parent
daa8a2eb4b
commit
cdb3c612b2
2 changed files with 114 additions and 1 deletions
|
|
@ -33,9 +33,28 @@ let
|
|||
# null when no SSO provider is configured anywhere, which the
|
||||
# assertion below turns into an eval failure rather than a discovery
|
||||
# request to `null/.well-known/...`.
|
||||
autheliaUrl = config.services.hyperhive.swarm.authelia.url;
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
autheliaUrl = autheliaCfg.url;
|
||||
autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration";
|
||||
|
||||
# The all-local case: this host runs BOTH the forge and the swarm's
|
||||
# authelia, so the secret can be moved without an operator. The other
|
||||
# two cases (swarm side via swarmctl, remote hive) leave
|
||||
# `clientSecretFile` to be set explicitly — see docs/swarm/.
|
||||
ssoLocal = cfg.sso.enable && autheliaCfg.enable;
|
||||
|
||||
# Where the plaintext lands inside the forge container. Under
|
||||
# /var/lib rather than /run: the forge may start before the delivery
|
||||
# unit on a later boot, and a secret that evaporates on reboot turns a
|
||||
# working login into an intermittent one.
|
||||
forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret";
|
||||
|
||||
# Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName`
|
||||
# the registration uses, so the redirect URI authelia is told to allow
|
||||
# and the one forgejo will actually send cannot drift apart — a
|
||||
# mismatch there is a rejected login with no error text worth reading.
|
||||
ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback";
|
||||
|
||||
caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||||
useSelfSigned = caTrust.useSelfSigned;
|
||||
caContainerPath = caTrust.caContainerPath;
|
||||
|
|
@ -814,6 +833,78 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# One declaration, two readers. The forge knows its own callback URL;
|
||||
# making the operator restate it in authelia's client list would be a
|
||||
# second source of truth for a string whose mismatch is a silent
|
||||
# rejected login.
|
||||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [
|
||||
{
|
||||
id = cfg.sso.clientId;
|
||||
description = "HyperHive forge";
|
||||
redirectUris = [ ssoRedirectUri ];
|
||||
}
|
||||
];
|
||||
|
||||
# Same case, same reasoning: this host minted the secret, so it can
|
||||
# say where the forge will find it.
|
||||
services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal (
|
||||
lib.mkDefault forgeSecretPath
|
||||
);
|
||||
|
||||
# The delivery. It runs on the HOST because that is the only place
|
||||
# both container trees are addressable: they share this host's
|
||||
# network namespace, which makes them feel co-located, but their
|
||||
# filesystem roots are separate — the forge cannot open a path inside
|
||||
# authelia's tree however local the port looks.
|
||||
#
|
||||
# ⚠️ Deliberately a copy and not a `bindMounts` entry.
|
||||
# nixos-container refuses to start when a bind source is missing, and
|
||||
# this secret does not exist until authelia's first boot has minted
|
||||
# it — so binding it would make the forge wait on a file that waits
|
||||
# on a container that starts after it. On a fresh hive that is a
|
||||
# permanent stall presenting as "the forge is broken", several layers
|
||||
# from its cause.
|
||||
systemd.services.hive-forge-oidc-secret = lib.mkIf ssoLocal {
|
||||
description = "deliver the forge's OIDC client secret from authelia";
|
||||
after = [ "container@${autheliaCfg.machine}.service" ];
|
||||
requires = [ "container@${autheliaCfg.machine}.service" ];
|
||||
before = [ "container@hive-forge.service" ];
|
||||
wantedBy = [ "container@hive-forge.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
SyslogIdentifier = "hive-forge-oidc-secret";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
src=${lib.escapeShellArg "${autheliaCfg.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
|
||||
# still be minting. Bounded wait, then fail: a silent skip here
|
||||
# produces a forge whose SSO button 401s, which is the failure
|
||||
# this whole design is trying not to ship.
|
||||
for _ in $(seq 1 60); do
|
||||
[ -s "$src" ] && break
|
||||
sleep 2
|
||||
done
|
||||
if [ ! -s "$src" ]; then
|
||||
echo "authelia has not minted $src after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The owning uid is DISCOVERED, not assumed: read it off the
|
||||
# forge container's own state dir. Whatever uid maps to forgejo
|
||||
# inside that container is by definition the one that owns the
|
||||
# directory it was created with, and hardcoding a number here
|
||||
# would be a second place for it to be wrong.
|
||||
uid=$(stat -c %u /var/lib/nixos-containers/hive-forge/var/lib/forgejo)
|
||||
install -D -m 0400 -o "$uid" -g "$uid" "$src" "$dst"
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||
allowedTCPPorts = [
|
||||
cfg.httpPort
|
||||
|
|
|
|||
|
|
@ -351,6 +351,28 @@ 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue