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

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