Watch
0
0
Fork
You've already forked hyperhive
0

nix: split swarm-authelia into service and deploy-mode files

`swarm.authelia` (what the SSO provider is to every hive: ports, domain,
`url`, the OIDC client register, the published names and the bridge's
address) moves to nix/host-modules/swarm-authelia-service.nix. Everything
else -- the `deploy.authelia` options, the whole `config` block including
`containers.swarm-authelia`, and the helpers only they read -- stays in
nix/host-modules/swarm-authelia.nix, which default.nix now imports
alongside the new file.

Both halves read four `let` bindings. `cfg`, `swarmDomain` and `deployCfg`
are option reads, so each file binds them from `config`; the service file
has no `hyperhiveCfg`, so it spells the paths out, as
swarm-nats-service.nix does. `instance` and `unitName` are literals, not
options, so the service file carries its own copy of the two (`unit`'s
default reads `unitName`). `deployCfg` is in the service file only for
`bridgeUrl`'s default, which is moved as it is.

`hyperhiveDomain` had no reader and is dropped rather than carried into
either file.

A pure move: option paths, option definitions and config are unchanged
apart from three comments that pointed "above"/"below" across the new
file boundary and now name the file. Authelia's container toplevel, the
host toplevel (with `c0re.hyperhiveFlake` pinned, since the flake source
path lands in /etc/hyperhive/serve.json), the `swarm.authelia` and
`deploy.authelia` values and option set, and the eleven
module-eval checks that enable authelia evaluate to the same derivations
before and after.

Refs #3742
This commit is contained in:
atlas 2026-10-01 10:09:03 +02:00
commit ba56bfe32e
3 changed files with 476 additions and 453 deletions

View file

