diff --git a/Cargo.lock b/Cargo.lock index c4c5b8ed..bae5012b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4555,29 +4555,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "swarm-authelia-bridge" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "reqwest 0.13.1", - "serde", - "serde_json", - "swarm-authelia-bridge-sock", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "swarm-authelia-bridge-sock" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "swarm-controller" version = "0.1.0" @@ -4588,10 +4565,8 @@ dependencies = [ "futures-util", "hive-jobq", "hive-jobq-wire", - "reqwest 0.13.1", "serde", "serde_json", - "swarm-authelia-bridge-sock", "swarm-queue-client", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index cd4cfce6..2f0ff283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,6 @@ members = [ "hive-sock-client", "hive-types", "hivectl", - "swarm-authelia-bridge", - "swarm-authelia-bridge-sock", "swarm-controller", "swarm-nats-auth", "swarm-queue-client", @@ -87,7 +85,6 @@ hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } -swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-queue-client = { path = "swarm-queue-client" } thiserror = "2" tower-http = { version = "0.7", features = ["fs"] } diff --git a/flake.nix b/flake.nix index cefc5a2f..1c7a6f53 100644 --- a/flake.nix +++ b/flake.nix @@ -154,9 +154,6 @@ services.hyperhive.swarm.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; - services.hyperhive.swarm.authelia.bridgePackage = - lib.mkDefault - self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; services.hyperhive.gateway.swaggerUiTheme = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 812e80b0..4bc72f10 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -11,18 +11,16 @@ # # Operator and agents are both subjects of the same provider, # differentiated by roles/claims rather than by mechanism — there is one -# IdP and one auth path. The users store is written by a program -# (`swarm-authelia-bridge`, see that option's doc comment), not -# maintained by hand: agents are created and destroyed continuously, so -# the subject set is *dynamic*. That is also why the file backend is -# right here, not a placeholder for LDAP: what makes a directory -# necessary is the size of the subject set, bounded by one swarm. +# IdP and one auth path. The users store is therefore written by a +# program (swarm-controller), not maintained by hand: agents are created +# and destroyed continuously, so the subject set is *dynamic*. That is +# also why the file backend is the right one here and not a placeholder +# for LDAP: what makes a directory necessary is the size of the subject +# set, and this deployment's is bounded by one swarm. # # Two roles: a **session** provider always, an **OIDC** provider when -# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC -# is always on now: the bridge needs its own machine-client identity for -# its introspection calls, contributed unconditionally, not behind -# `oidc.hiveIdentities`. Secrets map: docs/swarm/sso.md. +# `oidc.clients` is non-empty (derived, not flagged — authelia will not +# start with a clientless provider). Secrets map: docs/swarm/sso.md. # # Per-service integration — putting authelia's `auth_request` in front # of the gateway's existing `auth_basic` locations — is deliberately NOT @@ -96,33 +94,17 @@ let redirectUris = [ ]; }) hyperhiveCfg.swarm.hives; - # `swarm-authelia-bridge`'s own identity — distinct from - # `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A - # resource server introspecting a token proves its OWN identity to the - # IdP (RFC 7662), separately from whichever principal's token it is - # checking, so the bridge needs a client even though it never presents - # a token itself. Contributed unconditionally below (not gated behind - # `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the - # bridge is a core, always-present part of this module, not an opt-in - # consumer — see `usersFile`'s doc comment. - bridgeClientId = "swarm-authelia-bridge"; - bridgeClient = { - id = bridgeClientId; - description = "HyperHive swarm-authelia-bridge (users-database writer)"; - kind = "machine"; - redirectUris = [ ]; - }; - # authelia refuses to start with an OIDC provider that has no clients, # so the provider is derived from the client list rather than carrying - # its own `enable`: one fact, and it cannot contradict itself. + # its own `enable`: one fact, and it cannot contradict itself. An empty + # list is the default, which makes every hive that has not opted in + # byte-identical to before. # - # ⚠️ In practice this is now unconditionally `true` whenever the module - # is enabled: `bridgeClient` above is an unconditional definition of - # `oidc.clients` (see the `config` block), so the list is never empty. - # Kept as a derived boolean rather than simplified to a literal `true` - # so the OIDC-gated code below stays self-documenting about WHY it is - # conditional, not just that it happens to always be on today. + # ⚠️ The derived hive identities are definitions of this same option, + # so they can turn the provider on by themselves. That is only + # reachable where the queue is already enabled (`hiveIdentities` + # defaults to it) — and a queue-enabled hive already contributes a + # client, so no existing deployment flips. oidcEnabled = cfg.oidc.clients != [ ]; # Secrets that are 64 random bytes of hex and nothing more. The OIDC @@ -353,15 +335,12 @@ in description = '' Path (inside the container) of authelia's file users database. - Written by `swarm-authelia-bridge`, not by hand: agents come and - go continuously, so the subject set is dynamic and belongs to a - program. `swarm-controller` cannot write this file itself — a - different uid owns it — so the bridge is the only writer, - running inside this same container as this file's actual owner. - This module only guarantees the file *exists* and is valid YAML - at first boot, so authelia starts with no subjects rather than - failing to start — a provider with nobody in it yet is the - correct state before anything has provisioned users. + Written by swarm-controller, not by hand: agents come and go + continuously, so the subject set is dynamic and belongs to a + program. This module only guarantees the file *exists* and is + valid YAML at first boot, so authelia starts with no subjects + rather than failing to start — a provider with nobody in it yet + is the correct state before anything has provisioned users. ''; }; @@ -559,58 +538,11 @@ in `usersFile` as seen from the **host** — the container's root prefixed onto the path authelia sees. - Published for callers that only ever need to *read* the file - (e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge` - itself never uses this path — it runs inside the container, as - the file's own owner, and writes the in-container path directly. - ''; - }; - - bridgePackage = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge"; - description = '' - `swarm-authelia-bridge` package — the only process allowed to - write `usersFile`. Wired by default from this flake's own - package set (see `flake.nix`); override to run a different - build. - ''; - }; - - 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. - - Reachable directly from this host's other processes (this - container shares the host netns, same as authelia's own `port`) - without going through the gateway — this is an internal - service-to-service endpoint, not something meant to be exposed - publicly. - ''; - }; - - bridgeUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - readOnly = true; - default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null; - defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null''; - description = '' - Where `swarm-authelia-bridge` answers, **as seen from this - host** — correct only when a caller (`swarm-controller`) also - runs on this host, the same co-location assumption - `swarm.nix`'s `clientSecretFile` documents for its own - cross-host case. `null` when this host doesn't run - `swarm-authelia` at all. - - A split-host swarm has no automated delivery for this address: - the operator points `swarm-controller`'s own option at wherever - this host has made the bridge reachable (a firewall rule, a - different bind address), the same manual-copy shape used - throughout this codebase's other cross-host cases. + The distinction is load-bearing: the users database is written + from the host by a program that does not live in this container, + while authelia only ever sees the inner path. Handing the wrong + one to either side yields a file nobody reads rather than an + error. ''; }; }; @@ -618,16 +550,11 @@ in config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { # The derived half of the client list, declared the same way an # operator declares one. Everything downstream then reads a single - # uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart — + # uniformly-typed `cfg.oidc.clients` and cannot tell the two apart — # including the assertions below, which is why a hive named `x` # colliding with a declared `hive-x` is caught rather than rendered - # twice. `bridgeClient` is unconditional (plain list concatenation, - # not `mkIf`-gated like `hiveClients`): the bridge is always present - # wherever this module is, so `oidc.clients` is never actually empty - # — see `oidcEnabled`'s comment above. - services.hyperhive.swarm.authelia.oidc.clients = - lib.optionals cfg.oidc.hiveIdentities hiveClients - ++ [ bridgeClient ]; + # twice. + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf cfg.oidc.hiveIdentities hiveClients; # A redirect URI on a machine client is not harmless-but-unused: it # means whoever wrote it believes a browser is involved. Failing here @@ -826,68 +753,6 @@ in ''; }; - # The only process allowed to write `cfg.usersFile` — see that - # option's doc comment, and the crate's own README for the full - # "why does an unprivileged swarm-controller need a bridge at - # all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE - # point of this whole unit: it is that same account, so it - # owns the file it writes and needs no elevated privilege. - # Ordered after authelia's own secrets generator (needs its - # own client secret, minted by that unit's `mint` loop) and - # after authelia itself (introspects against its local - # `/api/oidc/introspection`, so needs it answering — not - # load-bearing at start, since nothing calls the bridge yet at - # boot, but a clean dependency order beats a would-be-transient - # failure on the first real request). - systemd.services.swarm-authelia-bridge = { - description = "swarm-authelia-bridge: the only writer of authelia's users database"; - wantedBy = [ "multi-user.target" ]; - after = [ - "${unitName}-secrets.service" - "${unitName}.service" - ]; - wants = [ - "${unitName}-secrets.service" - "${unitName}.service" - ]; - serviceConfig = { - ExecStart = "${cfg.bridgePackage}/bin/swarm-authelia-bridge"; - User = unitName; - Group = unitName; - Restart = "on-failure"; - RestartSec = "5s"; - }; - environment = { - SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}"; - # Own canonical store, alongside authelia's own state — - # NOT `swarm-controller`'s state dir: the two processes - # aren't guaranteed to be on the same host, and this store - # has to live wherever its writer (this bridge) does. See - # `swarm-authelia-bridge/README.md`'s "known limitation" - # section for the resulting `swarmctl`-owns-a-second-store - # seam. - SWARM_AUTHELIA_BRIDGE_STORE = "${stateDir}/swarm-authelia-bridge-users.json"; - SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile; - # The CONFIGURED authelia, not whatever is on `PATH`: the - # argon2 parameters baked into a hash have to match the - # verifier's — same reasoning as `swarmctl`'s own - # `SWARMCTL_AUTHELIA_BIN`. - SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${cfg.package}/bin/authelia"; - # Local loopback, not the public HTTPS vhost: this process - # runs right next to authelia (same container, same netns), - # so there is a faster, simpler path than round-tripping - # through the gateway's nginx for a call nothing external - # ever needs to see. - SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "http://127.0.0.1:${toString cfg.port}/api/oidc/introspection"; - SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId; - # Minted by `${unitName}-secrets`'s `mint` loop (it iterates - # every entry in `cfg.oidc.clients`, which now always - # includes `bridgeClient`) — same file this container's own - # `renderClient` reads the digest half of. - SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret"; - }; - }; - services.authelia.instances.${instance} = { enable = true; package = cfg.package; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index a17d1242..4f6ff103 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -87,14 +87,6 @@ let SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token"; }; - # Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check - # is satisfied by THIS daemon's existing queue OIDC identity - # (`queueEnv` above): "one identity per principal" already covers this, - # so there is nothing new to mint or copy, just the bridge's address. - authBridgeEnv = lib.optionalAttrs (cfg.authBridgeUrl != null) { - SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl; - }; - # Wrapped rather than documented: every one of these values is derived # from an option this deployment already set, so making the operator # re-supply them on the command line would be asking them to repeat the @@ -333,30 +325,6 @@ in graceful-absence shape the queue coordinates already use. ''; }; - - authBridgeUrl = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = if autheliaCfg.enable then autheliaCfg.bridgeUrl else null; - defaultText = lib.literalExpression '' - authelia's own `bridgeUrl` when this host also runs - `swarm-authelia`, else null - ''; - example = "http://127.0.0.1:9092"; - description = '' - Where `swarm-authelia-bridge` (the only writer of authelia's - users database) answers — see that option's own doc comment for - the cross-host caveat, since this default is only correct when - this host also runs `swarm-authelia`. - - No new credential to configure: the bearer token presented to - the bridge is minted from THIS daemon's own existing queue OIDC - identity (`queue.*` above) — "one identity per principal" - already covers it. `null` means no agent-identity support: - `CreateIdentity` jobs fail with a clear "no auth bridge - configured here" error rather than the daemon refusing to - start, the same graceful-absence shape `forgeTokenFile` uses. - ''; - }; }; config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { @@ -512,16 +480,15 @@ in ]; }; - # Queue coordinates (`queueEnv`), forge coordinates (`forgeEnv`), and - # the auth-bridge address (`authBridgeEnv`) merge in last. The - # daemon refuses a PARTIAL set of any of them rather than treating - # it as absent, which is why each is built as one attrset and never - # assigned individually. + # Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`) + # merge in last. The daemon refuses a PARTIAL set of either rather + # than treating it as absent, which is why each is built as one + # attrset and never assigned individually. # # They differ in how absence is prevented: the queue's is checked by # the assertions above, because a controller without a queue is - # broken rather than lighter; forge's and the auth bridge's are - # genuinely optional and stay gated on their own option resolving. + # broken rather than lighter; the forge's is genuinely optional and + # stays gated on `forgeTokenFile` resolving. environment = { SWARM_CONTROLLER_SOCKET = cfg.socketPath; # The swarm's hive directory, JSON-encoded — the full directory @@ -547,8 +514,7 @@ in SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; } // queueEnv - // forgeEnv - // authBridgeEnv; + // forgeEnv; }; # A systemd credential is a SNAPSHOT: it is materialised into `%d` once, diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 00954079..d110005c 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -156,12 +156,6 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; - # The only process allowed to write swarm-authelia's users database — - # same "runs *inside* a container, not on the host" placement as - # `swarm-nats-auth` above (this one lives in `swarm-authelia`'s - # container, as authelia's own user, not the host's closure). - swarm-authelia-bridge = mkBinPackage "swarm-authelia-bridge" "hyperhive swarm-authelia users-database write bridge"; - # The swarm operator's CLI, out of `daemonBins` for the same reason as # the daemon above: it is installed by the swarm-controller module on # the one host that runs the controller, and belongs in that hive's diff --git a/swarm-authelia-bridge-sock/Cargo.toml b/swarm-authelia-bridge-sock/Cargo.toml deleted file mode 100644 index 5f86c108..00000000 --- a/swarm-authelia-bridge-sock/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "swarm-authelia-bridge-sock" -version.workspace = true -edition.workspace = true -readme = "README.md" - -[dependencies] -serde.workspace = true - -[dev-dependencies] -serde_json.workspace = true - -[lints] -workspace = true diff --git a/swarm-authelia-bridge-sock/README.md b/swarm-authelia-bridge-sock/README.md deleted file mode 100644 index d15c417b..00000000 --- a/swarm-authelia-bridge-sock/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# swarm-authelia-bridge-sock - -Wire types for the **`swarm-authelia-bridge` socket** — the contract between -`swarm-authelia-bridge` (server, runs alongside `swarm-authelia`) and -`swarm-controller` (client). - -## Why it's its own crate - -Same rationale as `hive-priv-sock` (which this mirrors in spirit, though the -transport differs — this bridge is network-facing HTTP, not a unix socket, -since it has to reach a possibly-split-host `swarm-controller`): the bridge -is a narrowly-scoped, unprivileged-but-file-owning helper, and splitting the -wire contract out of any larger crate keeps both its own dependency -footprint and its interface small enough to audit at a glance. No server or -client logic here, only the request/response shapes both sides import. - -## Shape - -One operation today: idempotently ensure an agent exists as an authelia -subject. Deliberately **not** a wholesale-replace-the-file API — the bridge -owns both `users.json` (canonical) and rendering `users.yml` internally; a -caller only ever asks for one user to exist, never sends rendered YAML or a -file blob. See `swarm-authelia-bridge/README.md` for the helper itself. diff --git a/swarm-authelia-bridge-sock/src/lib.rs b/swarm-authelia-bridge-sock/src/lib.rs deleted file mode 100644 index c0fb0b60..00000000 --- a/swarm-authelia-bridge-sock/src/lib.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Wire types for the `swarm-authelia-bridge` socket. -//! -//! Both `swarm-authelia-bridge` (server) and `swarm-controller` (client) -//! import these so the shapes stay in sync. No server or client protocol -//! logic lives here, only the JSON contract carried as the body of the -//! bridge's one HTTP endpoint (`POST /requests`, bearer-authenticated) — -//! see `swarm-authelia-bridge`'s own docs for the transport. -//! -//! # Why a bridge at all, and why this shape -//! -//! `swarm-authelia`'s users database (`users.yml`) is owned by the -//! `authelia-swarm` system user, a different uid than `swarm-controller`'s -//! own — so `swarm-controller` cannot write it directly without either root -//! (`CAP_CHOWN`) or a shared group, both rejected for the same reason -//! `swarmctl`'s own README already rejected them for this exact file. The -//! fix taken instead: run this bridge's own -//! systemd unit as `User = "authelia-swarm";` — the literal name -//! `swarm-authelia.nix` already derives, resolved by systemd at start, no -//! numeric uid ever hand-pinned into nix eval — so the bridge simply *owns* -//! the file it writes. Fully ordinary permissions, no capabilities, no root. -//! -//! **Per-operation, not wholesale-replace.** [`BridgeRequest::EnsureAgentIdentity`] -//! asks for one user to exist; the bridge owns both `users.json` (canonical) -//! and rendering `users.yml` internally. A caller never sends rendered YAML -//! or a file blob — that would invite a last-writer-wins race between -//! independent callers and duplicate the rendering logic on both sides of -//! the wire. - -use serde::{Deserialize, Serialize}; - -/// A request to the bridge. One variant today — see the module doc for why -/// this isn't a file-replace API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum BridgeRequest { - /// Idempotently ensure `name` exists as an authelia subject — - /// `swarm-controller`'s `SwarmNodeKind::CreateIdentity` node's entire - /// job. Idempotence is load-bearing (agent creation's own design - /// consensus): re-running this for an agent that already has an - /// identity is a - /// genuine no-op, reported as [`BridgeResponse::AlreadyExists`] — no - /// password re-mint, no `users.yml` rewrite, nothing for authelia's - /// `file.watch` to react to. - EnsureAgentIdentity { - /// The agent's name — becomes the authelia username verbatim. The - /// bridge validates this server-side (same conservative charset - /// `swarmctl::users::validate_username` already enforces); this - /// crate carries the wire shape only, not the validation rule. - name: String, - }, -} - -/// The bridge's answer to a [`BridgeRequest`]. -/// -/// `#[serde(tag = "status")]` rather than a bare `Result`-shaped wrapper: an -/// external tag reads directly as one of three named outcomes on the wire -/// (`{"status":"created",...}`), with no separate "was this an error" -/// boolean to keep in sync with which variant it is. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum BridgeResponse { - /// The agent had no identity yet; one was minted and `users.yml` was - /// rewritten. - Created, - /// The agent already had an identity. No write happened — the - /// idempotent no-op path. - AlreadyExists, - /// The request was rejected or the write failed. Carries a message - /// for the caller to log/propagate, not a typed error enum: the - /// failure modes here (bad username, authelia binary failed, disk - /// full) have no caller-actionable distinction today — see - /// `PrivResponse` in `hive-priv-sock` for the same reasoning. - Error { message: String }, -} - -#[cfg(test)] -mod tests { - use super::{BridgeRequest, BridgeResponse}; - - /// Pins the external-tag wire shape — a reader off the wire (or a log - /// line) should be able to tell the three outcomes apart without - /// cross-referencing this crate's source. - #[test] - fn response_variants_tag_on_status() { - let created = serde_json::to_value(BridgeResponse::Created).unwrap(); - assert_eq!(created, serde_json::json!({"status": "created"})); - - let exists = serde_json::to_value(BridgeResponse::AlreadyExists).unwrap(); - assert_eq!(exists, serde_json::json!({"status": "already_exists"})); - - let err = serde_json::to_value(BridgeResponse::Error { - message: "boom".to_owned(), - }) - .unwrap(); - assert_eq!( - err, - serde_json::json!({"status": "error", "message": "boom"}) - ); - } - - #[test] - fn request_round_trips() { - let req = BridgeRequest::EnsureAgentIdentity { - name: "atlas".to_owned(), - }; - let json = serde_json::to_string(&req).unwrap(); - let back: BridgeRequest = serde_json::from_str(&json).unwrap(); - let BridgeRequest::EnsureAgentIdentity { name } = back; - assert_eq!(name, "atlas"); - } -} diff --git a/swarm-authelia-bridge/Cargo.toml b/swarm-authelia-bridge/Cargo.toml deleted file mode 100644 index 4e7fb493..00000000 --- a/swarm-authelia-bridge/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "swarm-authelia-bridge" -version.workspace = true -edition.workspace = true -readme = "README.md" - -[[bin]] -name = "swarm-authelia-bridge" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -axum.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -swarm-authelia-bridge-sock.workspace = true -tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true - -[lints] -workspace = true diff --git a/swarm-authelia-bridge/README.md b/swarm-authelia-bridge/README.md deleted file mode 100644 index c9d040e6..00000000 --- a/swarm-authelia-bridge/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# swarm-authelia-bridge - -The only thing allowed to write `swarm-authelia`'s users database. - -## Why this exists - -`swarm-controller`'s `CreateIdentity` job needs to add an agent as an -authelia subject, but `swarm-controller` runs unprivileged and does not own -`users.yml` — a different uid does (`authelia-swarm`, the user -`services.authelia.instances.swarm` runs as). Root/`CAP_CHOWN`/a shared -group were all examined and rejected during this crate's design thread — -see `swarmctl/README.md`'s identical analysis of this same file, written -before this crate existed. - -The fix: run **this** process as `User = "authelia-swarm";` instead — -inside the `swarm-authelia` container, alongside authelia itself — so it -simply owns the file it writes. No elevated privilege anywhere. - -## Shape - -- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s - `BridgeRequest` verbatim — one variant today (`EnsureAgentIdentity`, - idempotently ensure an agent exists as an authelia subject). -- Bearer-authenticated via authelia's own OIDC token introspection (RFC - 7662), against `swarm-controller`'s **existing** machine-client identity - (already minted for the queue connection) — no new credential. -- 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. -- Network-facing rather than unix-socket-only: `swarm-authelia`'s container - shares the host netns, so the same listener serves both a co-located - `swarm-controller` (loopback) and a split-host one (bind wider + firewall) - with no separate transport. - -## Known limitation - -`swarmctl` still writes an independent `users.json`/`users.yml` for human -accounts, assuming co-location with `swarm-controller`'s host. Two -canonical stores for the same physical file is a real seam, not solved -here — routing `swarmctl` through this bridge too is a plausible follow-up, -out of scope for the agent-identity slice this crate shipped with. diff --git a/swarm-authelia-bridge/src/introspect.rs b/swarm-authelia-bridge/src/introspect.rs deleted file mode 100644 index 575e28d9..00000000 --- a/swarm-authelia-bridge/src/introspect.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Validating a presented bearer token against authelia. -//! -//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token -//! introspection), not a shared dependency — `swarm-nats-auth` has no lib -//! target to import, and this is a handful of lines; factor out if a third -//! consumer shows up. Same verdict rule: `active` is the whole answer, and -//! everything that isn't an explicit `active: true` is a denial (network -//! error, timeout, non-2xx, unparseable body) — the failure modes of an -//! HTTP call are exactly the conditions an attacker would like this to -//! fall open under. -//! -//! Authenticates the introspection call itself with **this bridge's own** -//! client credentials (a resource server introspecting a token proves its -//! own identity to the `IdP`, per RFC 7662) — a separate authelia machine -//! client from the token being checked (`swarm-controller`'s). - -use std::time::Duration; - -use anyhow::{Context, Result}; -use serde::Deserialize; - -/// Generous relative to `swarm-nats-auth`'s 1.5s (that one is bounded by -/// the NATS server's own 2s `auth_callout` timeout; an HTTP request here -/// has no such external deadline to stay under), but still bounded — a -/// hung introspection call must not wedge a `CreateIdentity` job forever. -pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5); - -#[derive(Debug, Deserialize)] -struct IntrospectionResponse { - active: bool, -} - -/// Ask authelia whether `token` is currently valid. `Ok(true)` only on a -/// 2xx body with `active: true`; every other outcome is `Ok(false)` -/// (logged) or `Err` when the call could not be made at all — callers -/// must treat both as a denial. -pub async fn is_active( - http: &reqwest::Client, - url: &str, - client_id: &str, - client_secret: &str, - token: &str, -) -> Result { - let resp = http - .post(url) - .basic_auth(client_id, Some(client_secret)) - .form(&[("token", token)]) - .timeout(INTROSPECTION_TIMEOUT) - .send() - .await - .context("introspection request")?; - - let status = resp.status(); - if !status.is_success() { - tracing::warn!(%status, "introspection returned non-2xx; denying"); - return Ok(false); - } - let body: IntrospectionResponse = match resp.json().await { - Ok(b) => b, - Err(e) => { - tracing::warn!(error = ?e, "introspection body did not parse; denying"); - return Ok(false); - } - }; - Ok(body.active) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_active_true_deserializes_to_a_grant() { - let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap(); - assert!(yes.active); - let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap(); - assert!(!no.active); - assert!(serde_json::from_str::(r#"{"sub":"someone"}"#).is_err()); - } -} diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs deleted file mode 100644 index 82029a25..00000000 --- a/swarm-authelia-bridge/src/main.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! `swarm-authelia-bridge` — the only thing allowed to write -//! `swarm-authelia`'s users database. -//! -//! Runs **inside the `swarm-authelia` container**, as -//! `User = "authelia-swarm";` (the literal name -//! `swarm-authelia.nix`'s `unitName` already derives) — the same user -//! authelia's own instance runs as, and therefore the file's actual -//! owner. That is the whole answer to "how does an unprivileged -//! `swarm-controller` write a file it doesn't own": it doesn't — this -//! process does, sidestepping the uid boundary instead of bridging it -//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see -//! `swarmctl/README.md`'s own identical analysis of this same file). -//! -//! Network-facing, not unix-socket-only: `swarm-authelia`'s container -//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/ -//! `hive-matrix`), so one listener serves a co-located `swarm-controller` -//! (loopback) and a split-host one (bind wider, firewall it) with the -//! same code path — no separate transport for the cross-host case. -//! Bearer-authenticated via authelia's own OIDC token introspection (RFC -//! 7662) against `swarm-controller`'s **existing** machine-client -//! identity (already minted for the queue connection) — no new -//! credential to mint or deliver, and always local/fast to verify since -//! this process runs right next to the authelia it asks. -//! -//! One endpoint, `POST /requests`, body = -//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim — one variant -//! today (`EnsureAgentIdentity`, idempotently ensure an agent exists as -//! an authelia subject). Not a wholesale-replace-the-file API — this -//! process owns rendering `users.yml` internally; see `store` module. - -mod introspect; -mod store; - -use std::sync::Arc; - -use anyhow::{Context, Result, bail}; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::routing::post; -use axum::{Json, Router}; -use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse}; - -/// Where the introspecting bearer token is authenticated *to* — this -/// bridge's own authelia machine client, distinct from -/// `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, - store_path: std::path::PathBuf, - users_file: std::path::PathBuf, - authelia_bin: std::path::PathBuf, - introspection_url: String, - client_id: String, - client_secret: String, -} - -impl Config { - fn from_env() -> Result { - Ok(Self { - bind: env_var("SWARM_AUTHELIA_BRIDGE_BIND")?, - store_path: env_var("SWARM_AUTHELIA_BRIDGE_STORE")?.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")?, - client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?, - client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?, - }) - } -} - -fn env_var(name: &str) -> Result { - std::env::var(name).with_context(|| format!("{name} is unset")) -} - -fn read_secret_file(path: &str) -> Result { - let raw = - std::fs::read_to_string(path).with_context(|| format!("reading secret file {path}"))?; - let trimmed = raw.trim(); - if trimmed.is_empty() { - bail!("secret file {path} is empty"); - } - Ok(trimmed.to_owned()) -} - -struct AppState { - config: Config, - http: reqwest::Client, - /// Serializes `handle`'s load → insert → publish sequence. Without this, - /// two `EnsureAgentIdentity` requests landing close together (real: the - /// controller's job worker claims and spawns nodes without waiting for - /// each to finish, and `SwarmResourceKind` declares no resource dep - /// between two `CreateIdentity` jobs, so they run concurrently) both - /// `load_store` the same snapshot, both insert their own agent, and - /// whichever `publish`es second silently drops the first agent's entry — - /// the store is a plain file, not a database with its own concurrency - /// control. `tokio::sync::Mutex`, not `std`'s: held across the `.await`s - /// in `generate_password` and `publish`. - write_lock: tokio::sync::Mutex<()>, -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .init(); - - let config = Config::from_env()?; - let bind = config.bind.clone(); - let state = Arc::new(AppState { - config, - http: reqwest::Client::new(), - write_lock: tokio::sync::Mutex::new(()), - }); - - let app = Router::new() - .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"); - axum::serve(listener, app) - .await - .context("serving swarm-authelia-bridge") -} - -/// The single endpoint — body is the wire-crate's [`BridgeRequest`] -/// verbatim (JSON), not a per-operation REST route. One variant today, -/// but this is what keeps a second operation from needing a new route + -/// a new extractor shape: it just becomes a new match arm below. -async fn handle_request( - State(state): State>, - headers: HeaderMap, - Json(req): Json, -) -> Response { - if let Err(resp) = authorize(&state, &headers).await { - return resp; - } - let BridgeRequest::EnsureAgentIdentity { name } = req; - match handle(&state, name).await { - Ok(body) => (StatusCode::OK, Json(body)).into_response(), - Err(e) => { - let detail = format!("{e:#}"); - tracing::warn!(error = %detail, "ensure_identity failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BridgeResponse::Error { message: detail }), - ) - .into_response() - } - } -} - -/// Extract + introspect the bearer token. `Err` carries the response to -/// return outright (401 for anything short of an active token) — kept -/// separate from `handle`'s error path, which is "the op itself failed," -/// a different case from "the caller was never let in." -async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> { - let deny = || { - ( - StatusCode::UNAUTHORIZED, - Json(BridgeResponse::Error { - message: "missing or invalid bearer token".to_owned(), - }), - ) - .into_response() - }; - let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { - return Err(deny()); - }; - let Ok(auth) = auth.to_str() else { - return Err(deny()); - }; - let Some(token) = auth.strip_prefix("Bearer ") else { - return Err(deny()); - }; - match introspect::is_active( - &state.http, - &state.config.introspection_url, - &state.config.client_id, - &state.config.client_secret, - token, - ) - .await - { - Ok(true) => Ok(()), - Ok(false) => Err(deny()), - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), "introspection call failed; denying"); - Err(deny()) - } - } -} - -async fn handle(state: &AppState, name: String) -> Result { - store::validate_username(&name)?; - let cfg = &state.config; - - // Held across the whole load → insert → publish sequence, not just the - // write — two concurrent `EnsureAgentIdentity` calls must not both read - // the same on-disk snapshot before either publishes. See the field's - // doc comment on `AppState::write_lock` for why this is reachable in - // practice, not just theoretically. - let _write_guard = state.write_lock.lock().await; - - let mut user_store = store::load_store(&cfg.store_path, &cfg.users_file)?; - if user_store.users.contains_key(&name) { - return Ok(BridgeResponse::AlreadyExists); - } - let generated = generate_password(&cfg.authelia_bin).await?; - user_store.users.insert( - name.clone(), - store::User { - displayname: name.clone(), - password: generated, - email: None, - groups: Vec::new(), - }, - ); - store::publish(&cfg.store_path, &cfg.users_file, &user_store)?; - tracing::info!(agent = %name, "created authelia identity"); - Ok(BridgeResponse::Created) -} - -/// Mint a password digest via the **configured** authelia — the argon2 -/// parameters baked into a hash have to match the verifier's, same -/// reasoning `swarmctl::generate_password` documents (this is -/// deliberately a fresh async port of that function, not a shared one — -/// `swarmctl` is a separate binary crate with no lib target). -/// -/// `--random`, not `--password `: `/proc//cmdline` is -/// world-readable, so a password on argv would be readable by any local -/// process for the call's lifetime. Only the digest is kept — the -/// plaintext is generated and immediately discarded, since nothing reads -/// it back today (still an open question what an agent's login actually -/// uses this for). -async fn generate_password(bin: &std::path::Path) -> Result { - let out = tokio::process::Command::new(bin) - .args(["crypto", "hash", "generate", "argon2", "--random"]) - .output() - .await - .with_context(|| format!("running {}", bin.display()))?; - if !out.status.success() { - bail!( - "{} failed ({}): {}", - bin.display(), - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?; - stdout - .lines() - .find_map(|line| line.trim().strip_prefix("Digest:")) - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - .with_context(|| { - format!( - "could not parse {}'s output: missing 'Digest:'", - bin.display() - ) - }) -} diff --git a/swarm-authelia-bridge/src/store.rs b/swarm-authelia-bridge/src/store.rs deleted file mode 100644 index 15e8ec9b..00000000 --- a/swarm-authelia-bridge/src/store.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! The canonical user store this bridge owns, and the authelia users -//! database rendered from it. -//! -//! Deliberately its own copy, not shared code with `swarmctl::users` even -//! though the YAML shape is identical (same "factor out later if -//! duplication actually bites" reasoning as `swarm-controller::forge` -//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level -//! design thread). One real difference from `swarmctl`'s copy: this -//! store lives **wherever this -//! bridge runs** (inside the `swarm-authelia` container, alongside -//! `authelia-swarm`'s own state), not under `swarm-controller`'s state -//! dir — the two are not guaranteed to be the same host once -//! `swarm-authelia` and `swarm-controller` split across hosts, and this -//! bridge only ever runs where `swarm-authelia` does. -//! -//! ⚠️ **Known limitation, not solved here**: `swarmctl` still writes its -//! own independent `users.json`/`users.yml` for human accounts, assuming -//! co-location with `swarm-controller`'s host. Two independent canonical -//! stores for the same physical `users.yml` is a real seam — tracked as a -//! follow-up (route `swarmctl` through this bridge too), not attempted in -//! this slice, whose scope is agent identities only. - -use std::collections::BTreeMap; -use std::fmt::Write as _; -use std::fs::{self, File, Permissions}; -use std::io::Write as _; -use std::os::unix::fs::PermissionsExt as _; -use std::path::Path; - -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; - -/// What the `swarm-authelia` module's first-boot unit writes into -/// `users.yml` when there is no database yet — same constant/shape -/// `swarmctl::users::SEED_USERS_FILE` uses, since both write the same -/// physical file format. -pub const SEED_USERS_FILE: &str = "users: {}"; - -/// The canonical store, serialised as JSON. `BTreeMap` for a stable, -/// diffable render — same rationale as `swarmctl::users::UserStore`. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct UserStore { - pub users: BTreeMap, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct User { - pub displayname: String, - /// The argon2 **digest**, never a plaintext password. - pub password: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub email: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub groups: Vec, -} - -/// Same conservative charset `swarmctl::users::validate_username` already -/// enforces — usernames are YAML map keys, log lines, and access-control -/// subjects, so keeping them to plain ASCII means nothing downstream ever -/// has to reason about a name that needs quoting. -pub fn validate_username(name: &str) -> Result<()> { - if name.is_empty() || name.len() > 64 { - bail!("username must be 1..=64 characters, got {}", name.len()); - } - if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) { - bail!("username must start with an ASCII letter or digit: {name:?}"); - } - if let Some(bad) = name - .chars() - .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) - { - bail!("username may only contain [A-Za-z0-9._-], found {bad:?} in {name:?}"); - } - Ok(()) -} - -/// Reject control characters rather than escape them — same reasoning as -/// `swarmctl::users::reject_control_chars`: a display name or email -/// carrying one is a bug or an injection attempt in every real case. -fn reject_control_chars(field: &str, value: &str) -> Result<()> { - if let Some(c) = value.chars().find(|c| c.is_control()) { - bail!( - "{field} contains control character U+{:04X}; refusing to write it", - c as u32 - ); - } - Ok(()) -} - -/// A double-quoted YAML scalar — everything is quoted, including values -/// that would be fine bare, since an argon2 digest alone contains `$`, -/// `=`, `,` and `/`. Control characters are excluded upstream, so `"` and -/// `\` are the complete escape set. -fn quote(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -/// Render the store as authelia's block-style YAML users database. -/// Fallible for the same reason `swarmctl::users::render_yaml` is: it -/// re-validates every value it is about to emit, so "no control character -/// ever reaches the file" is a property of the one path that writes it. -pub fn render_yaml(store: &UserStore) -> Result { - if store.users.is_empty() { - return Ok(format!("{SEED_USERS_FILE}\n")); - } - let mut out = String::from("users:\n"); - for (name, user) in &store.users { - validate_username(name)?; - reject_control_chars("displayname", &user.displayname)?; - reject_control_chars("password digest", &user.password)?; - writeln!(out, " {name}:")?; - writeln!(out, " displayname: {}", quote(&user.displayname))?; - writeln!(out, " password: {}", quote(&user.password))?; - if let Some(email) = &user.email { - reject_control_chars("email", email)?; - writeln!(out, " email: {}", quote(email))?; - } - if !user.groups.is_empty() { - writeln!(out, " groups:")?; - for group in &user.groups { - reject_control_chars("group", group)?; - writeln!(out, " - {}", quote(group))?; - } - } - } - Ok(out) -} - -/// Load the canonical store, or start an empty one if this bridge has -/// never written a user. Same overwrite guard as -/// `swarmctl::load_store`: starting empty means the next write -/// **overwrites** `users_file`, which is only safe when that file is -/// still the untouched first-boot seed. -pub fn load_store(store_path: &Path, users_file: &Path) -> Result { - match fs::read_to_string(store_path) { - Ok(raw) => serde_json::from_str(&raw) - .with_context(|| format!("parsing the user store at {}", store_path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - match fs::read_to_string(users_file) { - Ok(existing) if !is_untouched_seed(&existing) => bail!( - "no user store at {} but {} already holds users — refusing to \ - overwrite it", - store_path.display(), - users_file.display() - ), - Ok(_) | Err(_) => Ok(UserStore::default()), - } - } - Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())), - } -} - -/// Whether an existing `users.yml` is safe to take over — i.e. it is the -/// untouched first-boot seed. Empty counts too: a zero-byte file holds -/// nothing to lose. -fn is_untouched_seed(contents: &str) -> bool { - let trimmed = contents.trim(); - trimmed.is_empty() || trimmed == SEED_USERS_FILE -} - -/// Write the store + the rendered users file. **No restart** (the load- -/// bearing difference from `swarmctl::publish`): this bridge relies on -/// authelia's `authentication_backend.file.watch`, confirmed working -/// against the pinned 4.39.20 build during this design work — a -/// restart would drop every active SSO session, which mara ruled out for -/// agent creation specifically (not a rare, human-initiated event). -pub fn publish(store_path: &Path, users_file: &Path, store: &UserStore) -> Result<()> { - let rendered = render_yaml(store)?; - let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?; - // Store first — if the store lands and the users file doesn't, the - // next run re-renders and repairs it. The other order loses a user. - write_atomic(store_path, &format!("{store_json}\n"))?; - write_atomic(users_file, &rendered) -} - -/// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`, -/// this does **not** need to preserve a foreign owner via `chown` — this -/// process runs as `users_file`'s own owning user (see the module doc: -/// the whole point of this bridge is running as `authelia-swarm`), so the -/// temp file it creates is already correctly owned. Preserves the -/// existing mode, same reasoning as `swarmctl`'s version (conservative -/// default for a file of password hashes). -fn write_atomic(path: &Path, contents: &str) -> Result<()> { - let dir = path - .parent() - .with_context(|| format!("{} has no parent directory", path.display()))?; - fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; - - let name = path - .file_name() - .with_context(|| format!("{} has no file name", path.display()))?; - let tmp = dir.join(format!( - ".{}.swarm-authelia-bridge.tmp", - name.to_string_lossy() - )); - - let mode = fs::metadata(path) - .ok() - .map_or(0o600, |meta| meta.permissions().mode() & 0o7777); - - let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?; - file.write_all(contents.as_bytes()) - .with_context(|| format!("writing {}", tmp.display()))?; - file.sync_all() - .with_context(|| format!("flushing {}", tmp.display()))?; - drop(file); - - fs::set_permissions(&tmp, Permissions::from_mode(mode)) - .with_context(|| format!("setting mode on {}", tmp.display()))?; - fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn user(password: &str) -> User { - User { - displayname: "atlas".to_owned(), - password: password.to_owned(), - email: None, - groups: Vec::new(), - } - } - - #[test] - fn an_empty_store_renders_the_seed_document() { - let out = render_yaml(&UserStore::default()).expect("renders"); - assert_eq!(out, "users: {}\n"); - assert!(is_untouched_seed(&out)); - } - - #[test] - fn an_argon2_digest_survives_quoting() { - let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w=="; - let mut store = UserStore::default(); - store.users.insert("atlas".to_owned(), user(digest)); - let out = render_yaml(&store).expect("renders"); - assert!(out.contains(&format!("password: \"{digest}\""))); - } - - #[test] - fn usernames_outside_the_conservative_set_are_refused() { - for bad in ["", "-leading", "has space", "quote\"d", "sla/sh"] { - assert!( - validate_username(bad).is_err(), - "{bad:?} should be rejected" - ); - } - for good in ["atlas", "svc-agent_1", "a.b"] { - validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}")); - } - } - - #[test] - fn a_control_character_is_refused_not_escaped() { - let mut u = user("$argon2id$x"); - u.displayname = "bad\nname".to_owned(); - let mut store = UserStore::default(); - store.users.insert("atlas".to_owned(), u); - let err = render_yaml(&store).expect_err("must refuse"); - assert!(err.to_string().contains("control character")); - } - - /// `write_atomic` round-trips through a real temp dir — the property - /// under test is the rename-into-place, not just the render. - #[test] - fn publish_writes_both_files_and_the_store_reloads() { - let dir = tempdir(); - let store_path = dir.join("users.json"); - let users_file = dir.join("users.yml"); - fs::write(&users_file, SEED_USERS_FILE).unwrap(); - - let mut store = UserStore::default(); - store.users.insert("atlas".to_owned(), user("$argon2id$x")); - publish(&store_path, &users_file, &store).expect("publish"); - - let reloaded = load_store(&store_path, &users_file).expect("reload"); - assert!(reloaded.users.contains_key("atlas")); - let yaml = fs::read_to_string(&users_file).unwrap(); - assert!(yaml.contains("atlas")); - - fs::remove_dir_all(&dir).ok(); - } - - /// Test-only temp dir under the OS temp root — disposable scratch for - /// one test's lifetime, not durable state (see `state-not-tmp`: this - /// is exactly the legitimate use, not a place we persist anything). - fn tempdir() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!( - "swarm-authelia-bridge-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&dir).unwrap(); - dir - } -} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index b94ebe9f..cb5f2f54 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -26,12 +26,8 @@ futures-util.workspace = true # to serve. hive-jobq.workspace = true hive-jobq-wire.workspace = true -# `auth`'s bridge client — same crate the bridge itself uses to define the -# request/response shape, so the two ends cannot drift. -reqwest.workspace = true serde.workspace = true serde_json.workspace = true -swarm-authelia-bridge-sock.workspace = true # The queue connect (token mint + auth callback + reconnect) is shared with # every other participant - a hive publishing its own status runs the same # code with a different client id. Two copies of credential handling is one diff --git a/swarm-controller/src/auth.rs b/swarm-controller/src/auth.rs deleted file mode 100644 index fcc759ba..00000000 --- a/swarm-controller/src/auth.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Client for `swarm-authelia-bridge` — the only writer of the swarm's -//! authelia users database (see that crate's README for why this daemon -//! cannot write it directly). -//! -//! Authenticated with THIS daemon's own queue OIDC identity -//! (`SWARM_CONTROLLER_OIDC_*`, the same one `swarm-queue-client` mints for -//! the queue connection) — "one identity per principal" means a second -//! op that needs to prove who this process is reuses the identity it -//! already has rather than provisioning a new one. A fresh token is -//! minted per call for the same reason the queue client mints one per -//! connection attempt: no window in which this process holds a token -//! that outlives its intended use. -//! -//! `None` when this deployment did not wire a bridge up — the bridge only -//! exists on hosts that also run `swarm-authelia`, so a controller split -//! from it simply has no identity-creation capability yet -//! (`SwarmNodeKind::CreateIdentity` fails such a job explicitly rather -//! than this module papering over the gap). - -use anyhow::{Context, Result}; -use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse}; - -/// A configured connection to `swarm-authelia-bridge`. -#[derive(Clone)] -pub struct AuthBridge { - http: reqwest::Client, - base_url: String, - queue_cfg: swarm_queue_client::QueueConfig, -} - -impl AuthBridge { - /// Read `SWARM_CONTROLLER_AUTH_BRIDGE_URL`; `Ok(None)` when unset. - /// - /// The queue identity (`SWARM_CONTROLLER_OIDC_*`) is not optional once - /// the bridge URL is set: the nix module sets `queueEnv` unconditionally - /// for every controller (the queue is required, not just co-located - /// service), so a bridge URL with no queue identity to authenticate - /// with is a deployment bug, not an absent-feature case — hence the - /// hard error rather than a second `None`. - pub fn from_env() -> Result> { - let Ok(base_url) = std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL") else { - return Ok(None); - }; - let queue_cfg = swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER") - .context("reading the queue OIDC identity the auth bridge authenticates with")? - .ok_or_else(|| { - anyhow::anyhow!( - "SWARM_CONTROLLER_AUTH_BRIDGE_URL is set but SWARM_CONTROLLER_OIDC_* is \ - not — the bridge is authenticated with this daemon's queue identity, so \ - that identity must exist first" - ) - })?; - Ok(Some(Self { - http: reqwest::Client::new(), - base_url, - queue_cfg, - })) - } - - /// Idempotently ensure `name` exists as an authelia subject. - pub async fn ensure_agent_identity(&self, name: &str) -> Result { - // `mint_token_for` builds its own HTTP client (trusting the queue's - // configured CA, if any) — deliberately not `self.http`, which is - // the bridge's own client and has nothing to do with authelia's - // token endpoint's trust anchors. - let token = swarm_queue_client::mint_token_for(&self.queue_cfg) - .await - .context("minting a bearer token for swarm-authelia-bridge")?; - - let response = self - .http - .post(format!("{}/requests", self.base_url)) - .bearer_auth(token) - .json(&BridgeRequest::EnsureAgentIdentity { - name: name.to_owned(), - }) - .send() - .await - .context("calling swarm-authelia-bridge")?; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!("swarm-authelia-bridge refused the request ({status}): {body}"); - } - - serde_json::from_str(&body).context("parsing swarm-authelia-bridge's response") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The common case: no bridge wired up for this deployment. - #[test] - fn absent_url_is_not_an_error() { - assert!(std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL").is_err()); - assert!( - AuthBridge::from_env() - .expect("absent is not an error") - .is_none() - ); - } -} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index d65c1e03..5be49dde 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -32,38 +32,31 @@ use serde::{Deserialize, Serialize}; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; -mod auth; mod status; -/// Node payload for the swarm-level job graph. Named `Swarm*` rather than -/// the bare `NodeKind`/`Resource` `hive-c0re::job_queue::model` already -/// uses, so a grep for either doesn't land on both crates. -/// -/// `CreateIdentity` is the first real variant — "the minimal shape for -/// agent creation is creating the identity and wiring that in" (the design -/// thread's own framing for why this landed before the forge-node work a -/// standalone `CreateRepo` variant would have started with). Its only -/// effect is calling `swarm-controller::auth`, which calls -/// `swarm-authelia-bridge`; nothing forge- or deploy-shaped happens yet. +/// Placeholder node payload for the swarm-level job graph — uninhabited on +/// purpose, and named `Swarm*` rather than the bare `NodeKind`/`Resource` +/// `hive-c0re::job_queue::model` already uses, so a grep for either doesn't +/// land on both crates. The *scheduler loop* below is real and running +/// (`spawn_jobq_worker`, mirroring `hive-c0re/src/job_queue/scheduler.rs`'s +/// `run_worker`) — what's still missing is a real job to give it: no +/// variant exists yet, so nothing is ever inserted into the graph and +/// `claim_next` always returns `None`. Giving this real variants (starting +/// with `CreateRepo`) is the next slice, landing together with +/// `swarm-controller::forge`, the client those nodes will call. `WireNode` +/// is trivially satisfiable on an empty enum (`match *self {}`), so the +/// wire machinery below is real and typechecked today, with nothing yet to +/// put in it. #[derive(Clone, Debug)] -enum SwarmNodeKind { - /// Ensure `agent` exists as an authelia subject at the swarm level. - CreateIdentity { agent: String }, -} +enum SwarmNodeKind {} impl hive_jobq_wire::WireNode for SwarmNodeKind { fn label(&self) -> String { - match self { - SwarmNodeKind::CreateIdentity { .. } => "create_identity".to_owned(), - } + match *self {} } fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value { - match self { - SwarmNodeKind::CreateIdentity { agent } => { - serde_json::json!({ "agent": agent }) - } - } + match *self {} } } @@ -80,33 +73,20 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind { /// Run a claimed node's actual work. Mirrors `hive-c0re/src/job_queue/ /// exec.rs::run_node`'s role exactly — the one place a `SwarmNodeKind` -/// variant turns into a real effect. -/// -/// `auth` is `None` on a host that runs a controller split from -/// `swarm-authelia` (no bridge configured there) — `CreateIdentity` fails -/// explicitly in that case rather than this fn papering over a -/// misconfigured deployment. +/// variant turns into a real effect. Trivially exhaustive today +/// (`match kind {}`) because the enum has no variants yet; the first +/// real arm (`CreateRepo`, calling `swarm-controller::forge`) lands +/// alongside that variant, not before. async fn run_swarm_node( _id: hive_jobq::NodeId, kind: SwarmNodeKind, builder: hive_jobq::builder::JobBuilder, - auth: Option>, ) -> ( hive_jobq::builder::JobBuilder, hive_jobq::scheduler::Outcome, ) { - let outcome = match kind { - SwarmNodeKind::CreateIdentity { agent } => match auth { - None => hive_jobq::scheduler::Outcome::Failed( - "no swarm-authelia-bridge is configured on this host".to_owned(), - ), - Some(bridge) => match bridge.ensure_agent_identity(&agent).await { - Ok(_) => hive_jobq::scheduler::Outcome::Done, - Err(e) => hive_jobq::scheduler::Outcome::Failed(format!("{e:#}")), - }, - }, - }; - (builder, outcome) + let _ = builder; + match kind {} } /// Spawn the swarm-level job-graph scheduler loop. Mirrors `hive-c0re/src/ @@ -122,23 +102,14 @@ async fn run_swarm_node( /// every in-flight HTTP request does. /// /// Cheap to run with an empty graph: `claim_next` on a graph nothing was -/// ever inserted into just returns `None` every poll. -/// -/// `auth` is cloned per iteration (an `Arc` clone, not a reconnect) and -/// moved into the closure `claim_next` takes ownership of — `run_swarm_node` -/// needs its own owned copy since the claimed future may outlive this loop -/// iteration. +/// ever inserted into just returns `None` every poll, so this is a +/// harmless idle loop until the first real node kind exists. fn spawn_jobq_worker( sched: Arc>>, - auth: Option>, ) { tokio::spawn(async move { loop { - let auth = auth.clone(); - let runner = - hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { - run_swarm_node(id, kind, builder, auth) - }); + let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, run_swarm_node); match runner { Some(runner) => { tokio::spawn(async move { @@ -203,7 +174,6 @@ fn socket_path() -> PathBuf { (name = "hives", description = "the swarm's hive directory"), (name = "links", description = "swarm service quick links"), (name = "jobq", description = "the swarm-level job graph"), - (name = "agents", description = "creating agent identities at swarm level"), ) )] struct ApiDoc; @@ -252,13 +222,6 @@ struct AppState { /// is synchronous (no `.await` while held). Always present, never /// gated on the swarm queue: this is process state, not something /// read over the network. - /// **Not** consulted by `POST /api/agents` — that endpoint only ever - /// queues the job (see [`create_agent`]); whether a bridge is - /// configured is `run_swarm_node`'s concern (it holds its own clone, - /// handed to it by `spawn_jobq_worker`), not this handler's. A request - /// still queues cleanly on a bridge-less host, then fails loud once - /// claimed — same "queue now, fail per-job" shape as an unreachable - /// swarm queue. jobq: Arc>>, } @@ -403,62 +366,6 @@ async fn get_hives_status( } } -/// Body of `POST /api/agents` — the agent name to create a swarm-level -/// identity for. No other fields: this endpoint is deliberately narrow — -/// "identity in authelia only, no forge or deploy yet" — so it asks for -/// nothing a `CreateIdentity` node doesn't use. -#[derive(Clone, Debug, Deserialize, ToSchema)] -struct CreateAgentRequest { - name: String, -} - -/// Where the queued job landed — a caller polls `/api/jobq/graph` (or -/// `?states=`) with this id to watch it settle, same as every other job -/// kind this daemon will ever queue. -#[derive(Clone, Debug, Serialize, ToSchema)] -struct CreateAgentResponse { - node_id: u64, -} - -/// Queue a `CreateIdentity` job for `name`. Returns as soon as the node is -/// inserted — **not** once the identity exists; `run_swarm_node` does that -/// work asynchronously off the scheduler loop already running -/// (`spawn_jobq_worker`), same as every other node kind. This is also the -/// first genuine non-test caller `SwarmNodeKind::CreateIdentity` has: the -/// node kind's `dead_code` bound was the whole reason this endpoint had to -/// land in the same change as the variant, not as a follow-up. -#[utoipa::path( - post, - path = "/api/agents", - request_body = CreateAgentRequest, - responses( - (status = 200, description = "job queued", body = CreateAgentResponse), - (status = 500, description = "the job could not be queued", body = String), - ), - tag = "agents" -)] -async fn create_agent( - State(state): State, - Json(req): Json, -) -> Result, (axum::http::StatusCode, String)> { - let mut sched = state - .jobq - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let ids = sched - .insert_job(None, |b| { - vec![ - b.node(SwarmNodeKind::CreateIdentity { agent: req.name }) - .guid(), - ] - }) - .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let [id] = ids[..] else { - unreachable!("exactly one handle was asked for"); - }; - Ok(Json(CreateAgentResponse { node_id: id.get() })) -} - /// Query params for `GET /api/jobq/graph` — `?states=` narrows to root /// groups in the named states, same shape `hive_jobq_wire::parse_states` /// parses. @@ -594,23 +501,11 @@ async fn main() -> Result<()> { }, }; - // Same "not fatal, log and carry on" shape as the queue connect above: - // a controller with no bridge wired up still serves everything else, - // and `run_swarm_node` gives an honest per-job failure instead of this - // fn refusing to start. - let auth = match auth::AuthBridge::from_env() { - Ok(auth) => auth.map(Arc::new), - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), "swarm-authelia-bridge misconfigured; agent identity creation is off"); - None - } - }; - let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))); - spawn_jobq_worker(Arc::clone(&jobq), auth.clone()); + spawn_jobq_worker(Arc::clone(&jobq)); let state = AppState { hives: Arc::new(load_hives()), @@ -626,7 +521,6 @@ async fn main() -> Result<()> { .routes(routes!(get_links)) .routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_rollup)) - .routes(routes!(create_agent)) .split_for_parts(); // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from // the nix store (see the module doc comment above). `api` is diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 862d72a6..c95c6cc3 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -321,45 +321,6 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result Result { - let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)); - if let Some(path) = &cfg.ca_file { - let pem = std::fs::read(path).map_err(|source| Error::CaFile { - path: path.display().to_string(), - source, - })?; - let cert = reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse { - path: path.display().to_string(), - source, - })?; - builder = builder.add_root_certificate(cert); - } - builder.build().map_err(Error::HttpClient) -} - -/// Mint a fresh token for `cfg`'s identity and hand back just the string — -/// no caching, a fresh HTTP client per call. -/// -/// Public because the queue connection is not the only thing this identity -/// authenticates: "one identity per principal" means a caller that already -/// holds a [`QueueConfig`] for its queue connection authenticates anywhere -/// else it needs to prove who it is from the exact same client, rather than -/// a second identity being provisioned per destination. No caching here -/// unlike [`connect`]'s callback: that one exists because `async-nats` reruns -/// its callback per reconnect *attempt*, a hot path this isn't — a caller -/// outside that loop (e.g. `swarm-controller::auth`'s bridge client) mints -/// per call, same as this crate did before the reconnect-storm fix added the -/// cache. -pub async fn mint_token_for(cfg: &QueueConfig) -> Result { - let http = build_http_client(cfg)?; - Ok(mint_token(&http, cfg).await?.token) -} - /// Fail fast unless the client is actually connected. /// /// **Call this before every `JetStream` request.** `retry_on_initial_connect` @@ -394,14 +355,36 @@ pub fn ensure_connected(client: &async_nats::Client) -> Result<(), Error> { /// token expires, the controller keeps serving, its status data quietly stops /// updating, and nothing says so until someone reads a dashboard. pub async fn connect(cfg: QueueConfig) -> Result { - // Trusts `cfg.ca_file` when set (the swarm's own CA, when the token - // endpoint is signed by it) — see `build_http_client`. A timeout too, - // because this client runs INSIDE the auth callback: a token endpoint - // that accepts the connection and then never answers would hang the - // callback, and with it the connection attempt that invoked it, with no - // retry and nothing in the log to say why. Failing fast lets + // A timeout, because this client runs INSIDE the auth callback: a token + // endpoint that accepts the connection and then never answers would hang + // the callback, and with it the connection attempt that invoked it, with + // no retry and nothing in the log to say why. Failing fast lets // `async-nats` do what it already does well — back off and try again. - let http = build_http_client(&cfg)?; + // 10s is generous for a form POST to a local IdP. + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)); + + // The swarm's own CA, when the token endpoint is signed by it. ADDED, not + // substituted: `add_root_certificate` extends the default set rather than + // replacing it, so a swarm can front authelia publicly and still have + // this work. + // + // Failing here rather than falling back to the platform roots is the + // point — an operator who named a CA file wants that anchor, and a + // silent fallback would turn their typo into `UnknownIssuer` five layers + // away, inside an auth callback, on a retry loop. + if let Some(path) = &cfg.ca_file { + let pem = std::fs::read(path).map_err(|source| Error::CaFile { + path: path.display().to_string(), + source, + })?; + let cert = reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse { + path: path.display().to_string(), + source, + })?; + builder = builder.add_root_certificate(cert); + } + + let http = builder.build().map_err(Error::HttpClient)?; let url = cfg.url.clone(); // Shared across every invocation of the callback below, which is the