diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index 6a991e6a..48d30bc7 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -13,8 +13,8 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | URL | upstream | declared by, when | | --- | --- | --- | | `/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-controller.enable` | -| `auth./` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` | -| `auth./bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` | +| `auth./` | authelia (unix socket `/run/swarm-authelia/authelia.sock`) | `swarm-authelia.nix`, `deploy.authelia.enable` | +| `auth./bridge/*` | swarm-authelia-bridge (unix socket `/run/swarm-authelia/bridge.sock`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` | | `forge./` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` | | `chat./_matrix/*` | tuwunel (unix socket `/run/hive-matrix/tuwunel.sock`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` | | `chat./` | fluffychat-web static | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` | diff --git a/docs/networking/network.md b/docs/networking/network.md index 658f8f7a..8475a758 100644 --- a/docs/networking/network.md +++ b/docs/networking/network.md @@ -165,7 +165,7 @@ agent containers. UI proxies, and any other HTTP services. The **host** firewall is the only firewall. The swarm service -containers that share the host netns (forge, authelia, bao, the +containers that share the host netns (forge, bao, the metrics and log stores) run with `networking.firewall.enable = false` (`nix/container-modules/swarm-container.nix`): a NixOS firewall inside a shared-netns container runs against the _host_ ruleset — at container diff --git a/docs/swarm/services.md b/docs/swarm/services.md index d93a87ee..f4796158 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -106,6 +106,12 @@ One authelia per swarm, in a `swarm-authelia` container, at provider, differentiated by roles and claims rather than by mechanism — there is one IdP and one auth path. +The container sits on the hive bridge in a network namespace of its own and +dials the store, the collector and its own public name through it. It binds +no TCP port: the login server, the metrics listener and the bridge each +listen on a unix socket in `/run/swarm-authelia`, a directory only the +gateway's nginx and the container's processes can open. + - **`deploy.authelia`** — run the container here. - **`swarm.authelia.url`** — where clients go to authenticate. Present on **every** hive and the same value on all of them: @@ -117,7 +123,7 @@ Agent subjects come from swarm-controller's agent-creation job, written into the users database by `swarm-authelia-bridge`; human ones come from `swarmctl user add` → [setup.md § 2](../getting-started/setup.md#2--your-sso-account). -The bridge listens on loopback on the authelia host, and the gateway +The bridge listens on a unix socket on the authelia host, and the gateway publishes it as `https:///bridge/`, which is where `deploy.swarm-controller.authBridgeUrl` points by default on every host. diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index fd2f83ad..fd033311 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -12,7 +12,7 @@ separate `enable` would be a second fact free to disagree with the first. ## Getting in the first time -authelia binds loopback only. The **gateway** on the host running it +authelia binds no TCP port: its listeners are unix sockets only the gateway can reach. The **gateway** on the host running it publishes it as `auth.` — vhost, dnsmasq record and TLS name all follow `deploy.authelia`, so there is nothing to turn on separately. (Details, including why a client hive must not declare that diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 5bb87489..e29401f7 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -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" diff --git a/nix/host-modules/swarm-authelia-service.nix b/nix/host-modules/swarm-authelia-service.nix index b884e5ca..9f3780d3 100644 --- a/nix/host-modules/swarm-authelia-service.nix +++ b/nix/host-modules/swarm-authelia-service.nix @@ -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; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 436a8b14..2de6e8e5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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:. - 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 diff --git a/nix/module-eval/nats-authelia.nix b/nix/module-eval/nats-authelia.nix index 026765d2..29fab0ea 100644 --- a/nix/module-eval/nats-authelia.nix +++ b/nix/module-eval/nats-authelia.nix @@ -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 diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index 48b027b5..9394c02d 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -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; diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 0b0b7f24..5b319322 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -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 diff --git a/swarm-authelia-bridge/README.md b/swarm-authelia-bridge/README.md index 8a189756..ef29bde4 100644 --- a/swarm-authelia-bridge/README.md +++ b/swarm-authelia-bridge/README.md @@ -33,7 +33,8 @@ simply owns the file it writes. No elevated privilege anywhere. - No restart of authelia after a write — relies on `authentication_backend.file.watch`, confirmed working against the pinned 4.39.20 build during this design work. -- Listens on loopback. The authelia vhost proxies `/bridge/` to it with +- Listens on the unix socket named by `SWARM_AUTHELIA_BRIDGE_SOCKET` (mode + `0660`, in a directory shared with the host's nginx). The authelia vhost proxies `/bridge/` to it with the prefix stripped, so `swarm-controller` on any host calls `https:///bridge/requests`. diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index e07252a6..1ea6d6a3 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -11,7 +11,8 @@ //! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see //! `swarmctl/README.md`'s own identical analysis of this same file). //! -//! Listens on loopback; the authelia vhost proxies `/bridge/` to it, so a +//! Listens on a unix socket (`SWARM_AUTHELIA_BRIDGE_SOCKET`) in a directory +//! the host's nginx shares; the authelia vhost proxies `/bridge/` to it, so a //! `swarm-controller` on any host reaches it at the same address. //! Bearer-authenticated via authelia's own OIDC token introspection (RFC //! 7662), and admits only `swarm-controller`'s **existing** machine-client @@ -45,7 +46,7 @@ use swarm_authelia_bridge_sock::{AgentIdentity, BridgeRequest, BridgeResponse}; /// `swarm-controller`'s (the token being checked). Set by the nix module /// that provisions this bridge's own client alongside authelia. struct Config { - bind: String, + socket: std::path::PathBuf, users_file: std::path::PathBuf, authelia_bin: std::path::PathBuf, introspection_url: String, @@ -60,7 +61,7 @@ struct Config { impl Config { fn from_env() -> Result { Ok(Self { - bind: env_var("SWARM_AUTHELIA_BRIDGE_BIND")?, + socket: env_var("SWARM_AUTHELIA_BRIDGE_SOCKET")?.into(), users_file: env_var("SWARM_AUTHELIA_BRIDGE_USERS_FILE")?.into(), authelia_bin: env_var("SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN")?.into(), introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?, @@ -116,7 +117,7 @@ async fn main() -> Result<()> { .init(); let config = Config::from_env()?; - let bind = config.bind.clone(); + let socket = config.socket.clone(); let state = Arc::new(AppState { config, http: reqwest::Client::new(), @@ -127,15 +128,32 @@ async fn main() -> Result<()> { .route("/requests", post(handle_request)) .with_state(state); - let listener = tokio::net::TcpListener::bind(&bind) - .await - .with_context(|| format!("binding {bind}"))?; - tracing::info!(%bind, "swarm-authelia-bridge listening"); + let listener = bind_socket(&socket)?; + tracing::info!(socket = %socket.display(), "swarm-authelia-bridge listening"); axum::serve(listener, app) .await .context("serving swarm-authelia-bridge") } +/// Bind the listening socket at `path`, replacing a stale one a crash left +/// behind (it would otherwise fail `bind` with `EADDRINUSE`). +/// +/// The mode is `0660`: `connect(2)` needs write permission, and the +/// directory's group decides who can reach the socket at all. +fn bind_socket(path: &std::path::Path) -> Result { + use std::os::unix::fs::PermissionsExt as _; + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("removing stale {}", path.display())), + } + let listener = tokio::net::UnixListener::bind(path) + .with_context(|| format!("binding {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .with_context(|| format!("setting the mode of {}", path.display()))?; + Ok(listener) +} + /// The single endpoint — body is the wire-crate's [`BridgeRequest`] /// verbatim (JSON), not a per-operation REST route. That is what let the /// second operation arrive as a match arm rather than as another route @@ -448,7 +466,8 @@ async fn generate_password(bin: &std::path::Path) -> Result { #[cfg(test)] mod tests { use super::{ - BridgeRequest, Config, Refusal, StatusCode, classify, handle, introspect::Verdict, respond, + BridgeRequest, Config, Refusal, StatusCode, bind_socket, classify, handle, + introspect::Verdict, respond, }; const CONTROLLER: &str = "swarm-controller"; @@ -565,7 +584,7 @@ mod tests { ) .unwrap(); Config { - bind: String::new(), + socket: std::path::PathBuf::new(), users_file, authelia_bin: std::path::PathBuf::new(), introspection_url: String::new(), @@ -576,6 +595,21 @@ mod tests { } } + #[tokio::test] + async fn the_socket_replaces_a_stale_one_and_is_group_only() { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("bridge-sock-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bridge.sock"); + // Dropping a listener leaves its file behind: the state a crash leaves. + drop(bind_socket(&path).unwrap()); + assert!(path.exists(), "control: the stale socket is on disk"); + let _listener = bind_socket(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o660); + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn the_operator_list_is_the_operator_groups_members() { let config = config_with_one_operator();