Watch
0
0
Fork
You've already forked hyperhive
0

swarm-authelia: run in its own network namespace

Refs #4815
This commit is contained in:
atlas 2026-10-21 21:26:13 +02:00 • committed by mara
commit 220b51d0a3
12 changed files with 204 additions and 105 deletions

View file

@ -61,6 +61,19 @@ in
own, and the gateway reaches it there, so it binds no host port. Remove
this definition.
'')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "authelia" "port" ] ''
authelia listens on a unix socket in a network namespace of its own, and
the gateway reaches it there, so it binds no port. Remove this
definition.
'')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "authelia" "metricsPort" ] ''
authelia's metrics listener is a unix socket the gateway scrapes, so it
binds no port. Remove this definition.
'')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "authelia" "bridgePort" ] ''
swarm-authelia-bridge listens on a unix socket the gateway proxies to, so
it binds no port. Remove this definition.
'')
(lib.mkRemovedOptionModule [
"services"
"hyperhive"

View file

@ -24,39 +24,6 @@ in
# Here is what authelia IS, including `url` and the OIDC client registry
# every hive needs as a *client* whether or not it runs the container.
options.services.hyperhive.swarm.authelia = {
port = lib.mkOption {
type = lib.types.port;
default = 9091;
description = ''
TCP port authelia listens on. 9091 is upstream's default and
sits outside hyperhive's claimed ranges (dashboard 7000, forge
3000, every agent in 8100..8999 via FNV-1a hash).
'';
};
metricsPort = lib.mkOption {
type = lib.types.port;
default = 9959;
description = ''
TCP port authelia serves its Prometheus metrics on, bound to
loopback. Upstream's default, kept so an operator reading
authelia's documentation finds what they expect.
A separate port from {option}`port` because it is a separate
listener with a different audience: the main one serves logins and
is reachable from the swarm, this one carries no authentication of
its own. The swarm collector scrapes it, from whichever host it
runs on, as `https://''${domain}/metrics`: the gateway's
`= /metrics` location proxies to this port only after authelia
has accepted the collector's token for that URL.
⚠️ Every swarm container shares the host network namespace, so
two services defaulting to the same port do not conflict at build
time — one simply loses at runtime, with nothing in any log. Check
a new value against the others before changing this.
'';
};
domain = lib.mkOption {
type = lib.types.str;
# Under the SWARM domain, like the forge and matrix: a swarm has one
@ -426,21 +393,6 @@ in
'';
};
bridgePort = lib.mkOption {
type = lib.types.port;
default = 9092;
description = ''
TCP port `swarm-authelia-bridge` listens on, loopback-bound
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
`port` (9091), outside hyperhive's other claimed ranges.
Not published on its own: the authelia vhost proxies
`/bridge/` to it, and that is the address `swarm-controller`
uses from any host. The bridge answers only
`swarm-controller`'s OIDC client.
'';
};
otel.clientId = lib.mkOption {
type = lib.types.str;
readOnly = true;

View file

@ -208,8 +208,8 @@ let
gatewayCfg.httpsPort != 443
) ":${toString gatewayCfg.httpsPort}";
forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}${gatewayPortSuffix}/${cfg.machine}";
# In the host netns: distinct from every other collector's there, which the
# assertion below checks.
# 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
@ -603,13 +603,31 @@ let
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
'';
# Shared host netns, like the forge container: the
# gateway reaches authelia at 127.0.0.1:<port>.
privateNetwork = false;
# 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 and its port, is
# 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
@ -854,21 +872,6 @@ in
it in swarm-otel-service.nix.
'';
}
{
assertion =
!(lib.elem forwarderTelemetryPort [
hyperhiveCfg.otel.telemetryPort
hyperhiveCfg.swarm.otel.telemetryPort
hyperhiveCfg.swarm.bao.otel.telemetryPort
hyperhiveCfg.swarm.nats.otel.telemetryPort
]);
message = ''
authelia's journal forwarder's telemetry port (${toString forwarderTelemetryPort})
collides with another collector's in the host netns: the hive
collector's, the swarm collector's, the store forwarder's or the
queue forwarder's.
'';
}
];
# Authelia's own gateway surface: the vhost that fronts it and the
@ -899,14 +902,14 @@ in
# page behind the login it serves.
services.hyperhive.gateway.vhosts."${cfg.domain}" = {
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
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 at 127.0.0.1 over plain http.
# 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;
@ -939,21 +942,21 @@ in
# page's own XHR. Enumerating individual endpoints would leave the
# next one added silently intercepted.
locations."/api/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
proxyPass = upstream mainSocket;
};
locations."/.well-known/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
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 = "http://127.0.0.1:${toString cfg.bridgePort}/";
proxyPass = upstreamAt bridgeSocket "/";
};
# Authelia's own metrics, for the swarm collector wherever it runs.
# The listener (`metricsPort`) is loopback-only and unauthenticated;
# 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.
#
@ -961,7 +964,7 @@ in
# `publishedScrapeTargets.authelia` in ./swarm-otel-service.nix; the two
# must agree, since that string is also the token's audience.
locations."= /metrics" = {
proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics";
proxyPass = upstreamAt metricsSocket "/";
extraConfig = ''
auth_request /__authelia_metrics_authz;
'';
@ -1009,6 +1012,20 @@ in
})
];
# 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;
@ -1018,7 +1035,16 @@ in
inherit privateNetwork;
# Public trust bundle only, read-only. Empty when the gateway is not
# self-signed, so the whole trust path drops out cleanly.
bindMounts = caTrust.bindMount;
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 =
{ ... }:
@ -1045,6 +1071,16 @@ in
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,
@ -1119,7 +1155,7 @@ in
Type = "oneshot";
RemainAfterExit = true;
User = unitName;
Group = unitName;
Group = socketGroup;
StateDirectory = unitName;
StateDirectoryMode = "0700";
UMask = "0077";
@ -1185,12 +1221,12 @@ in
serviceConfig = {
ExecStart = "${deployCfg.authelia.bridgePackage}/bin/swarm-authelia-bridge";
User = unitName;
Group = unitName;
Group = socketGroup;
Restart = "on-failure";
RestartSec = "5s";
};
environment = {
SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}";
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
@ -1225,10 +1261,22 @@ in
};
};
# 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
@ -1272,7 +1320,7 @@ in
# where those messages go instead of implying a mail path
settings = {
theme = "dark";
server.address = "tcp://127.0.0.1:${toString cfg.port}";
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
@ -1305,14 +1353,14 @@ in
log.level = "info";
# Prometheus exposition for the swarm collector to scrape.
# Loopback only, like the main listener above and for a
# stronger reason: this endpoint has no authentication of its
# 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 = "tcp://127.0.0.1:${toString cfg.metricsPort}";
address = "unix://${metricsSocket}?umask=0007";
};
# `watch` is load-bearing, not a convenience: authelia reads

View file

@ -692,7 +692,7 @@ let
in
url autheliaNotColocated == "${autheliaNotColocated.services.hyperhive.swarm.authelia.url}/bridge"
&& url autheliaColocated == url autheliaNotColocated
&& location.proxyPass == "http://127.0.0.1:${toString authelia.bridgePort}/";
&& location.proxyPass == "http://unix:/run/swarm-authelia/bridge.sock:/";
}
{
# Config NAMES the IdP; it never computes where the IdP is. Both arms

View file

@ -236,7 +236,7 @@ let
}
{
# The location the collector's job requests: gated, and proxying to the
# loopback listener that has no authentication of its own.
# socket listener that has no authentication of its own.
name = "authelia's vhost serves /metrics only behind the collector's auth subrequest";
ok =
let
@ -244,7 +244,7 @@ let
locs = autheliaOnly.services.nginx.virtualHosts.${h.swarm.authelia.domain}.locations;
authz = locs."= /__authelia_metrics_authz";
in
locs."= /metrics".proxyPass == "http://127.0.0.1:${toString h.swarm.authelia.metricsPort}/metrics"
locs."= /metrics".proxyPass == "http://unix:/run/swarm-authelia/metrics.sock:/"
&& lib.hasInfix "auth_request /__authelia_metrics_authz;" locs."= /metrics".extraConfig
&& authz.proxyPass == "https://${h.swarm.authelia.domain}/api/authz/auth-request"
&& lib.hasInfix "internal;" authz.extraConfig;

View file

@ -171,7 +171,7 @@ let
# Every service container at once: the services-only host plus the CI
# runner. The CI runner, the review classifier, the builder, the claude login
# minter, the metrics store and the homeserver are the six containers on a
# minter, the metrics store, the homeserver and authelia are the seven containers on a
# netns of their own.
serviceContainersWithCi = hive {
deploy.hive-controller.enable = false;
@ -599,7 +599,7 @@ let
let
shared = lib.filter (c: !c.privateNetwork) (lib.attrValues serviceContainers);
in
lib.length shared == 7
lib.length shared == 6
&& lib.all (
c: !c.config.networking.firewall.enable && !c.config.networking.resolvconf.enable
) shared;
@ -694,6 +694,51 @@ let
&& unit.serviceConfig.Group == "gateway-nginx"
&& lib.elem dir unit.serviceConfig.ReadWritePaths;
}
{
# On the bridge by DHCP, with all three listeners on sockets: nothing
# the bridge can dial. Every vhost location that forwards goes through
# one of the sockets, except the internal authz subrequest, which is
# `https://`. The main, bridge and metrics locations are named so an
# empty filter can't pass.
name = "authelia runs on a netns of its own and the gateway reaches all three listeners over sockets";
ok =
let
host = serviceContainersWithCi;
net = host.services.hyperhive.network;
c = host.containers.swarm-authelia;
dir = "/run/swarm-authelia";
sock = n: "${dir}/${n}.sock";
locs = host.services.nginx.virtualHosts.${host.services.hyperhive.swarm.authelia.domain}.locations;
forwarded = lib.filter (p: p != null && !(lib.hasPrefix "https://" p)) (
map (l: l.proxyPass) (lib.attrValues locs)
);
inner = c.config;
settings = inner.services.authelia.instances.swarm.settings;
unit = inner.systemd.services.authelia-swarm;
bridge = inner.systemd.services.swarm-authelia-bridge;
nginxGid = host.ids.gids.nginx;
in
c.privateNetwork
&& c.hostBridge == net.bridgeName
&& c.hostAddress == null
&& c.localAddress == null
&& inner.networking.interfaces.eth0.useDHCP
&& inner.networking.firewall.enable
&& settings.server.address == "unix://${sock "authelia"}?umask=0007"
&& settings.telemetry.metrics.address == "unix://${sock "metrics"}?umask=0007"
&& bridge.environment.SWARM_AUTHELIA_BRIDGE_SOCKET == sock "bridge"
&& !(bridge.environment ? SWARM_AUTHELIA_BRIDGE_BIND)
&& locs."/".proxyPass == "http://unix:${sock "authelia"}:/"
&& locs."/bridge/".proxyPass == "http://unix:${sock "bridge"}:/"
&& locs."= /metrics".proxyPass == "http://unix:${sock "metrics"}:/"
&& lib.all (lib.hasPrefix "http://unix:${dir}/") forwarded
&& c.bindMounts.${dir}.hostPath == dir
&& builtins.elem "d ${dir} 0770 root ${toString nginxGid} - -" host.systemd.tmpfiles.rules
&& inner.users.groups.gateway-nginx.gid == nginxGid
&& unit.serviceConfig.Group == "gateway-nginx"
&& bridge.serviceConfig.Group == "gateway-nginx"
&& lib.elem dir unit.serviceConfig.ReadWritePaths;
}
{
# The DynamicUser layout keeps the history under /var/lib/private; the
# static user's StateDirectory is the plain path. The move runs as its