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:
parent
eef7b70c0e
commit
ba56bfe32e
3 changed files with 476 additions and 453 deletions
|
|
@ -40,6 +40,7 @@
|
|||
./glue-services-issuer-bao-identity.nix
|
||||
./glue-swarm-bao-otel-oidc-client.nix
|
||||
./glue-swarm-otel-oidc-client.nix
|
||||
./swarm-authelia-service.nix
|
||||
./swarm-authelia.nix
|
||||
./swarm-bao-service.nix
|
||||
./swarm-bao.nix
|
||||
|
|
|
|||
464
nix/host-modules/swarm-authelia-service.nix
Normal file
464
nix/host-modules/swarm-authelia-service.nix
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# 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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue