`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
464 lines
20 KiB
Nix
464 lines
20 KiB
Nix
# The swarm's SSO provider as every hive sees it: where it answers, the OIDC
|
||
# client register every service checks itself against, and the names published
|
||
# for consumers acting on it from outside, identical on every host. What the
|
||
# host running it decides, and the container itself, are in ./swarm-authelia.nix.
|
||
{
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.authelia;
|
||
swarmDomain = config.services.hyperhive.swarm.domain;
|
||
deployCfg = config.services.hyperhive.deploy;
|
||
|
||
# The unit name upstream's `services.authelia.instances.<name>` derives,
|
||
# which `unit` publishes. ./swarm-authelia.nix binds the same two names for
|
||
# the instance it declares, and the two must agree.
|
||
instance = "swarm";
|
||
unitName = "authelia-${instance}";
|
||
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, in ./swarm-authelia.nix.
|
||
# 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.
|
||
'';
|
||
};
|
||
};
|
||
}
|