diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 9cadbf85..518a63a0 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -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 diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index fd047d03..c1097d96 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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** — + `.secret` holds a plaintext, `.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;