Watch
0
0
Fork
You've already forked hyperhive
0

swarm-bao: keep the store forwarder's OIDC secret on the host's /run, not in the container's root

swarm-bao-forwarder-oidc still reads the secret from bao under the
forwarder's own leaf, but now writes it to /run/swarm-bao-forwarder-oidc on
the host (tmpfs), which the swarm-bao container bind-mounts read-only for the
collector's LoadCredential. Before, it wrote it to
/var/lib/nixos-containers/swarm-bao/var/lib/swarm-bao-otel-oidc/<id>.secret on
persistent disk, where a superseded secret stayed after rotation and went
into backups of the container root. Host tmpfiles now removes that old file.

Rotation is unchanged: the ten-minute timer rewrites the file when the value
differs and refresh_consumer restarts the forwarder. After a reboot the
secret is fetched again; the file is not pre-created, so until the store
answers the forwarder stays down (LoadCredential refuses a missing source) and
resumes from its journal cursor afterwards. The container now starts after
systemd-tmpfiles-resetup, since the new bind source is made there.

Part of #4998 (slice 3: forwarder).
This commit is contained in:
atlas 2026-10-15 22:36:28 +02:00
commit 6f5bd1dc5d
3 changed files with 80 additions and 29 deletions

View file

@ -112,7 +112,7 @@ The **secret store's own** collector — the forwarder inside the `swarm-bao`
container — needs a delivery step too, and it takes the same route with one container — needs a delivery step too, and it takes the same route with one
principal of its own: `swarm-bao-forwarder-oidc.service` reads principal of its own: `swarm-bao-forwarder-oidc.service` reads
`swarm/services/<client-id>/oidc/client` under a leaf of its own and `swarm/services/<client-id>/oidc/client` under a leaf of its own and
lands it in the container's tree, where `LoadCredential` hands it to the writes it to `/run/swarm-bao-forwarder-oidc` on the host's tmpfs, which the container bind-mounts read-only and where `LoadCredential` hands it to the
collector. The client id is its own collector. The client id is its own
(`services.hyperhive.swarm.bao.otel.clientId`), registered by (`services.hyperhive.swarm.bao.otel.clientId`), registered by
`glue-swarm-bao-otel-oidc-client.nix` wherever authelia runs, and the swarm `glue-swarm-bao-otel-oidc-client.nix` wherever authelia runs, and the swarm
@ -123,11 +123,11 @@ this forwarder borrows neither the swarm collector's client nor
⚠️ **This one has no degraded mode, and the difference is structural.** The ⚠️ **This one has no degraded mode, and the difference is structural.** The
route it exports over accepts an authenticated request and nothing else, so a route it exports over accepts an authenticated request and nothing else, so a
forwarder without the secret has nothing to fall back to: the unit fails and forwarder without the secret has nothing to fall back to: the unit fails and
retries, and the collector refuses to start until the file arrives rather than retries, and the collector refuses to start until the file arrives, after every boot, rather than
exporting into a 401 while reporting healthy. Its ordering differs from the exporting into a 401 while reporting healthy. Its ordering differs from the
unit above it for the same structural reason — it reads the store inside the unit above it for the same structural reason — it reads the store inside the
container it delivers into, so it runs **after** that container rather than container it delivers into, so it runs **after** that container rather than
before it, and only a first boot ever waits. before it. The fetch creates the file, so when the store stays sealed or unreachable after a boot the forwarder stays down, then resumes from its saved journal cursor once the store answers.
### The queue's callout nkeys ### The queue's callout nkeys

View file

