Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
11 changed files with 155 additions and 69 deletions

View file

@ -69,11 +69,11 @@ provider, differentiated by roles and claims rather than by mechanism —
there is one IdP and one auth path. there is one IdP and one auth path.
- **`deploy.authelia`** — run the container here. - **`deploy.authelia`** — run the container here.
- **`swarm.authelia.url`** — where clients go to authenticate. Present - **`swarm.authelia.url`** — where clients go to authenticate.
on **every** hive and the same value on all of them: Present on **every** hive, defaulting to this host's own instance only
`https://<swarm.authelia.domain>`, whether or not this host runs the when this module is the thing running it; otherwise `null`, and a hive
container. Set it explicitly when joining a swarm whose IdP is under joining someone else's swarm sets it explicitly. Null means "no SSO
another name. configured", and consumers say so rather than guessing an address.
swarm-controller writes the users database, not by hand: hive-c0re swarm-controller writes the users database, not by hand: hive-c0re
creates and destroys agents continuously, so the subject set is dynamic. creates and destroys agents continuously, so the subject set is dynamic.

View file

@ -573,6 +573,20 @@ in
host: see docs/swarm/ for which secret goes where. host: see docs/swarm/ for which secret goes where.
''; '';
} }
{
# Without a provider URL there is nothing to discover against,
# and the rendered unit would ask `null/.well-known/…`.
assertion = autheliaUrl != null;
message = ''
The forge's SSO login source requires
services.hyperhive.swarm.authelia.url — the base URL of the
swarm's SSO provider.
It defaults to this host's own instance only when this host
runs authelia. A hive that federates with a swarm sets it
explicitly to wherever that provider lives.
'';
}
{ {
assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl; assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl;
message = '' message = ''

View file

@ -968,6 +968,20 @@ in
host: see docs/swarm/ for which secret goes where. host: see docs/swarm/ for which secret goes where.
''; '';
} }
{
# Without a provider URL there is nothing to discover against, and
# the rendered config would name `null` as its issuer.
assertion = autheliaUrl != null;
message = ''
This homeserver's SSO login flow requires
services.hyperhive.swarm.authelia.url — the base URL of the
swarm's SSO provider.
It defaults to this host's own instance only when this host
runs authelia. A hive that federates with a swarm sets it
explicitly to wherever that provider lives.
'';
}
{ {
# The callback URL must name the homeserver itself, and with no # The callback URL must name the homeserver itself, and with no
# gateway vhost there is no public name for it to be built from. # gateway vhost there is no public name for it to be built from.

View file

@ -457,25 +457,23 @@ in
}; };
url = lib.mkOption { url = lib.mkOption {
type = lib.types.str; type = lib.types.nullOr lib.types.str;
default = "https://${cfg.domain}"; default = if deployCfg.authelia.enable then "https://${cfg.domain}" else null;
defaultText = lib.literalExpression ''"https://''${domain}"''; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null'';
example = "https://auth.example.com"; example = "https://auth.example.com";
description = '' description = ''
Base URL clients are sent to for authentication — the half of Base URL clients are sent to for authentication — the half of
this module that exists on **every** hive, not just the one this module that exists on **every** hive, not just the one
running the container. running the container.
Names {option}`domain`, and does **not** ask whether this host Defaults to this host's own instance **only when this module is
runs the container: a swarm has one SSO provider, so every hive the thing running it**; in that case the URL is not a guess, it
addresses the same name and resolution decides where it is — is where this module just put the container. Otherwise `null`,
dnsmasq locally on the host serving the vhost, the real network and a hive that federates with a swarm sets it explicitly to
anywhere else. There is no loopback-vs-remote branch to get wherever the swarm's authelia lives. Null means "no SSO
wrong, the same way {option}`services.hyperhive.swarm.otel.domain` configured" and consumers say so rather than inventing an
has none. address — an endpoint baked in as a fallback is one that
resolves cleanly and points at the wrong machine.
Not having SSO is not a supported deployment: every swarm has an
IdP, so this is never `null`.
''; '';
}; };
@ -952,6 +950,18 @@ in
# would reject it, but three layers away and at boot — naming both # would reject it, but three layers away and at boot — naming both
# sources here is the cheaper failure. # sources here is the cheaper failure.
++ [ ++ [
{
# The bridge introspects by name, so a null URL becomes a nix
# coercion error several files from its cause. Only reachable by
# enabling authelia and clearing `url` by hand — an assertion
# rather than a fallback, because a guessed URL that evaluates
# cleanly is worse than a refused build.
assertion = cfg.url != null;
message =
"services.hyperhive.swarm.authelia.url must not be null when authelia "
+ "is enabled: swarm-authelia-bridge introspects at "
+ "`\${url}/api/oidc/introspection` from inside its container.";
}
{ {
assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients; assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients;
message = message =

View file

@ -382,7 +382,7 @@ in
tokenEndpoint = lib.mkOption { tokenEndpoint = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "${autheliaCfg.url}/api/oidc/token"; default = lib.optionalString (autheliaCfg.url != null) "${autheliaCfg.url}/api/oidc/token";
defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"''; defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"'';
description = '' description = ''
The OIDC token endpoint the controller mints its own access The OIDC token endpoint the controller mints its own access
@ -773,13 +773,12 @@ in
{ {
assertion = cfg.queue.tokenEndpoint != ""; assertion = cfg.queue.tokenEndpoint != "";
message = '' message = ''
services.hyperhive.swarm.controller.queue.tokenEndpoint is unset. services.hyperhive.swarm.controller.queue.tokenEndpoint is unset,
which means services.hyperhive.swarm.authelia.url is null.
It defaults from services.hyperhive.swarm.authelia.url, so this The controller mints its own access token before it may connect
only happens when it has been cleared by hand. The controller to the queue, so it needs to know where the swarm's identity
mints its own access token before it may connect to the queue, provider lives — set that URL, or set this endpoint directly.
so it needs to know where the swarm's identity provider lives —
set this endpoint directly.
''; '';
} }
{ {

View file

@ -109,6 +109,18 @@ let
# operator sees, not a coercion error from here. # operator sees, not a coercion error from here.
domainBase = if swarmDomain == null then "invalid" else swarmDomain; domainBase = if swarmDomain == null then "invalid" else swarmDomain;
# Is SSO configured for this SWARM. Swarm-wide by construction — `swarm.*` is
# identical on every host — and the option's own description is what makes
# this the right question to ask: a null URL means "no SSO configured".
#
# 🩸 A subject of an assertion below, NOT a gate. Dropping the OIDC block when
# this is false looks conservative and is the outage: `disable_login_form` is
# unconditional a few hundred lines down, so a Grafana with no OIDC settings
# is a Grafana with no login of any kind, arrived at silently. SSO is a
# requirement of running this service, so an unconfigured swarm fails to
# build and says which option to set.
ssoConfigured = hyperhiveCfg.swarm.authelia.url != null;
# A reader of the store is defined by holding a certificate the store # A reader of the store is defined by holding a certificate the store
# accepts, never by standing next to it — the rule # accepts, never by standing next to it — the rule
# ./glue-matrix-bao-token.nix states in full. # ./glue-matrix-bao-token.nix states in full.
@ -436,11 +448,34 @@ in
# host that runs authelia, and this whole block is gated on the host that # host that runs authelia, and this whole block is gated on the host that
# runs Grafana. ./glue-grafana-oidc-client.nix is where it moved to. # runs Grafana. ./glue-grafana-oidc-client.nix is where it moved to.
# This arm fires only where Grafana runs — this whole block is under # Both arms are what used to be a silent gate, and both fire only where
# `deploy.grafana.enable`. SSO itself needs no assertion any more: # Grafana runs — this whole block is under `deploy.grafana.enable`. The
# `swarm.authelia.url` is non-nullable, so every swarm has one and the # binding itself is asserted rather than a copy of its formula, the way
# OIDC block below always names a real endpoint. # ./swarm-nats.nix's own `autheliaUrl` arm does it: two spellings of one
# boolean is two places for a future edit to land in only one.
assertions = [ assertions = [
{
# SSO is not optional for this service, and the reason is a hundred
# lines below in `auth.disable_login_form = true`: Grafana ships an
# `admin`/`admin` account on a public vhost, so the password box is
# off whatever the topology. Emitting no OIDC block when the swarm
# names no IdP therefore produces a container with no way in at all —
# a state no log names, since nothing failed. Failing the build and
# naming the option is the only report that reaches anyone.
assertion = ssoConfigured;
message = ''
services.hyperhive.deploy.grafana.enable requires
services.hyperhive.swarm.authelia.url — Grafana's only login is SSO,
because its local login form is disabled unconditionally (it ships
an admin/admin account and its vhost is on the public gateway).
It defaults to this host's own instance only when this host runs
authelia. A hive that federates with a swarm sets it explicitly to
wherever that provider lives. Grafana exchanges its authorization
code at `''${url}/api/oidc/token` from inside its container, so a
null URL leaves no endpoint to name.
'';
}
{ {
# The other half of one login: the OIDC block names a `$__file{}` that # The other half of one login: the OIDC block names a `$__file{}` that
# `swarm-bao-grafana-oidc.service` below writes, and that unit reads # `swarm-bao-grafana-oidc.service` below writes, and that unit reads

View file

@ -511,6 +511,18 @@ in
to have this host mint all four. to have this host mint all four.
''; '';
} }
{
assertion = autheliaUrl != null;
message = ''
services.hyperhive.deploy.nats.enable requires
services.hyperhive.swarm.authelia.url — the queue authenticates
clients by validating tokens that authelia issued.
It defaults to this host's own instance only when this host
runs authelia. A hive that federates with a swarm sets it
explicitly to wherever that provider lives.
'';
}
]; ];
# One declaration, two readers. The queue knows which client id it # One declaration, two readers. The queue knows which client id it

View file

@ -1027,6 +1027,22 @@ in
should not be described as both. should not be described as both.
''; '';
} }
{
# A hive proves who it is with a token this provider mints, so
# there is no version of this collector that runs without one.
# Stated as an assertion rather than a fallback because a guessed
# issuer URL evaluates cleanly and refuses every hive at runtime.
assertion = hyperhiveCfg.swarm.authelia.url != null;
message = ''
services.hyperhive.deploy.swarm-otel.enable is true but
services.hyperhive.swarm.authelia.url is null: every hive
authenticates to this collector as itself, and the token comes
from the swarm's identity provider.
Point authelia.url at the swarm's provider, or enable
services.hyperhive.swarm.authelia on the host that runs it.
'';
}
{ {
# A port collision between two listeners on one host is a runtime # A port collision between two listeners on one host is a runtime
# coin toss with nothing in any log — the failure this whole # coin toss with nothing in any log — the failure this whole

View file

@ -500,8 +500,8 @@ in
options.services.hyperhive.swarm.statusPublish = { options.services.hyperhive.swarm.statusPublish = {
tokenEndpoint = lib.mkOption { tokenEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = "${swarmCfg.authelia.url}/api/oidc/token"; default = if swarmCfg.authelia.url != null then "${swarmCfg.authelia.url}/api/oidc/token" else null;
defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"''; defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token" when that URL is set, else null'';
example = "https://auth.example.com/api/oidc/token"; example = "https://auth.example.com/api/oidc/token";
description = '' description = ''
The swarm IdP's OAuth2 token endpoint. This hive mints a The swarm IdP's OAuth2 token endpoint. This hive mints a

View file

@ -70,6 +70,17 @@ let
swarm.authelia.url = "https://auth.example.invalid"; swarm.authelia.url = "https://auth.example.invalid";
}; };
# The mirror image: the identity is placed, and the swarm names no IdP. The
# other half of "SSO must always be configured", and isolated the same way —
# exactly one thing wrong, so the arm reads one refusal.
grafanaNoSso = hive {
deploy.grafana.enable = true;
deploy.grafana.plugins = [ ];
deploy.grafana.package = pkgs.emptyDirectory;
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
};
# Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its # Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its
# two reasons. An assertion is a config VALUE until something forces it — # two reasons. An assertion is a config VALUE until something forces it —
# `.config` never throws — so a fixture in a state the module refuses is # `.config` never throws — so a fixture in a state the module refuses is
@ -153,7 +164,19 @@ let
&& !(grafanaRefusedFor grafanaNoIdentity "swarm.authelia.url"); && !(grafanaRefusedFor grafanaNoIdentity "swarm.authelia.url");
} }
{ {
# Without this the arm above proves nothing: a refusal that fires on # "SSO must always be configured", as an eval-time refusal rather than a
# gate. A null URL used to drop the OIDC block silently, and
# `disable_login_form` is unconditional a hundred lines below it, so that
# combination produced a Grafana with no SSO and no password box — an
# outage whose cause is a boolean that evaluated to false at build time
# and left no trace. Same isolation as the arm above, mirrored.
name = "a grafana host in a swarm with no IdP is refused, naming swarm.authelia.url";
ok =
grafanaRefusedFor grafanaNoSso "services.hyperhive.swarm.authelia.url"
&& !(grafanaRefusedFor grafanaNoSso "deploy.bao.clientCertFile");
}
{
# Without this the two arms above prove nothing: a refusal that fires on
# every host is not a check, and both of these are hosts a swarm is # every host is not a check, and both of these are hosts a swarm is
# expected to have. Read through the same helper, so a message that # expected to have. Read through the same helper, so a message that
# stopped naming its option would fail the arms above rather than pass # stopped naming its option would fail the arms above rather than pass

View file

@ -59,15 +59,6 @@ let
swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed";
swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed";
}; };
# A hive running NOTHING of the swarm's own services — no IdP here, no
# `swarm.authelia.url` set by hand. The whole point of the fixture is what it
# does *not* say: it is the shape whose IdP address used to be null, and
# before that a co-location-derived guess.
autheliaNotColocated = hive { };
# The same swarm's IdP host, for the arm that the two agree.
autheliaColocated = hive { deploy.authelia.enable = true; };
cases = [ cases = [
{ {
# Reads the RENDERED settings, not the option: `calloutBlocks {…} // { # Reads the RENDERED settings, not the option: `calloutBlocks {…} // {
@ -125,34 +116,6 @@ let
name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace"; name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace";
ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script; ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script;
} }
{
# Config NAMES the IdP; it never computes where the IdP is. Both arms
# matter together: the address is the swarm's name on a hive that runs
# nothing, and it is the SAME string on the hive that serves the vhost —
# so a re-introduced co-location branch shows up as the two disagreeing
# rather than as a value that merely looks plausible on one of them.
name = "the swarm IdP address is its domain on every hive, co-located or not";
ok =
autheliaNotColocated.services.hyperhive.swarm.authelia.url
== "https://${autheliaNotColocated.services.hyperhive.swarm.authelia.domain}"
&&
autheliaColocated.services.hyperhive.swarm.authelia.url
== autheliaNotColocated.services.hyperhive.swarm.authelia.url;
}
{
# …and the name it resolves through is a DOMAIN, not a host this config
# picked. `127.0.0.1` is what the old default rendered on the IdP host,
# the bridge address is what the queue's defaults still render, and
# neither is an address a client may be handed: the domain is allowed to
# resolve differently in different places, which is the whole property.
name = "the swarm IdP address names no host address";
ok =
let
url = autheliaNotColocated.services.hyperhive.swarm.authelia.url;
bridgeIp = autheliaNotColocated.services.hyperhive.network.bridgeIp;
in
!(lib.hasInfix "127.0.0.1" url) && !(lib.hasInfix "localhost" url) && !(lib.hasInfix bridgeIp url);
}
]; ];
in in
runGroup "nats-authelia" cases runGroup "nats-authelia" cases