Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1471430d | ||
|
|
f022e97813 | ||
|
|
59ecefe0e1 | ||
|
|
9fa75a7f2f |
2 changed files with 305 additions and 14 deletions
|
|
@ -3,7 +3,7 @@
|
|||
The swarm runs one authelia, and it is two things at once: the **session
|
||||
provider** every protected vhost checks (`auth_request`), and — once any
|
||||
client is declared — an **OIDC provider** issuing tokens to relying
|
||||
parties like the forge.
|
||||
parties: the forge and the matrix homeserver.
|
||||
|
||||
The second role is derived rather than switched:
|
||||
`services.hyperhive.swarm.authelia.oidc.clients` being non-empty turns it
|
||||
|
|
@ -116,20 +116,20 @@ mechanism with flags.
|
|||
|
||||
### 1. All-local — one host runs both
|
||||
|
||||
Nothing to configure beyond `swarm.forge.sso.enable = true`. A host-side
|
||||
unit waits for authelia's first boot to mint the secret and copies it
|
||||
into the forge container, and the forge module contributes its own client
|
||||
Nothing to configure beyond `swarm.forge.sso.enable = true` or
|
||||
`swarm.matrix.sso.enable = true`. Per service, a host-side unit waits for
|
||||
authelia's first boot to mint that client's secret and copies it into the
|
||||
service's container, and the service's own module contributes its client
|
||||
entry — callback URL included — to authelia's client list.
|
||||
|
||||
The callback is built from the same source name the registration uses, so
|
||||
the redirect URI authelia is told to allow and the one forgejo actually
|
||||
sends cannot drift apart. A mismatch there is a rejected login with no
|
||||
error text worth reading.
|
||||
The callback is built once and read twice, so the redirect URI authelia is
|
||||
told to allow and the one the service actually sends cannot drift apart. A
|
||||
mismatch there is a rejected login with no error text worth reading.
|
||||
|
||||
⚠️ The delivery is a copy, not a `bindMounts` entry, and deliberately so:
|
||||
nixos-container refuses to start a container whose bind source is
|
||||
missing, and this secret does not exist until authelia's first boot has
|
||||
run. Binding it would make the forge wait on a file that waits on a
|
||||
run. Binding it would make the service wait on a file that waits on a
|
||||
container that starts after it — on a fresh hive, a permanent stall
|
||||
presenting as "the forge is broken", several layers from its cause.
|
||||
|
||||
|
|
@ -159,12 +159,38 @@ half-configured shows a login button that always fails — a symptom
|
|||
several layers from its cause, and far worse to diagnose than an
|
||||
evaluation error.
|
||||
|
||||
## Where each relying party differs
|
||||
|
||||
The registration half is identical; what each service does with the
|
||||
result is not.
|
||||
|
||||
| | forge | matrix |
|
||||
|---|---|---|
|
||||
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
|
||||
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
|
||||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
||||
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||
file through `LoadCredential` for the same reason the registration token
|
||||
does — that keeps `DynamicUser` and `PrivateUsers` intact, with no
|
||||
host-side ownership arrangement to maintain.
|
||||
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
||||
spoken by non-browser clients holding matrix access tokens — every
|
||||
agent's own daemon — as well as by federation, so `/_matrix/` is served
|
||||
directly and authenticates itself. The forward-auth vhosts protect
|
||||
browser surfaces; this is not one of them.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- **It does not disable local login.** The forge keeps its password
|
||||
database and gains a second door. An identity provider that can take
|
||||
the forge offline when it hiccups is a worse forge than one with two
|
||||
ways in.
|
||||
- **It does not disable local login.** Each service keeps its password
|
||||
database and gains a second door. An identity provider that can take a
|
||||
service offline when it hiccups is worse than one with two ways in.
|
||||
Making authelia the only path is a separate, reversible switch per
|
||||
service (tuwunel's `login_with_password`, forgejo's own setting).
|
||||
- **It does not provision users.** Agents are created and destroyed
|
||||
continuously, so the subject set belongs to a program rather than to a
|
||||
config file; today that program is `swarmctl`.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,43 @@ let
|
|||
# it is rebuilt from the current CA every boot rather than going stale.
|
||||
matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt";
|
||||
swarmDomain = config.services.hyperhive.swarm.domain;
|
||||
|
||||
# `url` is the half of the authelia module that exists on EVERY hive —
|
||||
# null when no SSO provider is configured anywhere, which the assertion
|
||||
# below turns into an eval failure rather than a discovery request to
|
||||
# `null/.well-known/…`.
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
autheliaUrl = autheliaCfg.url;
|
||||
|
||||
# The all-local case: this host runs BOTH the homeserver and the swarm's
|
||||
# authelia, so the secret can be moved without an operator. The other
|
||||
# two cases (swarm side, remote hive) leave `clientSecretFile` to be set
|
||||
# explicitly — same split the forge module documents.
|
||||
ssoLocal = cfg.sso.enable && autheliaCfg.enable;
|
||||
|
||||
# Where the plaintext lands inside the matrix container. Under /var/lib
|
||||
# rather than /run: the homeserver may start before the delivery unit on
|
||||
# a later boot, and a secret that evaporates on reboot turns a working
|
||||
# login into an intermittent one.
|
||||
matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret";
|
||||
|
||||
# ⚠️ tuwunel does NOT read the path above directly, and this indirection
|
||||
# is not ceremony. Upstream's own words: "under systemd the path must be
|
||||
# visible to the service after sandboxing (ReadWritePaths / ProtectHome),
|
||||
# typically by placing the file under /etc/tuwunel/" — which this
|
||||
# container has no writable etc for. `LoadCredential` is the answer
|
||||
# already in use two units below for the registration token, and for the
|
||||
# same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact
|
||||
# with no host-side chown or GID pinning.
|
||||
matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret";
|
||||
|
||||
# Format-locked by tuwunel, not chosen here: the callback host must point
|
||||
# directly at the matrix server and the path is fixed at
|
||||
# `/_matrix/client/unstable/login/sso/callback/<client_id>`. Built once
|
||||
# and read twice — by the homeserver's own config and by the client entry
|
||||
# handed to authelia — because a redirect-URI mismatch is a rejected
|
||||
# login with no error text worth reading.
|
||||
ssoCallbackUrl = "https://${toString cfg.gatewayHost}/_matrix/client/unstable/login/sso/callback/${cfg.sso.clientId}";
|
||||
# Falls back to the SWARM domain: a swarm runs one homeserver, so its
|
||||
# identifier belongs to the swarm rather than to whichever hive happens
|
||||
# to host it — otherwise moving the container between hives would look
|
||||
|
|
@ -394,6 +431,70 @@ in
|
|||
'';
|
||||
};
|
||||
};
|
||||
|
||||
sso = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Let this homeserver delegate login to the swarm's authelia,
|
||||
as an OIDC relying party — matrix SSO (`m.login.sso`), an
|
||||
extra flow offered alongside password login.
|
||||
|
||||
⚠️ Not to be confused with tuwunel's `oidc_*` settings, which
|
||||
point the other way: those make this homeserver an
|
||||
*authorization server* for matrix clients. This option makes
|
||||
it a *client* of an external identity provider. The two
|
||||
families share the protocol's name and answer opposite
|
||||
questions.
|
||||
|
||||
This **adds** a way in. Password login keeps working: an
|
||||
identity provider that can take the homeserver offline when
|
||||
it hiccups is a worse homeserver than one with two ways in.
|
||||
Making authelia the only path is a separate, reversible
|
||||
switch (tuwunel's `login_with_password`), deliberately not
|
||||
folded in here.
|
||||
|
||||
⚠️ Matrix SSO lives **inside** the homeserver, never behind a
|
||||
forward-auth proxy: the client-server API is spoken by
|
||||
non-browser clients holding matrix access tokens — every
|
||||
agent's own `hive-matrix-daemon` — plus federation, and a
|
||||
proxy in front of `/_matrix/` breaks all of it.
|
||||
'';
|
||||
};
|
||||
|
||||
clientId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "tuwunel";
|
||||
description = ''
|
||||
OAuth2 client id this homeserver identifies itself with. Must
|
||||
match the `id` of the corresponding entry in
|
||||
`services.hyperhive.swarm.authelia.oidc.clients`.
|
||||
'';
|
||||
};
|
||||
|
||||
clientSecretFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/var/lib/tuwunel-oidc/tuwunel.secret";
|
||||
description = ''
|
||||
Path **inside the matrix container** holding the client
|
||||
secret's plaintext.
|
||||
|
||||
A path, never a value: an OIDC client secret has two holders
|
||||
in two containers (authelia keeps a hash, this homeserver
|
||||
needs the plaintext), and a literal written here would be
|
||||
rendered into the world-readable nix store.
|
||||
|
||||
Required when `enable` is set — deliberately no fallback. A
|
||||
homeserver that boots with SSO half-configured is worse than
|
||||
one that fails to evaluate: tuwunel reads OIDC from its
|
||||
config file rather than a database row, so a malformed block
|
||||
can stop the server outright instead of merely hiding a
|
||||
button.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
|
@ -494,8 +595,140 @@ in
|
|||
"matrix.example.com" or "homeserver.internal".
|
||||
'';
|
||||
}
|
||||
{
|
||||
# Fail at EVAL, not at boot. tuwunel reads its identity providers
|
||||
# from the config file, so a half-configured one does not hide a
|
||||
# login button — it can stop the homeserver from starting at all.
|
||||
assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null;
|
||||
message = ''
|
||||
services.hyperhive.swarm.matrix.sso.enable requires
|
||||
sso.clientSecretFile — the path (inside the matrix container)
|
||||
holding the OIDC client secret's plaintext.
|
||||
|
||||
On a hive that also runs the swarm's authelia this is wired up
|
||||
for you. Set it explicitly when authelia lives on another
|
||||
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 = !cfg.sso.enable || autheliaUrl != null;
|
||||
message = ''
|
||||
services.hyperhive.swarm.matrix.sso.enable 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
|
||||
# gateway vhost there is no public name for it to be built from.
|
||||
assertion = !cfg.sso.enable || cfg.gatewayHost != null;
|
||||
message = ''
|
||||
services.hyperhive.swarm.matrix.sso.enable requires
|
||||
services.hyperhive.swarm.matrix.gatewayHost.
|
||||
|
||||
tuwunel's SSO callback URL is format-locked to
|
||||
`<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`,
|
||||
and the identity provider redirects a browser to it — so it
|
||||
has to be a name the browser can reach, which is exactly what
|
||||
`gatewayHost` is. With it null the homeserver is direct on
|
||||
httpPort and has no such name.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# One declaration, two readers. The homeserver knows its own callback
|
||||
# URL; making the operator restate it in authelia's client list would
|
||||
# be a second source of truth for a string whose mismatch is a silent
|
||||
# rejected login.
|
||||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [
|
||||
{
|
||||
id = cfg.sso.clientId;
|
||||
description = "HyperHive matrix";
|
||||
redirectUris = [ ssoCallbackUrl ];
|
||||
}
|
||||
];
|
||||
|
||||
# Same case, same reasoning: this host minted the secret, so it can say
|
||||
# where the homeserver will find it.
|
||||
services.hyperhive.swarm.matrix.sso.clientSecretFile = lib.mkIf ssoLocal (
|
||||
lib.mkDefault matrixSecretPath
|
||||
);
|
||||
|
||||
# The delivery. It runs on the HOST because that is the only place both
|
||||
# container trees are addressable: they share this host's network
|
||||
# namespace, which makes them feel co-located, but their filesystem
|
||||
# roots are separate — the homeserver cannot open a path inside
|
||||
# authelia's tree however local the port looks.
|
||||
#
|
||||
# ⚠️ Deliberately a copy and not a `bindMounts` entry.
|
||||
# nixos-container refuses to start when a bind source is missing, and
|
||||
# this secret does not exist until authelia's first boot has minted it
|
||||
# — so binding it would make the homeserver wait on a file that waits
|
||||
# on a container that starts after it. On a fresh hive that is a
|
||||
# permanent stall presenting as "matrix is broken", several layers from
|
||||
# its cause.
|
||||
#
|
||||
# The registration token above dodges that with an activation script
|
||||
# that pre-creates the file. ⚠️ That dodge is NOT available here:
|
||||
# tuwunel requires the secret file to exist *and be non-empty*, so a
|
||||
# zero-byte placeholder would satisfy the bind mount and then stop the
|
||||
# homeserver from starting.
|
||||
systemd.services.hive-matrix-oidc-secret = lib.mkIf ssoLocal {
|
||||
description = "deliver the homeserver's OIDC client secret from authelia";
|
||||
after = [ "container@${autheliaCfg.machine}.service" ];
|
||||
requires = [ "container@${autheliaCfg.machine}.service" ];
|
||||
before = [ "container@hive-matrix.service" ];
|
||||
wantedBy = [ "container@hive-matrix.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
SyslogIdentifier = "hive-matrix-oidc-secret";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"}
|
||||
dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString cfg.sso.clientSecretFile}"}
|
||||
|
||||
# authelia's container is up, but its first-boot generator may
|
||||
# still be minting. Bounded wait, then fail: a silent skip here
|
||||
# produces a homeserver whose SSO login dead-ends, which is the
|
||||
# failure this whole design is trying not to ship.
|
||||
for _ in $(seq 1 60); do
|
||||
[ -s "$src" ] && break
|
||||
sleep 2
|
||||
done
|
||||
if [ ! -s "$src" ]; then
|
||||
echo "authelia has not minted $src after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# root-owned 0400, and deliberately NOT the forge's `stat -c %u`
|
||||
# uid discovery: that reads the service's state dir to learn which
|
||||
# uid to hand the file to, and tuwunel runs under `DynamicUser`, so
|
||||
# there is no stable uid to discover. It never reads this path
|
||||
# directly anyway — `LoadCredential` does, as root, before the
|
||||
# sandbox and the dynamic user exist.
|
||||
install -D -m 0400 -o root -g root "$src" "$dst"
|
||||
'';
|
||||
};
|
||||
|
||||
# ⚠️ Deliberately NO `networking.hosts` entry for authelia's name, and
|
||||
# the difference from hive-forge (which needs one) is worth stating:
|
||||
# that container resolves through the host's resolvers, where the swarm
|
||||
# domain has no records. This one resolves through the hive's dnsmasq
|
||||
# at `bridgeIp` (see the static resolv.conf below), and every
|
||||
# `gateway.localNames` entry — authelia's domain among them — is
|
||||
# already mapped there. Adding a loopback override would only create a
|
||||
# second answer that can disagree with the first.
|
||||
|
||||
# Activation-time token generation — without this the bind-mount
|
||||
# would hand tuwunel an empty file on first boot and break every
|
||||
# registration until restart. Idempotent;
|
||||
|
|
@ -607,6 +840,34 @@ in
|
|||
# Tuwunel's default suffix is " 💕" — suppress it so agent
|
||||
# display names are clean (just the agent name, no emoji).
|
||||
new_user_displayname_suffix = "";
|
||||
}
|
||||
# `optionalAttrs`, not a key set to `[]`: with SSO off the
|
||||
# rendered settings must be *exactly* what they were before
|
||||
# this option existed, and an empty list is still a key.
|
||||
// lib.optionalAttrs cfg.sso.enable {
|
||||
# tuwunel's OIDC server and this list are the two ends of one
|
||||
# pipe: `oidc_native_auth` stays false (its default), which
|
||||
# upstream defines as "the OIDC server runs only to broker
|
||||
# for a configured identity_provider". So the client-facing
|
||||
# half needs no configuration — only the upstream half does.
|
||||
identity_provider = [
|
||||
{
|
||||
# A free, case-insensitive string, not an enum: a
|
||||
# recognised brand gets defaults and provider-specific
|
||||
# workarounds, an unrecognised one simply gets neither.
|
||||
# Which is why `issuer_url` below is not optional for us
|
||||
# — the pre-supplied issuers cover public providers only.
|
||||
brand = "authelia";
|
||||
client_id = cfg.sso.clientId;
|
||||
client_secret_file = matrixSecretCredential;
|
||||
issuer_url = toString autheliaUrl;
|
||||
callback_url = ssoCallbackUrl;
|
||||
# Explicit, though a lone provider is auto-defaulted:
|
||||
# relying on that logs a warning every startup, and a
|
||||
# recurring warning that is expected is one nobody reads.
|
||||
default = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
# Keeps DynamicUser=true + PrivateUsers=true intact — no
|
||||
|
|
@ -614,7 +875,11 @@ in
|
|||
# See `man systemd.exec` → LoadCredential.
|
||||
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
||||
"registration_token:${toString cfg.registrationTokenFile}"
|
||||
];
|
||||
]
|
||||
# Same mechanism, second secret. tuwunel re-reads this file on
|
||||
# every OAuth exchange, not just at startup, so it has to outlive
|
||||
# the unit's start — a credentials path does.
|
||||
++ lib.optional cfg.sso.enable "oidc_client_secret:${toString cfg.sso.clientSecretFile}";
|
||||
|
||||
# Federation TLS against a peer whose cert chains to the swarm
|
||||
# root: tuwunel's outbound client is reqwest with the `rustls`
|
||||
|
|
|
|||
Loading…
Reference in a new issue