@ -1401,12 +1401,17 @@ let
# `swarm-forwarder-oidc` role's grant is this one path, so any other answers # `swarm-forwarder-oidc` role's grant is this one path, so any other answers
# 403 however correct it looks. # 403 however correct it looks.
forwarderStoreSecretPath = "secret/swarm/services/${cfg.otel.clientId}/oidc/client"; forwarderStoreSecretPath = "secret/swarm/services/${cfg.otel.clientId}/oidc/client";
# At rest in the container's tree, written by the host unit below — under # On the host's /run (tmpfs), so the secret never reaches persistent disk:
# /var/lib rather than /run, because a secret that evaporates on reboot # after a reboot `swarm-bao-forwarder-oidc` fetches it again. The container
# turns a working export into an intermittent one. # sees the directory read-only at the same path. The file is NOT created
forwarderSecretInContainer = "/var/lib/swarm-bao-otel-oidc/${cfg.otel.clientId}.secret"; # ahead of that fetch: `LoadCredential` refuses a missing source, so until
forwarderHostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${forwarderSecretInContainer}"; # the store answers the forwarder stays down and resumes from its saved
forwarderHostSecretDir = builtins.dirOf forwarderHostSecretPath; # read cursor. A placeholder file would start it, and its refused pushes
# would be dropped once the exporter's retry window ends.
forwarderSecretDir = "/run/swarm-bao-forwarder-oidc";
forwarderSecret = "${forwarderSecretDir}/${cfg.otel.clientId}.secret";
# Where the secret sat on persistent disk before it moved to /run.
forwarderLegacyHostSecretPath = "/var/lib/nixos-containers/${cfg.machine}/var/lib/swarm-bao-otel-oidc/${cfg.otel.clientId}.secret";
atomicWriteSecret = import ./lib/atomic-write-secret.nix { }; atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
refreshConsumer = import ./lib/refresh-consumer.nix { }; refreshConsumer = import ./lib/refresh-consumer.nix { };
@ -2738,8 +2743,9 @@ in
# THE delivery unit for the forwarder's own OIDC client secret, copied # THE delivery unit for the forwarder's own OIDC client secret, copied
# in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login # in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login
# that fails LOUDLY, since every state it fails on is one a retry fixes, # that fails LOUDLY, since every state it fails on is one a retry fixes,
# then a read, then a root-owned file in the container's tree that # then a read, then a root-owned file on the host's /run that the
# `LoadCredential` re-exposes to the collector's dynamic user. # container bind-mounts read-only and `LoadCredential` re-exposes to the
# collector's dynamic user.
# #
# ⚠️ ORDERED AFTER the store's container and deliberately NOT before it, # ⚠️ ORDERED AFTER the store's container and deliberately NOT before it,
# which is the one line that differs from the unit it copies. That one # which is the one line that differs from the unit it copies. That one
@ -2748,12 +2754,11 @@ in
# process that cannot start until this finishes. There is no bootstrap # process that cannot start until this finishes. There is no bootstrap
# cycle behind it — the identity used here is the static # cycle behind it — the identity used here is the static
# `forwarder-oidc.pem` leaf from `swarm-bao-pki`, not anything the store # `forwarder-oidc.pem` leaf from `swarm-bao-pki`, not anything the store
# issues — only an ordering one, and the cost is bounded: the file is # issues — only an ordering one. The file is on tmpfs, so EVERY boot has
# under /var/lib, so it survives reboots and only a FIRST boot has the # the collector starting before it exists: `LoadCredential` refuses to
# collector starting before it exists. `LoadCredential` refuses to start # start a unit whose source is missing, so the collector restarts, says
# a unit whose source is missing, so that boot is a collector that # so each time, and comes up the moment this lands. Loud and
# restarts, says so each time, and comes up the moment this lands. Loud # self-healing rather than silently exporting without a credential.
# and self-healing rather than silently exporting without a credential.
systemd.services.swarm-bao-forwarder-oidc = { systemd.services.swarm-bao-forwarder-oidc = {
description = "fetch the secret store forwarder's OIDC client secret from the store"; description = "fetch the secret store forwarder's OIDC client secret from the store";
after = [ after = [
@ -2837,18 +2842,25 @@ in
# a program: `printf` is bash's own, so the plaintext never becomes # a program: `printf` is bash's own, so the plaintext never becomes
# an argument in /proc the way `install <<<"$secret"` or an `echo` # an argument in /proc the way `install <<<"$secret"` or an `echo`
# from `path` would. # from `path` would.
install -d -m 0755 ${lib.escapeShellArg forwarderHostSecretDir} install -d -m 0700 ${lib.escapeShellArg forwarderSecretDir}
if secret_differs ${lib.escapeShellArg forwarderHostSecretPath} "$secret"; then if secret_differs ${lib.escapeShellArg forwarderSecret} "$secret"; then
atomic_write_secret 0400 root:root ${lib.escapeShellArg forwarderHostSecretPath} "$secret" atomic_write_secret 0400 root:root ${lib.escapeShellArg forwarderSecret} "$secret"
fi fi
# `LoadCredential` copies the file at start only: a rotated secret # `LoadCredential` copies the file at start only: a rotated secret
# reaches the forwarder by restarting it. # reaches the forwarder by restarting it.
refresh_consumer ${lib.escapeShellArg cfg.machine} opentelemetry-collector.service ${lib.escapeShellArg forwarderHostSecretPath} refresh_consumer ${lib.escapeShellArg cfg.machine} opentelemetry-collector.service ${lib.escapeShellArg forwarderSecret}
''; '';
}; };
systemd.timers.swarm-bao-forwarder-oidc = storeRefreshTimer "re-read the secret store forwarder's OIDC client secret from the store"; systemd.timers.swarm-bao-forwarder-oidc = storeRefreshTimer "re-read the secret store forwarder's OIDC client secret from the store";
systemd.tmpfiles.rules = [
# The container's bind source, so it exists when nspawn sets the mount
# up. Not the file: see `forwarderSecretDir`.
"d ${forwarderSecretDir} 0700 root root -"
"r ${forwarderLegacyHostSecretPath}"
];
}) })
(lib.mkIf (deployCfg.bao.enable && haveServerTls) { (lib.mkIf (deployCfg.bao.enable && haveServerTls) {
@ -3721,7 +3733,15 @@ in
# The CA bind source is written at runtime by a host unit, so the # The CA bind source is written at runtime by a host unit, so the
# container has to start after it — otherwise nspawn sets up a mount # container has to start after it — otherwise nspawn sets up a mount
# over a file that does not exist yet. # over a file that does not exist yet.
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering; #
# The forwarder's secret directory is a bind source too, made by
# tmpfiles; on a switch the container restarts in the same transaction
# as `systemd-tmpfiles-resetup`, so it starts after it. A container that
# cannot start is a store that is down.
systemd.services."container@${cfg.machine}" = lib.mkMerge [
caTrust.containerOrdering
{ after = [ "systemd-tmpfiles-resetup.service" ]; }
];
containers.${cfg.machine} = { containers.${cfg.machine} = {
autoStart = true; autoStart = true;
@ -3734,6 +3754,13 @@ in
# /var, systemd owns `${stateDir}` through `StateDirectory=`, and # /var, systemd owns `${stateDir}` through `StateDirectory=`, and
# binding over it is what breaks the unit. # binding over it is what breaks the unit.
bindMounts = { bindMounts = {
# The forwarder's OIDC secret, written by `swarm-bao-forwarder-oidc`
# on the host's tmpfs; read-only because the container only loads it.
${forwarderSecretDir} = {
hostPath = forwarderSecretDir;
isReadOnly = true;
};
# Read-only: host units write it (`swarm-bao-certs`, and # Read-only: host units write it (`swarm-bao-certs`, and
# `swarm-bao-agent-pki` for the agent CA and the listener bundle), and # `swarm-bao-agent-pki` for the agent CA and the listener bundle), and
# the store has no reason to modify its own identity. # the store has no reason to modify its own identity.
@ -4230,17 +4257,18 @@ in
# How the secret crosses the sandbox. The host unit # How the secret crosses the sandbox. The host unit
# `swarm-bao-forwarder-oidc` leaves a root-owned 0400 file at this # `swarm-bao-forwarder-oidc` leaves a root-owned 0400 file at this
# path in the container's tree; systemd reads it as root before # path on the host's /run, which the container bind-mounts
# the sandbox exists and re-exposes it to whichever uid # read-only; systemd reads it as root before the sandbox exists
# `DynamicUser` picked, under the directory the extension above # and re-exposes it to whichever uid `DynamicUser` picked, under
# names through `${env:CREDENTIALS_DIRECTORY}`. # the directory the extension above names through
# `${env:CREDENTIALS_DIRECTORY}`.
# #
# ⚠️ Unconditional, and a missing source is a unit that refuses to # ⚠️ Unconditional, and a missing source is a unit that refuses to
# start. That is the intended shape rather than an oversight: this # start. That is the intended shape rather than an oversight: this
# forwarder has one way to deliver, so a boot where the secret has # forwarder has one way to deliver, so a boot where the secret has
# not arrived yet is a collector that says so and retries, not one # not arrived yet is a collector that says so and retries, not one
# that quietly exports into a 401. The restart is what closes the # that quietly exports into a 401. The restart is what closes the
# first-boot window the host unit's own comment describes. # post-boot window the host unit's own comment describes.
# #
# ⚠️ `Restart` is deliberately NOT set here, same as the sibling # ⚠️ `Restart` is deliberately NOT set here, same as the sibling
# collector in ./swarm-otel.nix: nixpkgs' own # collector in ./swarm-otel.nix: nixpkgs' own
@ -4254,7 +4282,7 @@ in
# is still delivering the secret, leaving it failed rather than # is still delivering the secret, leaving it failed rather than
# retrying. # retrying.
systemd.services.opentelemetry-collector.serviceConfig = { systemd.services.opentelemetry-collector.serviceConfig = {
LoadCredential = [ "${forwarderCredentialId}:${forwarderSecretInContainer}" ]; LoadCredential = [ "${forwarderCredentialId}:${forwarderSecret}" ];
RestartSec = 15; RestartSec = 15;
}; };
}; };

View file

@ -368,9 +368,32 @@ let
e.client_secret_file == "\${env:CREDENTIALS_DIRECTORY}/oidc-client-secret" e.client_secret_file == "\${env:CREDENTIALS_DIRECTORY}/oidc-client-secret"
&& &&
unit.LoadCredential == [ unit.LoadCredential == [
"oidc-client-secret:/var/lib/swarm-bao-otel-oidc/swarm-bao-collector.secret" "oidc-client-secret:/run/swarm-bao-forwarder-oidc/swarm-bao-collector.secret"
]; ];
} }
{
# A copy on persistent disk outlives the secret's rotation and lands in
# backups of the container's root. The file is deliberately not created
# ahead of the fetch: a placeholder would start the forwarder and have
# its pushes refused instead of leaving it down until the store answers.
name = "the forwarder's client secret is written to the host's /run only, and the old copy removed";
ok =
let
m = baoWithCollector;
script = m.systemd.services.swarm-bao-forwarder-oidc.script;
mount = m.containers.swarm-bao.bindMounts."/run/swarm-bao-forwarder-oidc";
rules = m.systemd.tmpfiles.rules;
in
lib.hasInfix "atomic_write_secret 0400 root:root /run/swarm-bao-forwarder-oidc/swarm-bao-collector.secret" script
&& !(lib.hasInfix "/var/lib/nixos-containers" script)
&& !(lib.hasInfix "/var/lib/swarm-bao-otel-oidc" script)
&& mount.hostPath == "/run/swarm-bao-forwarder-oidc"
&& mount.isReadOnly
&& lib.elem "d /run/swarm-bao-forwarder-oidc 0700 root root -" rules
&& lib.elem "r /var/lib/nixos-containers/swarm-bao/var/lib/swarm-bao-otel-oidc/swarm-bao-collector.secret" rules
&& !(lib.any (r: lib.hasPrefix "f /run/swarm-bao-forwarder-oidc" r) rules)
&& lib.elem "systemd-tmpfiles-resetup.service" m.systemd.services."container@swarm-bao".after;
}
{ {
# Same 403-not-a-miss property the grafana and matrix readers are pinned # Same 403-not-a-miss property the grafana and matrix readers are pinned
# for: the reader's grant is one path under `services`, so a secret # for: the reader's grant is one path under `services`, so a secret