@ -32,7 +32,6 @@ let
networkCfg = config.services.hyperhive.network;
hyperhiveCfg = config.services.hyperhive;
gatewayCfg = hyperhiveCfg.gateway;
hyperhiveDomain = hyperhiveCfg.domain;
swarmDomain = hyperhiveCfg.swarm.domain;
deployCfg = hyperhiveCfg.deploy;
@ -114,13 +113,14 @@ let
# 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` below 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.
# 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
# below are: the required-domain assertion in hive-network.nix should
# be what an operator sees, not a coercion error from here.
# 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
@ -390,452 +390,10 @@ let
privateNetwork = false;
in
{
# `enable` and both packages moved to `services.hyperhive.deploy.authelia`
# — see ./deploy.nix. Whether this host runs the swarm's SSO provider, and
# which build it runs, are deployment decisions; what stays here is what
# authelia IS, including `url` and the OIDC client registry every hive needs
# as a *client* whether or not it runs the container.
options.services.hyperhive.swarm.authelia = {
port = lib.mkOption {
type = lib.types.port;
default = 9091;
description = ''
TCP port authelia listens on. 9091 is upstream's default and
sits outside hyperhive's claimed ranges (dashboard 7000, forge
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
'';
};
metricsPort = lib.mkOption {
type = lib.types.port;
default = 9959;
description = ''
TCP port authelia serves its Prometheus metrics on, bound to
loopback. Upstream's default, kept so an operator reading
authelia's documentation finds what they expect.
A separate port from {option}`port` because it is a separate
listener with a different audience: the main one is proxied by
the gateway and reachable from the swarm, this one is scraped by
the collector on this host and by nothing else.
⚠️ Every swarm container shares the host network namespace, so
two services defaulting to the same port do not conflict at build
time — one simply loses at runtime, with nothing in any log. Check
a new value against the others before changing this.
::: {.note}
Loopback means this endpoint is only reachable by a collector on
the *same host*, so the scrape target is declared only when one is
enabled here. Run the swarm's collector elsewhere and authelia's
metrics are simply not collected — no error, and nothing in a log
to say so. Making them reachable across hosts is a different piece
of work: the endpoint would have to be published under a name,
with a certificate and an audience.
:::
'';
};
domain = lib.mkOption {
type = lib.types.str;
# Under the SWARM domain, like the forge and matrix: a swarm has one
# SSO provider, and the session cookie has to reach the swarm's
# services.
#
# Total on a null swarm domain so the required-domain assertion in
# hive-network.nix is the thing that fires; see the comment there.
default = if swarmDomain == null then "auth.invalid" else "auth.${swarmDomain}";
defaultText = lib.literalExpression ''"auth.''${services.hyperhive.swarm.domain}"'';
example = "login.example.com";
description = ''
Public hostname for the SSO provider — the sub-domain shape the
forge and matrix already use, under the swarm's domain because a
swarm has **one** SSO provider. Must be the name browsers
actually visit: it is the `authelia_url` the session cookie is
validated against.
⚠️ Unlike the forge and matrix names, this one carries **no
migration pin**: nothing depends on the previous
`auth.''${services.hyperhive.domain}` yet, so it moves outright.
'';
};
url = lib.mkOption {
type = lib.types.str;
default = "https://${cfg.domain}";
defaultText = lib.literalExpression ''"https://''${domain}"'';
example = "https://auth.example.com";
description = ''
Base URL clients are sent to for authentication — the half of
this module that exists on **every** hive, not just the one
running the container.
Names {option}`domain`, and does **not** ask whether this host
runs the container: a swarm has one SSO provider, so every hive
addresses the same name and resolution decides where it is —
dnsmasq locally on the host serving the vhost, the real network
anywhere else. There is no loopback-vs-remote branch to get
wrong, the same way {option}`services.hyperhive.swarm.otel.domain`
has none.
Not having SSO is not a supported deployment: every swarm has an
IdP, so this is never `null`.
'';
};
oidc.hiveIdentities = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Mint machine clients per hive in
{option}`services.hyperhive.swarm.hives`, so each hive can
authenticate to swarm services as itself: `hive-<name>` for the
hive's own daemons, and `hive-<name>-agent` for the agent containers
running on it.
Two clients rather than one because they are not the same
principal — a hive's daemons run on the host and an agent runs
in a container the host hands a credential to, so a swarm
service has to be able to grant them different things. It is
one client per *hive* on the agent side, not per agent: agents
are created at runtime, and a per-agent client would make
creating one a config change plus an authelia reload. The cost
is that agents on a hive are indistinguishable from each other,
tracked as a follow-up rather than papered over.
On by default: a swarm's hives have identities, and that is a
fact about the swarm rather than about any one host. It used to
default to whether the queue ran on *this* machine, which made
the answer differ between two hosts of one swarm — set it false
for a swarm whose hives authenticate to nothing.
The clients are inert until something authenticates with them:
each is a client id and a secret sitting on this host. What
delivers a secret to a hive that is not this host is a separate
problem and deliberately not solved here.
'';
};
oidc.clients = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.str;
example = "forgejo";
description = ''
OAuth2 client id, as the relying party knows itself.
'';
};
description = lib.mkOption {
type = lib.types.str;
example = "HyperHive forge";
description = ''
Human-readable name, shown on authelia's consent screen.
This is the string a person reads when deciding whether
to hand an application their identity, so it should name
the application rather than the protocol.
'';
};
kind = lib.mkOption {
type = lib.types.enum [
"interactive"
"machine"
];
default = "interactive";
example = "machine";
description = ''
Whether a human logs in through this client, or a daemon
authenticates as itself.
`interactive` is the authorization-code flow: a browser is
redirected, a person authenticates, the client receives an
id-token. `machine` is `client_credentials`: there is
nobody to redirect and no identity to assert but the
client's own, so it receives an access token and no
id-token.
This is declared rather than inferred from an empty
`redirectUris`, because authelia permits only the grants a
client names — omitting `grant_types` yields
authorization-code alone, and a daemon then fails at the
token endpoint with `unauthorized_client` rather than at
evaluation.
'';
};
redirectUris = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "https://forge.example.com/user/oauth2/authelia/callback" ];
description = ''
Exact callback URLs the provider will redirect to.
Matched literally by authelia — a trailing-slash
difference is a rejected login, not a warning.
Meaningless for `kind = "machine"`, which is asserted
rather than silently ignored.
'';
};
tokenEndpointAuthMethod = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"client_secret_basic"
"client_secret_post"
"client_secret_jwt"
"private_key_jwt"
"none"
]
);
default = null;
example = "client_secret_post";
description = ''
How this client proves its identity at the token
endpoint. `null` leaves authelia on its own default
(`client_secret_basic`), which is what every client that
does not say otherwise gets.
Set it when the relying party's implementation differs,
because authelia enforces the registered method rather
than accepting whatever arrives. tuwunel sends
`client_secret_post`, and against a client registered for
basic the result is a 401 from `/api/oidc/token` **after
a successful consent** — the login looks like it worked
right up to the last hop, and neither the redirect nor
the secret is at fault.
⚠️ `null` is NOT accepted on a client with `bearerAuthz`.
Measured against authelia 4.39.20: under that scope the
method must be *stated*: omitting it is refused with
`must be configured as 'client_secret_basic', … but it's
configured as` an empty string. The sentence above is
true of an ordinary client and false of that one, which
is exactly how a reviewer reads this option and concludes
the assertion below is wrong.
'';
};
audience = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "hive-alpha" ];
description = ''
Audiences (`aud`) this client is permitted to request a
token for. Empty means it asks for none, which is the
right answer for a client whose resource server does not
distinguish callers.
⚠️ Registering an audience only *permits* it — the value
lands in a token when the client **asks** for it at the
token endpoint, and a client that does not send
`audience=` receives a token with `aud: []` however
complete this list looks. Measured against authelia
4.39.20: the config reads exactly right and the resource
server rejects every token, because a config that grants
and a request that claims are two separate acts.
Requesting an audience that is *not* listed here is
refused with `invalid_target`, which is what makes this
usable as a boundary rather than a label: a client cannot
mint a token for a resource slot that is not its own.
'';
};
bearerAuthz = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Grant this client the `authelia.bearer.authz` scope, so it
may present its access token to authelia's authz endpoint
and be authorised by an `access_control` rule — how a
scraper reaches a service published behind the gateway.
A named capability rather than a free-form `scopes` list,
for the same reason `kind` derives the rest: authelia
refuses some scope/grant combinations outright (`openid`
with `client_credentials` among them), and a list would
make those combinations expressible again. This admits the
one value that is legal here and nothing else.
::: {.note}
Setting this obliges two other options, and the assertions
below enforce it. Authelia checks the same thing, but only
in its `preStart` validator — which means a violation
builds and deploys cleanly and then fails to restart,
taking swarm SSO down. The assertions move that to
evaluation, where a wrong value costs nothing.
:::
'';
};
accessTokenSignedResponseAlg = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"none"
"RS256"
]
);
default = null;
example = "RS256";
description = ''
Signing algorithm for this client's **access** tokens.
`null` leaves authelia on its default, which issues an
opaque token (`authelia_at_…`) — a database handle that
carries no claims and means nothing to anyone but this
provider.
Set `RS256` when the resource server verifies the token
*itself* rather than asking this provider about it: that
yields an RFC 9068 JWT (`at+jwt`) carrying `aud`, `iss`
and `client_id`, verifiable against `/jwks.json` with no
round trip.
⚠️ This is what makes a token readable by an
OIDC-verifying consumer at all. A resource server given
an opaque token is not *misconfigured* — it is
structurally unable to verify it, and says so in terms
that point at the verifier rather than at the token's
format.
'';
};
};
}
);
default = [ ];
description = ''
OIDC relying parties this provider will issue tokens to.
Declaring one turns the provider on; the default empty list
leaves this module exactly as it was — a session provider and
nothing else.
⚠️ **There is deliberately no secret here.** A client secret has
two holders in two containers (authelia keeps a *hash*, the
relying party the *plaintext*), and
`services.authelia.instances.<n>.settings` is rendered into the
**nix store**, which is world-readable and permanent. So this
option carries only the parts that are safe to evaluate: the
secret is minted on first boot and never passes through a nix
expression. See `docs/swarm/` for what goes where.
'';
};
# Derived facts, exposed for consumers that have to act on this
# container **from outside it** — `swarmctl` is the first, and it
# needs all three. Read-only options rather than literals repeated at
# the call site: the machine and unit names are derived from
# `instance` here, so a second copy elsewhere is a second thing to
# keep in step, and the one that drifts is the one nobody tests.
hiveClientPrefix = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "hive-";
description = ''
Prefix of the OAuth2 client id minted for each hive in
`services.hyperhive.swarm.hives` — the client for hive `alpha` is
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha`.
Read-only for the same reason as `machine` and `unit`: it is what
this module produces, published so a consumer does not carry a
second copy.
The consumer that matters is the queue's auth-callout responder,
which decides *which hive* a connection is by stripping this
prefix off the introspected client id. Split the two spellings and
every hive is denied — as a timeout, indistinguishable from a hive
that simply has not reported.
'';
};
agentClientSuffix = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "-agent";
description = ''
Suffix appended to a hive's own client id to name the client its
*agent containers* present — agents on hive `alpha` all present
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha${config.services.hyperhive.swarm.authelia.agentClientSuffix}`.
Read-only for the same reason as `hiveClientPrefix`, and read by the
same consumer with the same failure mode: a split spelling denies
every agent as a timeout.
A suffix on the hive's id rather than a prefix of its own, because
`agent-alpha` reads as *the agent named alpha* — which is precisely
what this identity does not say.
One id per hive rather than per agent, because agents are created at
runtime and a per-agent client would make creating one a config
change plus a reload. The consequence is that this identity says
*which hive* an agent belongs to and never *which agent* — the
broker cannot tell two agents on one hive apart.
'';
};
machine = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-authelia";
description = ''
Name of the nixos-container authelia runs in. Read-only: it is
what this module declares, published so callers of
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
have to hardcode it.
'';
};
unit = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "${unitName}.service";
description = ''
authelia's systemd unit *inside* the container. Read-only, and
derived from the instance name exactly like the unit itself.
'';
};
bridgePort = lib.mkOption {
type = lib.types.port;
default = 9092;
description = ''
TCP port `swarm-authelia-bridge` listens on, loopback-bound
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
`port` (9091), outside hyperhive's other claimed ranges.
Reachable directly from this host's other processes (this
container shares the host netns, same as authelia's own `port`)
without going through the gateway — this is an internal
service-to-service endpoint, not something meant to be exposed
publicly.
'';
};
bridgeUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
readOnly = true;
default = if deployCfg.authelia.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
description = ''
Where `swarm-authelia-bridge` answers, **as seen from this
host** — correct only when a caller (`swarm-controller`) also
runs on this host, the same co-location assumption
`swarm.nix`'s `clientSecretFile` documents for its own
cross-host case. `null` when this host doesn't run
`swarm-authelia` at all.
A split-host swarm has no automated delivery for this address:
the operator points `swarm-controller`'s own option at wherever
this host has made the bridge reachable (a firewall rule, a
different bind address), the same manual-copy shape used
throughout this codebase's other cross-host cases.
'';
};
};
# 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 — which two builds it
# What authelia IS to every hive, where it answers (`url`), the OIDC
# register every service checks itself against and its port, is
# `swarm.authelia` in ./swarm-authelia-service.nix. What the host running
# the container decides is here — which two builds it
# runs, and three 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