1451 lines
69 KiB
Nix
1451 lines
69 KiB
Nix
# The swarm's SSO provider: one authelia for the whole swarm, in a
|
|
# `swarm-authelia` nixos-container on the host with `deploy.authelia.enable`
|
|
# (`deploy.allSwarmServices` defaults it on, see
|
|
# ./swarm-required-services.nix). What every hive sees of it, `url` and the
|
|
# OIDC client register included, is in ./swarm-authelia-service.nix.
|
|
#
|
|
# The file backend is the right shape, not a placeholder for LDAP: what
|
|
# makes a directory necessary is the size of the subject set, and this
|
|
# one is bounded by a swarm. Who writes that store, and why it is a
|
|
# program rather than a config file, is in `docs/swarm/sso.md`.
|
|
#
|
|
# `docs/swarm/sso.md` owns the role model — session provider always, OIDC
|
|
# provider derived from the client list — and the secrets map.
|
|
#
|
|
# Per-service integration — the `auth_request` in front of a vhost —
|
|
# lives in the module declaring that vhost. What lives here is the
|
|
# `access_control` rule each of them relies on.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.swarm.authelia;
|
|
networkCfg = config.services.hyperhive.network;
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
gatewayCfg = hyperhiveCfg.gateway;
|
|
swarmDomain = hyperhiveCfg.swarm.domain;
|
|
deployCfg = hyperhiveCfg.deploy;
|
|
|
|
# Group an account must hold to reach operator-only surfaces. Named
|
|
# here because this module writes the rule that enforces it and
|
|
# `swarmctl user add --group <this>` is what grants it — the two must
|
|
# agree, and one constant is how they stay agreeing.
|
|
#
|
|
# ⚠️ `admins` and not a new word, because `docs/getting-started/setup.md` and
|
|
# `docs/swarm/sso.md` have been telling every operator to create
|
|
# exactly that group since the bootstrap step existed. This is the
|
|
# first rule that CONSUMES a group name; picking a different one would
|
|
# have meant every account created by following the guide silently
|
|
# failing the check it was supposed to pass.
|
|
operatorGroup = "admins";
|
|
|
|
# `target`, for `operatorGroup` and nobody else. The `deny` is what makes
|
|
# the group rule exclusive, for the reason `collectorOnly` gives: a subject
|
|
# outside the group skips the first rule and would otherwise land on
|
|
# `default_policy`.
|
|
operatorsOnly = target: [
|
|
(
|
|
target
|
|
// {
|
|
policy = "one_factor";
|
|
subject = [ "group:${operatorGroup}" ];
|
|
}
|
|
)
|
|
(target // { policy = "deny"; })
|
|
];
|
|
|
|
# Whether the swarm collector's client is actually registered here.
|
|
#
|
|
# Asked of the client list rather than re-derived from the conditions
|
|
# that produce it (a non-empty audience set, authelia
|
|
# being co-located). A second copy of that predicate is a second thing
|
|
# to keep in step, and the two drifting is not a build failure: a rule
|
|
# naming an unregistered client is refused by authelia's *startup*
|
|
# validator, so the whole SSO service fails to restart.
|
|
#
|
|
# Reading the registration itself also makes the rule correct for a
|
|
# registrar this module has never heard of — an operator registering
|
|
# the collector by hand against a provider that is not co-located with
|
|
# it gets the same rule, from the same expression.
|
|
# ⚠️ `swarm.otel` named in full, not through a let binding: there are two
|
|
# otel options one word apart, and only this one is the swarm's collector.
|
|
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
|
|
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
|
|
|
|
# `target`, for the collector's client and nobody else.
|
|
#
|
|
# The `deny` is the protection, and it has to be a rule of its own. A rule
|
|
# with a `subject` does not match a subject outside it: authelia skips it and
|
|
# moves on, and with nothing after it lands on `default_policy`, which is
|
|
# `one_factor` — *any* authenticated subject, every operator today, every
|
|
# agent once they hold authelia accounts. Nor does the audience stand
|
|
# between a browser session and this data: `authn_strategies` on the authz
|
|
# endpoint also accepts `CookieSession`, and a cookie carries no audience.
|
|
# Until a collector is registered, the `deny` is the only rule.
|
|
collectorOnly =
|
|
target:
|
|
lib.optional collectorRegistered (
|
|
target
|
|
// {
|
|
policy = "one_factor";
|
|
subject = [ "oauth2:client:${collectorClientId}" ];
|
|
}
|
|
)
|
|
++ [ (target // { policy = "deny"; }) ];
|
|
|
|
# The metrics store's name, every path. Authelia is consulted there for the
|
|
# collector's push route and `/metrics`, and for an operator's session on
|
|
# the read API and vmui; Grafana's bearer on the read API is checked by
|
|
# `swarm-metrics-auth`, never here.
|
|
#
|
|
# The collector's client is admitted anywhere on the name. Every other
|
|
# subject is refused the collector's two paths, then operators are admitted
|
|
# to the rest, then everything else is refused. Prefixes rather than exact
|
|
# paths for the collector's two, so no spelling of them reaches the
|
|
# operator rule. No rule takes a narrower `resources` than that, so no path
|
|
# or query form falls through to `default_policy`.
|
|
metricsStoreDomain = config.services.hyperhive.swarm.victoriametrics.domain;
|
|
metricsStoreRules =
|
|
lib.optional collectorRegistered {
|
|
domain = metricsStoreDomain;
|
|
policy = "one_factor";
|
|
subject = [ "oauth2:client:${collectorClientId}" ];
|
|
}
|
|
++ [
|
|
{
|
|
domain = metricsStoreDomain;
|
|
policy = "deny";
|
|
resources = [
|
|
"^/opentelemetry/.*"
|
|
"^/metrics.*"
|
|
];
|
|
}
|
|
{
|
|
domain = metricsStoreDomain;
|
|
policy = "one_factor";
|
|
subject = [ "group:${operatorGroup}" ];
|
|
}
|
|
{
|
|
domain = metricsStoreDomain;
|
|
policy = "deny";
|
|
}
|
|
];
|
|
|
|
# The log store's ingest routes: the collector's client, nobody else. A
|
|
# prefix rather than the one route the vhost serves, so no spelling of it,
|
|
# and no route added under `/insert/`, reaches `default_policy`. The rest of
|
|
# the name (read API, vmui) stays open to any authenticated caller; its
|
|
# `/metrics` is covered by `metricsRules`.
|
|
logsStoreRules = collectorOnly {
|
|
domain = config.services.hyperhive.swarm.victorialogs.domain;
|
|
resources = [ "^/insert/.*" ];
|
|
};
|
|
|
|
# One pair per `swarm.otel.publishedScrapeTargets` entry, read off the same
|
|
# URL the collector requests and its token is minted for, so a target
|
|
# cannot be published without the rules that confine it to the collector.
|
|
# An entry on the metrics store's name is covered by `metricsStoreRules`.
|
|
metricsRules = lib.filter (r: r.domain != metricsStoreDomain) (
|
|
lib.concatLists (
|
|
lib.mapAttrsToList (
|
|
job: url:
|
|
let
|
|
# The split `parsePublished` in ./swarm-otel.nix makes for the scrape
|
|
# job; the two must agree on what the URL's host and path are.
|
|
parts = builtins.match "https://([^/]+)(/.*)" url;
|
|
in
|
|
if parts == null then
|
|
throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived."
|
|
else
|
|
collectorOnly {
|
|
domain = builtins.elemAt parts 0;
|
|
# ⚠️ Authelia matches `resources` against the cleaned path PLUS the
|
|
# query string, while nginx's `= <path>` location ignores the query.
|
|
# A bare `^<path>$` therefore misses `<path>?x`, which the location
|
|
# still serves, and that request falls through to `default_policy`.
|
|
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ];
|
|
}
|
|
) config.services.hyperhive.swarm.otel.publishedScrapeTargets
|
|
)
|
|
);
|
|
|
|
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
|
# user, group and StateDirectory from the instance name
|
|
# (`authelia` + `-<name>`). Both are read back from `swarm.authelia.unit`,
|
|
# so the instance declared below is the one that option names.
|
|
unitName = lib.removeSuffix ".service" cfg.unit;
|
|
instance = lib.removePrefix "authelia-" unitName;
|
|
stateDir = "/var/lib/${unitName}";
|
|
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
|
|
|
|
caTrust = import ./lib/hive-ca-trust.nix {
|
|
inherit lib gatewayCfg;
|
|
tlsCfg = deployCfg.hive-controller.tls;
|
|
};
|
|
# `swarm-authelia-bridge` verifies the gateway when it introspects by name.
|
|
# The store's `pki` root is a runtime file no build-time option can name, so
|
|
# without this bundle an https call out of here fails `UnknownIssuer`.
|
|
caBundleModule = caTrust.trustBundle {
|
|
inherit pkgs;
|
|
name = cfg.machine;
|
|
consumers = [
|
|
"swarm-authelia-bridge"
|
|
]
|
|
# Go, for the swarm collector's name and authelia's own. Only where the
|
|
# forwarder runs: naming it would otherwise define an empty unit.
|
|
++ lib.optional publishes "opentelemetry-collector";
|
|
};
|
|
|
|
# The container's journal forwarder pushes to its own receiver on the swarm
|
|
# collector, the route `serviceProducers.swarm-authelia` in
|
|
# ./swarm-otel-service.nix declares, as a client of its own. The port suffix
|
|
# is elided on 443, as in ./swarm-bao.nix.
|
|
forwarderClientId = cfg.otel.clientId;
|
|
gatewayPortSuffix = lib.optionalString (
|
|
gatewayCfg.httpsPort != 443
|
|
) ":${toString gatewayCfg.httpsPort}";
|
|
forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}${gatewayPortSuffix}/${cfg.machine}";
|
|
# Loopback port of the forwarder's own metrics, inside the container's netns:
|
|
# no other collector shares it. The module requires a value.
|
|
forwarderTelemetryPort = 8901;
|
|
# A client this container registers and mints for itself. Its secret goes
|
|
# through the store like every other forwarder's: the publisher carries it
|
|
# there and the forwarder reads it back under the same identity.
|
|
forwarderClient = {
|
|
id = forwarderClientId;
|
|
description = "HyperHive authelia journal forwarder";
|
|
kind = "machine";
|
|
redirectUris = [ ];
|
|
# Its own id as its own permitted audience, the one the swarm collector's
|
|
# receiver for this container checks.
|
|
audience = [ forwarderClientId ];
|
|
# The receiver verifies the token offline against `/jwks.json`;
|
|
# authelia's default opaque handle fails there.
|
|
accessTokenSignedResponseAlg = "RS256";
|
|
};
|
|
|
|
# The secret publisher runs in this container, beside the mint whose secrets
|
|
# it copies. It needs a store identity, and a reader is defined by holding a
|
|
# certificate the store accepts, never by standing next to it — the rule
|
|
# ./glue-queue-agent-credential.nix states in full. `deploy.bao.enable` here
|
|
# would be the co-location assumption itself.
|
|
publisherCfg = deployCfg.swarm-secret-publisher;
|
|
publishes =
|
|
publisherCfg.enable
|
|
&& publisherCfg.baoClientCertFile != null
|
|
&& publisherCfg.baoClientKeyFile != null;
|
|
publisherUnit = "swarm-secret-publish";
|
|
forwarderFetchUnit = "swarm-journal-forwarder-oidc";
|
|
# The publisher's store identity, handed into the container as system
|
|
# credentials (credential id → host source). The forwarder's fetch presents
|
|
# it too: one identity for the whole container.
|
|
storeFiles = lib.optionalAttrs publishes (
|
|
lib.filterAttrs (_: source: source != null) {
|
|
publisher-bao-cert = publisherCfg.baoClientCertFile;
|
|
publisher-bao-key = publisherCfg.baoClientKeyFile;
|
|
publisher-bao-server-ca = deployCfg.bao.serverCaFile;
|
|
}
|
|
);
|
|
# nixos-container expands `extraFlags` unquoted, so a source path must be
|
|
# one word.
|
|
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
|
|
# The store options both units take, with the credentials as each loads them.
|
|
storeIdentityIn =
|
|
unit:
|
|
let
|
|
credential = id: "/run/credentials/${unit}.service/${id}";
|
|
in
|
|
{
|
|
package = deployCfg.bao.package;
|
|
addr = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}";
|
|
clientCertFile = credential "publisher-bao-cert";
|
|
clientKeyFile = credential "publisher-bao-key";
|
|
caFile =
|
|
if storeFiles ? publisher-bao-server-ca then credential "publisher-bao-server-ca" else null;
|
|
};
|
|
|
|
# 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` (./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 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
|
|
# per hive rather than per service, why `audience` is the client id, and
|
|
# why these tokens are signed rather than opaque — is in
|
|
# `docs/swarm/sso.md`, § Machine clients.
|
|
#
|
|
# Fed to the option as a DEFINITION in the config block below, rather
|
|
# than appended to the declared list downstream. That is what puts it
|
|
# through the submodule: one list, one type, every option's default
|
|
# present. Appending a raw attrset instead left the list half-typed —
|
|
# and a field later added to the submodule then existed on the
|
|
# declared entries and not on these, which is an eval error reachable
|
|
# only once hive identities are on.
|
|
hiveClients = lib.mapAttrsToList (name: _: {
|
|
id = "${cfg.hiveClientPrefix}${name}";
|
|
description = "HyperHive hive ${name}";
|
|
kind = "machine";
|
|
redirectUris = [ ];
|
|
audience = [ "${cfg.hiveClientPrefix}${name}" ];
|
|
accessTokenSignedResponseAlg = "RS256";
|
|
}) hyperhiveCfg.swarm.hives;
|
|
|
|
# The one URL an agent's token has to be authorised at beyond the queue:
|
|
# the log store's machine query route. Read off `swarm.victorialogs.domain`
|
|
# — an unconditional option, declared whether or not this host runs the
|
|
# store — rather than restated, because it is also the string `swarm-logs`
|
|
# requests as its audience and the string `hive-c0re/environment.nix` hands
|
|
# an agent as its endpoint. Three readers, one formula; two spellings
|
|
# present as a valid token refused at the store.
|
|
logsQueryUrl = "https://${hyperhiveCfg.swarm.victorialogs.domain}/select/logsql/query";
|
|
|
|
# The identity an agent container presents to the swarm's queue. One per
|
|
# HIVE, not per agent, and that is the load-bearing choice rather than a
|
|
# shortcut: agents are created at **runtime**, so anything minted per agent
|
|
# would make creating one a config change plus an authelia reload. Keyed on
|
|
# the hive, this list's length tracks the roster above it — deploy-time, like
|
|
# every other entry here.
|
|
#
|
|
# ⚠️ The cost, stated rather than discovered later: every agent on a hive
|
|
# presents the SAME client id, so the broker can tell *hives* apart and not
|
|
# *agents*. Deliberate and deferred — closing it requires the client list to
|
|
# stop being static, which is the same problem the users-database writer
|
|
# already solves for identities.
|
|
#
|
|
# 🏷️ The id EXTENDS the hive's own (`hive-<name>-agent`) rather than taking a
|
|
# prefix of its own (`agent-<name>`), because that reads as *the agent called
|
|
# `<name>`* — which is the one thing this identity does not carry. The cost is
|
|
# that the two ids are not distinguishable by prefix, so the responder's
|
|
# agent rule must be tried BEFORE its hive rule; `policy.rs` says so at the
|
|
# match site, and the assertion below covers the case that ordering cannot.
|
|
#
|
|
# Mirrors `hiveClients` field for field so the two stay comparable. The
|
|
# signing algorithm is not load-bearing here — this client's consumers are
|
|
# the queue's auth-callout responder, which *introspects* rather than
|
|
# verifying offline, and authelia's own authz endpoint — but it matches its
|
|
# sibling rather than inventing a second answer to a question nobody asked.
|
|
agentClients = lib.mapAttrsToList (name: _: {
|
|
id = "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}";
|
|
description = "HyperHive agents on hive ${name}";
|
|
kind = "machine";
|
|
redirectUris = [ ];
|
|
# ⚠️ **Two** audiences, where `hiveClients` has one, because an agent
|
|
# presents this token at two different kinds of consumer. The queue
|
|
# introspects it and reads the client id; the log store's gateway
|
|
# location runs `auth_request` against authelia, which authorises a
|
|
# bearer token **by the URL being requested** and refuses one whose
|
|
# audience does not name it. Measured, not assumed: without the URL
|
|
# registered here the token endpoint answers `invalid_target` for it,
|
|
# and a token minted with no audience at all is answered by the gateway
|
|
# with a bare 401 that names no cause.
|
|
audience = [
|
|
"${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}"
|
|
logsQueryUrl
|
|
];
|
|
# Without it the authz endpoint refuses an otherwise valid token and
|
|
# blames the token rather than the missing grant — the same note
|
|
# `glue-swarm-otel-oidc-client.nix` carries over the collector's client.
|
|
bearerAuthz = true;
|
|
# Stated rather than left on authelia's default, for
|
|
# `glue-swarm-otel-oidc-client.nix`'s reason: authelia permits only
|
|
# basic / JWT methods for a confidential client holding this scope, and
|
|
# enforces it in the startup validator. `swarm-queue-client` sends the
|
|
# credential as basic auth, so this is also what the minter already does.
|
|
tokenEndpointAuthMethod = "client_secret_basic";
|
|
accessTokenSignedResponseAlg = "RS256";
|
|
}) hyperhiveCfg.swarm.hives;
|
|
|
|
# `swarm-authelia-bridge`'s own identity — distinct from
|
|
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
|
|
# resource server introspecting a token proves its OWN identity to the
|
|
# IdP (RFC 7662), separately from whichever principal's token it is
|
|
# checking, so the bridge needs a client even though it never presents
|
|
# a token itself. Contributed unconditionally below (not gated behind
|
|
# `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the
|
|
# bridge is a core, always-present part of this module, not an opt-in
|
|
# consumer — see `usersFile`'s doc comment.
|
|
bridgeClientId = "swarm-authelia-bridge";
|
|
bridgeClient = {
|
|
id = bridgeClientId;
|
|
description = "HyperHive swarm-authelia-bridge (users-database writer)";
|
|
kind = "machine";
|
|
redirectUris = [ ];
|
|
};
|
|
|
|
# `swarmctl login`: an operator's own account, from wherever they run the
|
|
# CLI. The id is the one `swarmctl/src/remote.rs` sends. The audience is
|
|
# the controller's API on the swarm apex, which authelia matches as a URL
|
|
# prefix; the redirect matches a loopback callback on any port.
|
|
swarmctlClient = {
|
|
id = "swarmctl";
|
|
description = "swarmctl (swarm operator CLI)";
|
|
kind = "native";
|
|
redirectUris = [ "http://127.0.0.1/callback" ];
|
|
audience = [ "https://${swarmDomain}/api/" ];
|
|
};
|
|
|
|
# Derived from the client list rather than carrying its own `enable`;
|
|
# `docs/swarm/sso.md` says why that is one fact instead of two.
|
|
#
|
|
# ⚠️ In practice this is now unconditionally `true` whenever the module
|
|
# is enabled: `bridgeClient` above is an unconditional definition of
|
|
# `oidc.clients` (see the `config` block), so the list is never empty.
|
|
# Kept as a derived boolean rather than simplified to a literal `true`
|
|
# so the OIDC-gated code below stays self-documenting about WHY it is
|
|
# conditional, not just that it happens to always be on today.
|
|
oidcEnabled = cfg.oidc.clients != [ ];
|
|
|
|
# Secrets that are 64 random bytes of hex and nothing more. The OIDC
|
|
# hmac key joins them; the issuer key does not (see below — it is RSA).
|
|
randomKeys = [
|
|
"jwt"
|
|
"session"
|
|
"storage-encryption"
|
|
]
|
|
++ lib.optional oidcEnabled "oidc-hmac";
|
|
|
|
# Per-client material lives beside the rest of authelia's state, one
|
|
# file per half: the relying party needs the PLAINTEXT, authelia keeps
|
|
# only a DIGEST. Splitting them is what lets the clients file below be
|
|
# re-rendered on every boot from a secret that was minted once.
|
|
clientsDir = "${stateDir}/oidc-clients";
|
|
clientsFile = "${stateDir}/oidc-clients.yml";
|
|
|
|
# The users database an empty user set is replaced with. authelia exits at
|
|
# startup on `users: {}`, so the file always holds at least this subject,
|
|
# and it can never log in: authelia answers a `disabled` user as "not
|
|
# found" before checking any password, and the digest decodes but its key
|
|
# is all zeros, which no known password hashes to.
|
|
#
|
|
# The `.` keeps the name out of agent names (`[a-z0-9-]`), and once this
|
|
# file is written `swarmctl user add` refuses the name as already taken.
|
|
# The writers round-trip `disabled`, so it survives every later write.
|
|
placeholderUser = "swarm.placeholder";
|
|
placeholderUsersFile = pkgs.writeText "authelia-users-placeholder.yml" ''
|
|
users:
|
|
${placeholderUser}:
|
|
displayname: placeholder (cannot log in)
|
|
email: ${placeholderUser}@hyperhive.local
|
|
disabled: true
|
|
password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
'';
|
|
|
|
# Rendered at RUNTIME, not evaluated: the digest is read from disk by
|
|
# the script, so nothing secret ever enters a nix expression (and
|
|
# therefore the store). Everything else here is public metadata that
|
|
# nix is the right place for.
|
|
# A machine client is not an interactive one with the redirect list left
|
|
# empty: authelia derives the permitted grant from what is declared, and an
|
|
# omitted `grant_types` means authorization-code ONLY. Measured against
|
|
# authelia 4.39.20 — a client rendered without it answers a
|
|
# `client_credentials` request with
|
|
# unauthorized_client: The OAuth 2.0 Client is not allowed to use
|
|
# authorization grant 'client_credentials'
|
|
# so the two shapes have to be told apart here rather than inferred from an
|
|
# empty list.
|
|
#
|
|
# `openid` is deliberately absent from a machine client's scopes: authelia
|
|
# REFUSES the combination outright ("the values 'openid' are not allowed"
|
|
# with `client_credentials`), because a daemon receives an access token and
|
|
# never an id-token. There is no user to identify.
|
|
renderClient =
|
|
c:
|
|
''
|
|
printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id}
|
|
printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description}
|
|
''
|
|
# A native client is public: it has no secret, so none is minted for it.
|
|
+ (
|
|
if c.kind == "native" then
|
|
''
|
|
printf -- ' public: true\n'
|
|
''
|
|
else
|
|
''
|
|
printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})"
|
|
''
|
|
)
|
|
+ ''
|
|
printf -- ' authorization_policy: one_factor\n'
|
|
''
|
|
# Read plainly, and that is a property of the list rather than of
|
|
# this line: every entry reaching here is a definition of
|
|
# `oidc.clients`, so the module system has applied the submodule and
|
|
# each option's default is present. A derived entry that names only
|
|
# the fields it cares about still arrives with the rest filled in.
|
|
+ lib.optionalString (c.kind != "native" && c.tokenEndpointAuthMethod != null) ''
|
|
printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod}
|
|
''
|
|
# Flow-style YAML, matching `scopes` below. The values are client ids
|
|
# and hive names, which `Ident` already constrains to `[a-z0-9-]` — no
|
|
# character in that set needs quoting in a YAML flow sequence.
|
|
+ lib.optionalString (c.audience != [ ]) ''
|
|
printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)}
|
|
''
|
|
+ lib.optionalString (c.accessTokenSignedResponseAlg != null) ''
|
|
printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg}
|
|
''
|
|
+ (
|
|
if c.kind == "machine" then
|
|
''
|
|
printf -- ' grant_types: ["client_credentials"]\n'
|
|
printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n'
|
|
''
|
|
# What authelia 4.39.20 requires of a public client holding
|
|
# `authelia.bearer.authz`; its validator refuses the client without
|
|
# any one of these lines.
|
|
else if c.kind == "native" then
|
|
''
|
|
printf -- ' token_endpoint_auth_method: none\n'
|
|
printf -- ' require_pkce: true\n'
|
|
printf -- ' pkce_challenge_method: S256\n'
|
|
printf -- ' require_pushed_authorization_requests: true\n'
|
|
printf -- ' consent_mode: explicit\n'
|
|
printf -- ' response_types: [code]\n'
|
|
printf -- ' response_modes: [form_post]\n'
|
|
printf -- ' grant_types: [authorization_code, refresh_token]\n'
|
|
printf -- ' scopes: [offline_access, authelia.bearer.authz]\n'
|
|
printf -- ' redirect_uris:\n'
|
|
${lib.concatMapStrings (u: ''
|
|
printf -- ' - %s\n' ${lib.escapeShellArg u}
|
|
'') c.redirectUris}
|
|
''
|
|
else
|
|
''
|
|
printf -- ' scopes: [openid, profile, email, groups]\n'
|
|
printf -- ' redirect_uris:\n'
|
|
${lib.concatMapStrings (u: ''
|
|
printf -- ' - %s\n' ${lib.escapeShellArg u}
|
|
'') c.redirectUris}
|
|
''
|
|
);
|
|
|
|
# The OIDC half of the first-boot generator, kept out of the script
|
|
# body so neither is read through the other's indentation.
|
|
oidcGenScript = lib.optionalString oidcEnabled ''
|
|
# The issuer key is the one secret here that is NOT interchangeable
|
|
# with a random blob: it *signs* id tokens, and every relying party
|
|
# verifies them against the public half served at `/jwks.json`. A
|
|
# symmetric secret cannot do that, so this one is an RSA pair.
|
|
#
|
|
# Rotating it invalidates every token already issued, which is why
|
|
# it is generated once and left alone — same reason as the session
|
|
# and storage keys above.
|
|
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
|
|
if [ ! -s "$issuer" ]; then
|
|
openssl genrsa -out "$issuer" 4096
|
|
echo "generated $issuer"
|
|
fi
|
|
chmod 0600 "$issuer"
|
|
|
|
# Each client's secret, minted once and kept as two files: the
|
|
# plaintext its relying party authenticates with, and the digest
|
|
# authelia compares against. The two live in different containers,
|
|
# so neither side can generate it alone — this is the only place
|
|
# that sees both.
|
|
#
|
|
# `--random` is why no plaintext ever reaches an argv: authelia
|
|
# generates the password itself and prints it beside its digest,
|
|
# so nothing has to be handed to a second process on a command
|
|
# line.
|
|
clients=${lib.escapeShellArg clientsDir}
|
|
mkdir -p "$clients"
|
|
chmod 0700 "$clients"
|
|
|
|
mint() {
|
|
sec="$clients/$1.secret"
|
|
dig="$clients/$1.digest"
|
|
if [ -s "$sec" ] && [ -s "$dig" ]; then
|
|
chmod 0600 "$sec" "$dig"
|
|
return
|
|
fi
|
|
out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random)
|
|
printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec"
|
|
printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig"
|
|
chmod 0600 "$sec" "$dig"
|
|
# Fail closed. An empty secret is a client that can never
|
|
# authenticate, and it surfaces three layers away as an opaque
|
|
# 401 from the token endpoint — refusing to start is by far the
|
|
# cheaper failure to diagnose.
|
|
if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then
|
|
echo "authelia crypto hash generate produced no secret/digest for $1" >&2
|
|
exit 1
|
|
fi
|
|
echo "minted client secret for $1"
|
|
}
|
|
|
|
${lib.concatMapStrings (c: ''
|
|
mint ${lib.escapeShellArg c.id}
|
|
'') (lib.filter (c: c.kind != "native") cfg.oidc.clients)}
|
|
|
|
# Re-rendered every boot, deliberately: the secret is minted once,
|
|
# but the metadata around it (a new redirect URI, a renamed client)
|
|
# comes from nix and has to be able to change without disturbing
|
|
# the secret. Written through a temp file so a crash mid-write
|
|
# cannot leave authelia half a file to parse.
|
|
{
|
|
printf -- 'identity_providers:\n'
|
|
printf -- ' oidc:\n'
|
|
printf -- ' clients:\n'
|
|
${lib.concatMapStrings renderClient cfg.oidc.clients}
|
|
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
|
|
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
|
|
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
|
|
'';
|
|
|
|
# A netns of its own on the hive bridge: the container dials the store, the
|
|
# swarm collector and its own public name through the bridge, and the
|
|
# gateway reaches the three listeners below over sockets, so authelia binds
|
|
# no TCP port on any interface.
|
|
privateNetwork = true;
|
|
|
|
# Fixed, not an option: the directory's mode is what keeps the sockets
|
|
# private, so it holds these three sockets and nothing else.
|
|
socketDir = "/run/swarm-authelia";
|
|
mainSocket = "${socketDir}/authelia.sock";
|
|
metricsSocket = "${socketDir}/metrics.sock";
|
|
bridgeSocket = "${socketDir}/bridge.sock";
|
|
# `upstream` forwards the request's own URI, `upstreamAt` replaces it.
|
|
upstream = socket: "http://unix:${socket}";
|
|
upstreamAt = socket: path: "http://unix:${socket}:${path}";
|
|
|
|
# The gid the gateway's nginx runs as. Only the number crosses into the
|
|
# container, which knows it as `gateway-nginx`; that group is the primary
|
|
# group of every process that creates a socket, so nginx can connect.
|
|
nginxGid = config.ids.gids.nginx;
|
|
socketGroup = "gateway-nginx";
|
|
in
|
|
{
|
|
# What authelia IS to every hive, where it answers (`url`), the OIDC
|
|
# register every service checks itself against, is
|
|
# `swarm.authelia` in ./swarm-authelia-service.nix. What the host running
|
|
# the container decides is here — which two builds it
|
|
# runs, and two 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
|
|
# renames.
|
|
#
|
|
# ⚠️ `usersFile` is a path *inside* the container and still belongs here:
|
|
# a path's scope is the scope of the filesystem it names, and that
|
|
# filesystem is this host's container root.
|
|
options.services.hyperhive.deploy.authelia = {
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = pkgs.authelia;
|
|
defaultText = lib.literalExpression "pkgs.authelia";
|
|
description = ''
|
|
authelia package to run in the container. Defaults to
|
|
nixpkgs's; override to pin a specific upstream.
|
|
'';
|
|
};
|
|
|
|
bridgePackage = lib.mkOption {
|
|
type = lib.types.package;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge";
|
|
description = ''
|
|
`swarm-authelia-bridge` package — the only process allowed to
|
|
write `usersFile`. Wired by default from this flake's own
|
|
package set (see `flake.nix`); override to run a different
|
|
build.
|
|
'';
|
|
};
|
|
|
|
usersFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "${stateDir}/users.yml";
|
|
defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"'';
|
|
description = ''
|
|
Path (inside the container) of authelia's file users database.
|
|
|
|
Written by `swarm-authelia-bridge`, not by hand: agents come and
|
|
go continuously, so the subject set is dynamic and belongs to a
|
|
program. `swarm-controller` cannot write this file itself — a
|
|
different uid owns it — so the bridge is the only writer,
|
|
running inside this same container as this file's actual owner.
|
|
When the user set is empty this module writes a single
|
|
disabled placeholder subject, `${placeholderUser}`, so authelia
|
|
starts and serves its login page; nobody can log in until
|
|
`swarmctl user add` creates the first account.
|
|
'';
|
|
};
|
|
|
|
hostUsersFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
readOnly = true;
|
|
default = "/var/lib/nixos-containers/${cfg.machine}${deployCfg.authelia.usersFile}";
|
|
description = ''
|
|
`usersFile` as seen from the **host** — the container's root
|
|
prefixed onto the path authelia sees.
|
|
|
|
Published for callers that only ever need to *read* the file
|
|
(e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge`
|
|
itself never uses this path — it runs inside the container, as
|
|
the file's own owner, and writes the in-container path directly.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf deployCfg.authelia.enable {
|
|
# The derived half of the client list, declared the same way an
|
|
# operator declares one. Everything downstream then reads a single
|
|
# uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart —
|
|
# including the assertions below, which is why a hive named `x`
|
|
# colliding with a declared `hive-x` is caught rather than rendered
|
|
# twice. `bridgeClient` is unconditional (plain list concatenation,
|
|
# not `mkIf`-gated like `hiveClients`): the bridge is always present
|
|
# wherever this module is, so `oidc.clients` is never actually empty
|
|
# — see `oidcEnabled`'s comment above.
|
|
services.hyperhive.swarm.authelia.oidc.clients =
|
|
lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients)
|
|
++ [
|
|
bridgeClient
|
|
forwarderClient
|
|
]
|
|
++ lib.optional (swarmDomain != null) swarmctlClient;
|
|
|
|
# A redirect URI on a machine client is not harmless-but-unused: it
|
|
# means whoever wrote it believes a browser is involved. Failing here
|
|
# is how that belief gets corrected at the point it was expressed,
|
|
# rather than at a token endpoint months later.
|
|
assertions =
|
|
map (c: {
|
|
assertion = c.kind != "machine" || c.redirectUris == [ ];
|
|
message =
|
|
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
|
|
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
|
|
+ "client has nobody to redirect; drop the URIs or make it "
|
|
+ "kind = \"interactive\".";
|
|
}) cfg.oidc.clients
|
|
# Two clients sharing an id renders two YAML entries under one name.
|
|
# Newly reachable now that part of the list is DERIVED: a hive called
|
|
# `x` and a service client called `hive-x` never met before. Authelia
|
|
# would reject it, but three layers away and at boot — naming both
|
|
# sources here is the cheaper failure.
|
|
++ [
|
|
{
|
|
assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients;
|
|
message =
|
|
"services.hyperhive.swarm.authelia: duplicate OIDC client id(s): "
|
|
+ lib.concatStringsSep ", " (
|
|
lib.unique (
|
|
lib.filter (id: lib.count (x: x == id) (map (c: c.id) cfg.oidc.clients) > 1) (
|
|
map (c: c.id) cfg.oidc.clients
|
|
)
|
|
)
|
|
)
|
|
+ ". Hive identities are named `hive-<name>` from "
|
|
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
|
+ "colliding client.";
|
|
}
|
|
{
|
|
# A hive whose name ENDS with the agent suffix mints an id the
|
|
# queue's responder reads as somebody else's agents:
|
|
# `hive-foo-agent` parses as *the agents of hive foo* before it
|
|
# parses as *the hive foo-agent*, because the agent rule is the
|
|
# more specific one and therefore runs first.
|
|
#
|
|
# ⚠️ NOT covered by the duplicate-id assertion above, and the gap is
|
|
# the interesting half. That one fires only when BOTH `foo` and
|
|
# `foo-agent` are on the roster, because only then are two clients
|
|
# actually named the same string. With `foo-agent` alone there is no
|
|
# duplicate and nothing to see — the hive simply receives its
|
|
# agents' grant instead of its own, and a NATS denial arrives as a
|
|
# timeout, so the symptom names nothing.
|
|
#
|
|
# Checkable here and nowhere downstream: the responder runs in a
|
|
# container with no view of the roster (`policy.rs`'s `hive_name`
|
|
# says why that is deliberate), so this is the last place that knows
|
|
# both the naming scheme and the set of names.
|
|
assertion =
|
|
!cfg.oidc.hiveIdentities
|
|
|| !lib.any (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives);
|
|
message =
|
|
"services.hyperhive.swarm.hives contains "
|
|
+ lib.concatMapStringsSep ", " (h: "'${h}'") (
|
|
lib.filter (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives)
|
|
)
|
|
+ " — names ending with '${cfg.agentClientSuffix}', the suffix that "
|
|
+ "marks a hive's agent containers. Such a hive's own client id is "
|
|
+ "indistinguishable from another hive's agent client, and the "
|
|
+ "queue resolves it as the agents. Rename the hive.";
|
|
}
|
|
# The two obligations `authelia.bearer.authz` carries. Authelia
|
|
# enforces both itself — but in its `preStart` validator, so a
|
|
# violation produces a green `nixos-rebuild switch` and an authelia
|
|
# that then refuses to come back up, taking swarm SSO with it.
|
|
# Asserting here moves the same failure to evaluation, where a
|
|
# wrong value costs a build and nothing else.
|
|
{
|
|
assertion = lib.all (
|
|
c: !(c.bearerAuthz || c.kind == "native") || c.audience != [ ]
|
|
) cfg.oidc.clients;
|
|
message =
|
|
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
+ lib.concatStringsSep ", " (
|
|
map (c: "client '${c.id}'") (
|
|
lib.filter (c: (c.bearerAuthz || c.kind == "native") && c.audience == [ ]) cfg.oidc.clients
|
|
)
|
|
)
|
|
+ " sets bearerAuthz or kind = \"native\" but declares no audience. The audience is "
|
|
+ "what authorises a bearer token at a given URL, so without "
|
|
+ "one the scope grants access to nothing and authelia refuses "
|
|
+ "the configuration outright.";
|
|
}
|
|
{
|
|
assertion = lib.all (
|
|
c:
|
|
!c.bearerAuthz
|
|
|| lib.elem c.tokenEndpointAuthMethod [
|
|
"client_secret_basic"
|
|
"client_secret_jwt"
|
|
"private_key_jwt"
|
|
]
|
|
) cfg.oidc.clients;
|
|
message =
|
|
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
+ lib.concatStringsSep ", " (
|
|
map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") (
|
|
lib.filter (
|
|
c:
|
|
c.bearerAuthz
|
|
&& !lib.elem c.tokenEndpointAuthMethod [
|
|
"client_secret_basic"
|
|
"client_secret_jwt"
|
|
"private_key_jwt"
|
|
]
|
|
) cfg.oidc.clients
|
|
)
|
|
)
|
|
+ ". A confidential client carrying authelia.bearer.authz must "
|
|
+ "authenticate with client_secret_basic, client_secret_jwt or "
|
|
+ "private_key_jwt. Notably client_secret_post is refused, and "
|
|
+ "it is what an OAuth2 client library may reach for first. "
|
|
+ "Leaving it null is refused too: authelia requires the method "
|
|
+ "to be STATED under this scope rather than defaulted.";
|
|
}
|
|
# Nothing else stops `bearerAuthz` on an interactive client, and it
|
|
# would silently do nothing: `renderClient` reads the flag only in
|
|
# the `machine` branch, so the scope is simply never emitted and the
|
|
# client authenticates fine while being authorised for nothing.
|
|
#
|
|
# That is this module's own failure mode one level up — a green
|
|
# build and a grant that does not exist — and `kind` defaults to
|
|
# `interactive`, so it is reached by FORGETTING a field rather than
|
|
# by writing a wrong one.
|
|
{
|
|
assertion = lib.all (c: !c.bearerAuthz || c.kind == "machine") cfg.oidc.clients;
|
|
message =
|
|
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
+ lib.concatStringsSep ", " (
|
|
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.kind != "machine") cfg.oidc.clients)
|
|
)
|
|
+ " sets bearerAuthz with kind != \"machine\". The scope is only "
|
|
+ "emitted for machine clients, so this grants nothing while "
|
|
+ "evaluating and deploying cleanly. A browser client has a user "
|
|
+ "to authorise and does not need it.";
|
|
}
|
|
{
|
|
assertion = lib.all oneWord (lib.attrValues storeFiles);
|
|
message = ''
|
|
The secret publisher's store identity paths must not contain
|
|
whitespace: nspawn's --load-credential receives them through
|
|
nixos-container's unquoted extra flags.
|
|
'';
|
|
}
|
|
{
|
|
assertion =
|
|
(hyperhiveCfg.swarm.otel.serviceProducers.${cfg.machine}.clientId or null) == forwarderClientId;
|
|
message = ''
|
|
services.hyperhive.swarm.otel.serviceProducers has no entry
|
|
"${cfg.machine}" with clientId "${forwarderClientId}", so the swarm
|
|
collector has no receiver for authelia's journal forwarder. Declare
|
|
it in swarm-otel-service.nix.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# Authelia's own gateway surface: the vhost that fronts it and the
|
|
# name the hive resolver answers for. Both live here rather than in
|
|
# the gateway, and both are inside `deployCfg.authelia.enable` — that guard is the
|
|
# load-bearing part.
|
|
#
|
|
# ⚠️ Every hive in a swarm knows `authelia.url`, but only the host
|
|
# that RUNS the container may claim the name. A client hive
|
|
# declaring this vhost would answer for a service it does not run,
|
|
# and publishing the DNS record would point every agent on its
|
|
# bridge at that wrong answer.
|
|
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
|
|
|
# This host serves the vhost, and the container behind it resolves
|
|
# through the hive's dnsmasq.
|
|
services.hyperhive.gateway.enable = lib.mkDefault true;
|
|
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
|
|
|
# `server_name = authelia.domain`, all of `/` → authelia.
|
|
#
|
|
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
|
|
# authelia validates `authelia_url ⊂ session cookie domain` at
|
|
# STARTUP, so a mismatch is a container that refuses to boot rather
|
|
# than a login that misbehaves.
|
|
#
|
|
# ⚠️ And deliberately NO `auth_request` here: it would put the login
|
|
# page behind the login it serves.
|
|
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
|
|
locations."/" = {
|
|
proxyPass = upstreamAt mainSocket "/";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
# authelia decides by the ORIGINAL request, not by the hop it
|
|
# sees — the login redirect and the session cookie's domain
|
|
# both derive from these. Without them every request looks
|
|
# like it arrived over a local socket in plain http.
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-Host $host;
|
|
proxy_set_header X-Forwarded-Uri $request_uri;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
# A dead upstream here means "not bootstrapped" far more often
|
|
# than "misconfigured proxy", and a bare 502 says the opposite.
|
|
proxy_intercept_errors on;
|
|
error_page 502 503 504 = /__hive_sso_unavailable;
|
|
'';
|
|
};
|
|
locations."= /__hive_sso_unavailable" = {
|
|
extraConfig = ''
|
|
internal;
|
|
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
|
|
default_type text/html;
|
|
'';
|
|
};
|
|
|
|
# Authelia's MACHINE surface, routed without the error interception
|
|
# above. A longer prefix wins over `/` in nginx, and
|
|
# `proxy_intercept_errors` / `error_page` are set inside that
|
|
# location rather than at server level, so they do not reach here.
|
|
#
|
|
# ⚠️ THIS IS A SECURITY BOUNDARY, not a tidy-up. Do not fold it back
|
|
# into `/`: the friendly error page answers **200**, and
|
|
# `auth_request` reads any 2xx as ACCESS GRANTED. Full consequence
|
|
# list in `docs/swarm/sso.md`, § Machine callers must fail closed.
|
|
#
|
|
# The split is by AUDIENCE, not by path list — including the login
|
|
# page's own XHR. Enumerating individual endpoints would leave the
|
|
# next one added silently intercepted.
|
|
locations."/api/" = {
|
|
proxyPass = upstream mainSocket;
|
|
};
|
|
locations."/.well-known/" = {
|
|
proxyPass = upstream mainSocket;
|
|
};
|
|
# `swarm-authelia-bridge`, a separate process from authelia, also a
|
|
# machine surface. The trailing slash on `proxyPass` strips the
|
|
# prefix, so `/bridge/requests` reaches the bridge as `/requests`.
|
|
# The bridge itself refuses every client but swarm-controller's.
|
|
locations."/bridge/" = {
|
|
proxyPass = upstreamAt bridgeSocket "/";
|
|
};
|
|
|
|
# Authelia's own metrics, for the swarm collector wherever it runs.
|
|
# The listener (`metricsSocket`) is unauthenticated and serves at `/`;
|
|
# this location is the only way in from off the machine. `= /metrics`
|
|
# outranks the `/` prefix, so its auth is the one that runs.
|
|
#
|
|
# The collector requests exactly this URL, declared as
|
|
# `publishedScrapeTargets.authelia` in ./swarm-otel-service.nix; the two
|
|
# must agree, since that string is also the token's audience.
|
|
locations."= /metrics" = {
|
|
proxyPass = upstreamAt metricsSocket "/";
|
|
extraConfig = ''
|
|
auth_request /__authelia_metrics_authz;
|
|
'';
|
|
};
|
|
|
|
# The subrequest, same shape as the forge's `= /__forge_metrics_authz`
|
|
# (./hive-forge/default.nix), and machine-facing for the same reason: no
|
|
# `error_page 401 =302`, so a scraper gets the 401 rather than a login
|
|
# page parsed as metrics.
|
|
#
|
|
# Authelia authorising a request for itself does not loop: the
|
|
# subrequest lands on `/api/` above, which carries no `auth_request`.
|
|
# An authelia that is down answers the subrequest 502, which
|
|
# `auth_request` turns into a 500 for the scrape — closed, not open.
|
|
locations."= /__authelia_metrics_authz" = {
|
|
proxyPass = "https://${cfg.domain}/api/authz/auth-request";
|
|
# nixpkgs appends its OWN `Host $host` after extraConfig, which would
|
|
# override verifiedProxyTo's — see hive-gateway/vhost-lib.nix.
|
|
recommendedProxySettings = false;
|
|
extraConfig = ''
|
|
internal;
|
|
${gatewayCfg.lib.verifiedProxyTo cfg.domain}
|
|
proxy_pass_request_body off;
|
|
proxy_set_header Content-Length "";
|
|
proxy_set_header X-Original-Method $request_method;
|
|
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-Host $http_host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
'';
|
|
};
|
|
};
|
|
|
|
# Order the container after the host CA generator, so the bind source
|
|
# exists before nspawn sets the mount up. nspawn also fails to start it
|
|
# when a credential source is missing, so where this host mints the
|
|
# publisher's leaf the container pulls the minting unit in and waits for
|
|
# it, as ./swarm-grafana.nix's does. The minting is local: the store need
|
|
# not be up.
|
|
systemd.services."container@${cfg.machine}" = lib.mkMerge [
|
|
caTrust.containerOrdering
|
|
(lib.mkIf publishes {
|
|
after = [ "swarm-bao-pki.service" ];
|
|
wants = [ "swarm-bao-pki.service" ];
|
|
})
|
|
];
|
|
|
|
# The socket directory, created host-side before the container starts:
|
|
# nixos-container refuses to start when a bind source is missing.
|
|
#
|
|
# Root-owned and group nginx, `0770`: the listeners create their sockets
|
|
# through the group (the authelia user's uid is allocated inside the
|
|
# container, so the host cannot name it), and the gateway traverses it the
|
|
# same way. Everything else is kept out.
|
|
systemd.tmpfiles.rules = [
|
|
"d ${socketDir} 0770 root ${toString nginxGid} - -"
|
|
];
|
|
|
|
# The hive bridge carries the container's DHCP lease and its resolver.
|
|
services.hyperhive.network.enable = lib.mkDefault true;
|
|
|
|
containers.${cfg.machine} = {
|
|
autoStart = true;
|
|
ephemeral = false;
|
|
# No `--link-journal=host`: the journal stays in the guest, where the
|
|
# container's own forwarder reads it (../container-modules/swarm-journal-forwarder.nix).
|
|
extraFlags = lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
|
|
inherit privateNetwork;
|
|
# Public trust bundle only, read-only. Empty when the gateway is not
|
|
# self-signed, so the whole trust path drops out cleanly.
|
|
hostBridge = networkCfg.bridgeName;
|
|
bindMounts = {
|
|
# A bind mount rather than a path both sides happen to know: a
|
|
# container's `/run` is an nspawn tmpfs the host cannot see into.
|
|
${socketDir} = {
|
|
hostPath = socketDir;
|
|
isReadOnly = false;
|
|
};
|
|
}
|
|
// caTrust.bindMount;
|
|
|
|
config =
|
|
{ ... }:
|
|
{
|
|
imports = [
|
|
../container-modules/swarm-container.nix
|
|
../container-modules/swarm-journal-forwarder.nix
|
|
../container-modules/swarm-secret-publisher.nix
|
|
(import ../container-modules/swarm-container-resolver.nix {
|
|
inherit (networkCfg) bridgeIp;
|
|
# The forwarder dials the swarm collector and authelia by name,
|
|
# and its fetch and the publisher the store.
|
|
dnsConsumers = [
|
|
cfg.unit
|
|
]
|
|
++ lib.optionals publishes [
|
|
"opentelemetry-collector.service"
|
|
"${forwarderFetchUnit}.service"
|
|
"${publisherUnit}.service"
|
|
];
|
|
})
|
|
caBundleModule
|
|
];
|
|
|
|
services.hyperhive.swarmContainer = { inherit privateNetwork; };
|
|
|
|
# The bridge address comes from the gateway's DHCP. `swarm-bridge-dns`
|
|
# owns resolv.conf, so dhcpcd must not write it over that unit.
|
|
networking.interfaces.eth0.useDHCP = true;
|
|
networking.dhcpcd.extraConfig = "nohook resolv.conf";
|
|
|
|
# The sockets' group is the primary group of the processes that create
|
|
# them, so that group is nginx's gid. A static group, so every unit
|
|
# that binds a socket can name it.
|
|
users.groups.${socketGroup}.gid = nginxGid;
|
|
|
|
# Authenticated as the forwarder's own OIDC client, its secret read
|
|
# from the store with the publisher's identity: one identity for the
|
|
# whole container. With no identity there is nothing to read it with,
|
|
# and the forwarder is off.
|
|
services.hyperhive.swarmJournalForwarder = lib.mkIf publishes {
|
|
enable = true;
|
|
serviceName = "authelia";
|
|
endpoint = forwarderEndpoint;
|
|
clientId = forwarderClientId;
|
|
tokenUrl = "${toString cfg.url}/api/oidc/token";
|
|
telemetryPort = forwarderTelemetryPort;
|
|
store = storeIdentityIn forwarderFetchUnit // {
|
|
secretPath = "secret/swarm/services/${forwarderClientId}/oidc/client";
|
|
};
|
|
};
|
|
systemd.services.${forwarderFetchUnit} = lib.mkIf publishes {
|
|
serviceConfig.LoadCredential = lib.attrNames storeFiles;
|
|
};
|
|
|
|
# Copies what the mint below leaves in `clientsDir` into the store,
|
|
# under the publisher's own identity, a system credential. Nothing
|
|
# orders authelia after it.
|
|
services.hyperhive.swarmSecretPublisher = lib.mkIf publishes {
|
|
enable = true;
|
|
clientSecretDir = clientsDir;
|
|
inherit (publisherCfg) secrets;
|
|
store = storeIdentityIn publisherUnit;
|
|
};
|
|
systemd.services.${publisherUnit} = lib.mkIf publishes {
|
|
# A run before the mint would only skip every secret and wait
|
|
# for the path unit.
|
|
after = [ "${unitName}-secrets.service" ];
|
|
serviceConfig.LoadCredential = lib.attrNames storeFiles;
|
|
};
|
|
|
|
# The authelia binary itself, so an operator who gets a shell
|
|
# in here can run `authelia crypto hash generate` to make a
|
|
# password for the users file. Without it the container runs
|
|
# authelia and cannot invoke it: the unit's ExecStart resolves
|
|
# through the store path, and nothing puts the CLI on PATH.
|
|
environment.systemPackages = [ deployCfg.authelia.package ];
|
|
|
|
# authelia's own secrets, generated in-container on first
|
|
# boot. They are jwt/session/storage keys — nothing outside
|
|
# this container ever reads them, which is what makes
|
|
# in-container generation right rather than merely easier.
|
|
# (hive-matrix generates its token host-side only because
|
|
# hive-c0re has to read that one.)
|
|
#
|
|
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
|
# so systemd creates the directory owned by the account that
|
|
# has to read the files and this unit can write nowhere else.
|
|
# No chown, no mode juggling: authelia opens these paths
|
|
# itself, as its own user, under `PrivateUsers=true`.
|
|
systemd.services."${unitName}-secrets" = {
|
|
description = "Generate authelia's secrets on first boot";
|
|
wantedBy = [ "multi-user.target" ];
|
|
before = [ "${unitName}.service" ];
|
|
requiredBy = [ "${unitName}.service" ];
|
|
# `deployCfg.authelia.package` is here for its CLI, not its
|
|
# daemon: the client secrets are minted with `authelia crypto
|
|
# hash generate`, which is the only way to produce a digest in
|
|
# the exact form authelia will later verify.
|
|
path = [
|
|
pkgs.coreutils
|
|
]
|
|
++ lib.optionals oidcEnabled [
|
|
pkgs.openssl
|
|
deployCfg.authelia.package
|
|
];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
User = unitName;
|
|
Group = socketGroup;
|
|
StateDirectory = unitName;
|
|
StateDirectoryMode = "0700";
|
|
UMask = "0077";
|
|
SyslogIdentifier = "${unitName}-secrets";
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
|
|
${atomicWriteSecret}
|
|
|
|
# Each is generated once and never rotated here: the
|
|
# session and storage keys are load-bearing for data
|
|
# already written (sessions, the encrypted store), so
|
|
# replacing one is an operator action, not a boot action.
|
|
for f in ${lib.concatStringsSep " " randomKeys}; do
|
|
p=${lib.escapeShellArg stateDir}/"$f".key
|
|
if [ ! -s "$p" ]; then
|
|
value=$(head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
atomic_write_secret 0600 "" "$p" "$value"
|
|
echo "generated $p"
|
|
fi
|
|
chmod 0600 "$p"
|
|
done
|
|
${oidcGenScript}
|
|
|
|
# authelia exits at startup on an empty user set, so an empty
|
|
# database gets the disabled placeholder subject instead. A
|
|
# file holding exactly `users: {}` is an empty set too; neither
|
|
# writer ever produces one, so replacing it drops no user.
|
|
users=${lib.escapeShellArg deployCfg.authelia.usersFile}
|
|
if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then
|
|
install -m 0600 ${placeholderUsersFile} "$users"
|
|
echo "seeded $users with the disabled placeholder ${placeholderUser}"
|
|
fi
|
|
chmod 0600 "$users"
|
|
'';
|
|
};
|
|
|
|
# The only process allowed to write `deployCfg.authelia.usersFile` — see that
|
|
# option's doc comment, and the crate's own README for the full
|
|
# "why does an unprivileged swarm-controller need a bridge at
|
|
# all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE
|
|
# point of this whole unit: it is that same account, so it
|
|
# owns the file it writes and needs no elevated privilege.
|
|
# Ordered after authelia's own secrets generator (needs its
|
|
# own client secret, minted by that unit's `mint` loop) and
|
|
# after authelia itself (introspects against its local
|
|
# `/api/oidc/introspection`, so needs it answering — not
|
|
# load-bearing at start, since nothing calls the bridge yet at
|
|
# boot, but a clean dependency order beats a would-be-transient
|
|
# failure on the first real request).
|
|
systemd.services.swarm-authelia-bridge = {
|
|
description = "swarm-authelia-bridge: the only writer of authelia's users database";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [
|
|
"${unitName}-secrets.service"
|
|
"${unitName}.service"
|
|
];
|
|
wants = [
|
|
"${unitName}-secrets.service"
|
|
"${unitName}.service"
|
|
];
|
|
serviceConfig = {
|
|
ExecStart = "${deployCfg.authelia.bridgePackage}/bin/swarm-authelia-bridge";
|
|
User = unitName;
|
|
Group = socketGroup;
|
|
Restart = "on-failure";
|
|
RestartSec = "5s";
|
|
};
|
|
environment = {
|
|
SWARM_AUTHELIA_BRIDGE_SOCKET = bridgeSocket;
|
|
# ONE file, and this is it. The bridge keeps no second,
|
|
# private users store: a store of its own treated as canonical,
|
|
# with `users.yml` written as a rendering of it, would be two
|
|
# canonical stores for one physical file, and would make
|
|
# `swarm agent create` refuse to start on a hive whose
|
|
# `users.yml` already held users.
|
|
SWARM_AUTHELIA_BRIDGE_USERS_FILE = deployCfg.authelia.usersFile;
|
|
# The CONFIGURED authelia, not whatever is on `PATH`: the
|
|
# argon2 parameters baked into a hash have to match the
|
|
# verifier's — same reasoning as `swarmctl`'s own
|
|
# `SWARMCTL_AUTHELIA_BIN`.
|
|
SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${deployCfg.authelia.package}/bin/authelia";
|
|
# By name through the gateway, not loopback. A loopback
|
|
# literal encodes "authelia is in my netns" at the call site,
|
|
# and authelia's OIDC endpoints are https-only in effect —
|
|
# reached directly they answer 400, because the forwarded
|
|
# headers nginx injects for every other consumer are what let
|
|
# it determine its own issuer. Same URL `swarm-nats-auth`
|
|
# uses, so there is one idiom rather than two.
|
|
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection";
|
|
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
|
|
# Minted by `${unitName}-secrets`'s `mint` loop (it iterates
|
|
# every entry in `cfg.oidc.clients`, which now always
|
|
# includes `bridgeClient`) — same file this container's own
|
|
# `renderClient` reads the digest half of.
|
|
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
|
# The one client the bridge answers. Every other active token
|
|
# (each hive and its agents hold one) is refused, on every
|
|
# request, since `/bridge/` is reachable from any host.
|
|
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
|
|
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
|
|
};
|
|
};
|
|
|
|
# Upstream's unit is `ProtectSystem=strict`, so the socket directory
|
|
# is writable only by name. A socket left by a killed process would
|
|
# fail the next `bind`, so both are removed before each start.
|
|
systemd.services.${unitName}.serviceConfig = {
|
|
ReadWritePaths = [ socketDir ];
|
|
ExecStartPre = [ "${pkgs.coreutils}/bin/rm -f ${mainSocket} ${metricsSocket}" ];
|
|
};
|
|
|
|
services.authelia.instances.${instance} = {
|
|
enable = true;
|
|
package = deployCfg.authelia.package;
|
|
|
|
# Primary group of the unit and its user: the group the sockets are
|
|
# created in (see `socketGroup`).
|
|
group = socketGroup;
|
|
|
|
# Merged at RUNTIME alongside the nix-generated config, which
|
|
# is the whole reason the client digests can exist at all:
|
|
# `settings` below is rendered into the world-readable nix
|
|
# store, and a client secret's digest may not go there.
|
|
# Empty (and inert) on a hive with no clients declared.
|
|
settingsFiles = lib.optional oidcEnabled clientsFile;
|
|
|
|
secrets = {
|
|
jwtSecretFile = "${stateDir}/jwt.key";
|
|
sessionSecretFile = "${stateDir}/session.key";
|
|
storageEncryptionKeyFile = "${stateDir}/storage-encryption.key";
|
|
}
|
|
// lib.optionalAttrs oidcEnabled {
|
|
# Both are `LoadCredential`-delivered by upstream's module,
|
|
# so they reach authelia as `AUTHELIA_*_FILE` env and never
|
|
# as values. Same by-path discipline as the three above —
|
|
# which is what lets the provider's own secrets stay
|
|
# in-container: nothing outside this container reads them.
|
|
#
|
|
# ⚠️ The issuer key is NOT passed as the deprecated
|
|
# `issuer_private_key`: on 4.38+ the config key is the
|
|
# `jwks` *list*, and a list element cannot take the
|
|
# `_FILE` env treatment. Upstream's module bridges that
|
|
# by generating a small settings file that templates the
|
|
# PEM in (`{{ secret "<path>" }}`) and prepending it to
|
|
# `settingsFiles`. So handing it a path here is the
|
|
# modern shape, not the legacy one — checked against the
|
|
# pin (nixpkgs `services/security/authelia.nix`), because
|
|
# the option name alone reads like the old key.
|
|
oidcHmacSecretFile = "${stateDir}/oidc-hmac.key";
|
|
oidcIssuerPrivateKeyFile = "${stateDir}/oidc-issuer.key";
|
|
};
|
|
|
|
# Small-deployment defaults, and the scope is the
|
|
# justification: one swarm, one authelia, no replicas.
|
|
# - file users backend, written by swarm-controller
|
|
# - local sqlite storage: redis buys shared session state
|
|
# across replicas, and there is one instance
|
|
# - filesystem notifier: SMTP is for mailing humans, and
|
|
# provisioning is programmatic; a file is honest about
|
|
# where those messages go instead of implying a mail path
|
|
settings = {
|
|
theme = "dark";
|
|
server.address = "unix://${mainSocket}?umask=0007";
|
|
|
|
# Let a machine present an OAuth2 access token to the same
|
|
# `auth_request` endpoint browsers use, so a scraper can be
|
|
# authenticated by the gateway instead of every service
|
|
# growing its own static bearer.
|
|
#
|
|
# ⚠️ `authn_strategies` REPLACES the defaults rather than
|
|
# adding to them, so `CookieSession` is listed explicitly.
|
|
# Dropping it does not fail to evaluate and does not fail to
|
|
# start — it silently ends every operator session on the
|
|
# swarm UI, which rides this same endpoint.
|
|
#
|
|
# Unconditional, and not keyed to whichever service is
|
|
# currently scraped: this only makes a *scheme* available.
|
|
# Authorisation is the audience — authelia refuses a token
|
|
# that carries no audience for the requested URL, and a
|
|
# client may only be issued audiences it is registered for.
|
|
# So enabling the scheme grants nobody anything until a
|
|
# client is registered for a specific URL.
|
|
server.endpoints.authz.auth-request = {
|
|
implementation = "AuthRequest";
|
|
authn_strategies = [
|
|
{
|
|
name = "HeaderAuthorization";
|
|
schemes = [ "Bearer" ];
|
|
}
|
|
{ name = "CookieSession"; }
|
|
];
|
|
};
|
|
log.level = "info";
|
|
|
|
# Prometheus exposition for the swarm collector to scrape.
|
|
# A socket only the gateway can reach, like the main listener above
|
|
# and for a stronger reason: this endpoint has no authentication of its
|
|
# own, and it reports request volumes and outcomes for every
|
|
# SSO login on the swarm. The collector reaches it through the
|
|
# vhost's gated `= /metrics` location.
|
|
telemetry.metrics = {
|
|
enabled = true;
|
|
address = "unix://${metricsSocket}?umask=0007";
|
|
};
|
|
|
|
# `watch` is load-bearing, not a convenience: authelia reads
|
|
# this file once at startup, and `swarm-authelia-bridge` writes
|
|
# it to create agent identities while being unable to restart
|
|
# authelia — running unprivileged is the whole reason it may
|
|
# write the file at all. Without this, an identity it creates is
|
|
# real on disk and invisible until something unrelated restarts.
|
|
authentication_backend.file = {
|
|
path = deployCfg.authelia.usersFile;
|
|
watch = true;
|
|
};
|
|
|
|
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
|
|
# user", which is authentication, not authorisation. The
|
|
# swarm UI is operator-only, and agents are getting
|
|
# authelia accounts of their own — so the day that lands,
|
|
# a session alone would be enough to open it. The
|
|
# `operatorsOnly` pairs below are what make the distinction
|
|
# real; without them the vhost's `auth_request` is a check
|
|
# nobody fails.
|
|
#
|
|
# The group is a constant rather than an option: it is the
|
|
# value an operator types into `swarmctl user add --group`,
|
|
# and a configurable name is one more way for the rule and
|
|
# the account to disagree silently.
|
|
access_control = {
|
|
default_policy = "one_factor";
|
|
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
|
|
# The metrics rules are listed first so they cannot be
|
|
# shadowed by a broader domain rule added later. A `deny` with
|
|
# no `resources` ends its domain: a rule for that domain
|
|
# listed after it is never reached.
|
|
rules =
|
|
# The collector's client where one is registered, then
|
|
# `deny` — see `collectorOnly` above, which is where the
|
|
# reasoning for both halves lives.
|
|
metricsRules
|
|
++ metricsStoreRules
|
|
++ logsStoreRules
|
|
# The swarm UI on the apex. Not keyed to a deploy flag: its
|
|
# vhost is on the swarm controller's host, which need not
|
|
# be this one. Guarded on the domain being set: without it
|
|
# a null apex would render a rule matching the string "null".
|
|
++ lib.optionals (swarmDomain != null) (operatorsOnly {
|
|
domain = swarmDomain;
|
|
})
|
|
# The store's browser UI. Not keyed to a deploy flag: its
|
|
# vhost is on the store's host, which need not be this
|
|
# one, and without these rules any session passes it.
|
|
++ lib.optionals (swarmDomain != null) (operatorsOnly {
|
|
domain = hyperhiveCfg.swarm.bao.ui.domain;
|
|
})
|
|
# Every hive's dashboard, each on its own hive's host.
|
|
++ lib.optionals (swarmDomain != null) (
|
|
lib.concatMap (domain: operatorsOnly { inherit domain; }) (
|
|
lib.attrValues hyperhiveCfg.swarm.dashboardDomains
|
|
)
|
|
);
|
|
};
|
|
|
|
# The cookie domain is the SWARM's domain, NOT authelia's
|
|
# own host: the session cookie has to be sent to the apps
|
|
# being protected (`forge.<swarm>`, `chat.<swarm>`), and a
|
|
# cookie scoped to `auth.<swarm>` reaches none of them.
|
|
# authelia enforces the relationship from the other side
|
|
# too — `authelia_url` must be a sub-domain of `domain`, so
|
|
# setting both to the same host fails validation at startup
|
|
# rather than at first login.
|
|
#
|
|
# Each hive's dashboard answers on `swarm.dashboardDomains`,
|
|
# under this domain, so the cookie reaches it whatever the
|
|
# hive's own domain is.
|
|
session.cookies = [
|
|
{
|
|
domain = cookieDomain;
|
|
authelia_url = "https://${cfg.domain}";
|
|
}
|
|
];
|
|
|
|
storage.local.path = "${stateDir}/db.sqlite3";
|
|
notifier.filesystem.filename = "${stateDir}/notification.txt";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|