Compare commits

..
18 changed files with 92 additions and 1400 deletions

25
Cargo.lock generated
View file

@ -4555,29 +4555,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 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]] [[package]]
name = "swarm-controller" name = "swarm-controller"
version = "0.1.0" version = "0.1.0"
@ -4588,10 +4565,8 @@ dependencies = [
"futures-util", "futures-util",
"hive-jobq", "hive-jobq",
"hive-jobq-wire", "hive-jobq-wire",
"reqwest 0.13.1",
"serde", "serde",
"serde_json", "serde_json",
"swarm-authelia-bridge-sock",
"swarm-queue-client", "swarm-queue-client",
"tokio", "tokio",
"tracing", "tracing",

View file

@ -21,8 +21,6 @@ members = [
"hive-sock-client", "hive-sock-client",
"hive-types", "hive-types",
"hivectl", "hivectl",
"swarm-authelia-bridge",
"swarm-authelia-bridge-sock",
"swarm-controller", "swarm-controller",
"swarm-nats-auth", "swarm-nats-auth",
"swarm-queue-client", "swarm-queue-client",
@ -87,7 +85,6 @@ hive-host-sock = { path = "hive-host-sock" }
hive-priv-sock = { path = "hive-priv-sock" } hive-priv-sock = { path = "hive-priv-sock" }
hive-sock-client = { path = "hive-sock-client" } hive-sock-client = { path = "hive-sock-client" }
hive-types = { path = "hive-types" } hive-types = { path = "hive-types" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
swarm-queue-client = { path = "swarm-queue-client" } swarm-queue-client = { path = "swarm-queue-client" }
thiserror = "2" thiserror = "2"
tower-http = { version = "0.7", features = ["fs"] } tower-http = { version = "0.7", features = ["fs"] }

View file

@ -154,9 +154,6 @@
services.hyperhive.swarm.nats.authPackage = services.hyperhive.swarm.nats.authPackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; 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 = services.hyperhive.gateway.swaggerUiTheme =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;

View file

@ -11,18 +11,16 @@
# #
# Operator and agents are both subjects of the same provider, # Operator and agents are both subjects of the same provider,
# differentiated by roles/claims rather than by mechanism — there is one # differentiated by roles/claims rather than by mechanism — there is one
# IdP and one auth path. The users store is written by a program # IdP and one auth path. The users store is therefore written by a
# (`swarm-authelia-bridge`, see that option's doc comment), not # program (swarm-controller), not maintained by hand: agents are created
# maintained by hand: agents are created and destroyed continuously, so # and destroyed continuously, so the subject set is *dynamic*. That is
# the subject set is *dynamic*. That is also why the file backend is # also why the file backend is the right one here and not a placeholder
# right here, not a placeholder for LDAP: what makes a directory # for LDAP: what makes a directory necessary is the size of the subject
# necessary is the size of the subject set, bounded by one swarm. # set, and this deployment's is bounded by one swarm.
# #
# Two roles: a **session** provider always, an **OIDC** provider when # Two roles: a **session** provider always, an **OIDC** provider when
# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC # `oidc.clients` is non-empty (derived, not flagged — authelia will not
# is always on now: the bridge needs its own machine-client identity for # start with a clientless provider). Secrets map: docs/swarm/sso.md.
# its introspection calls, contributed unconditionally, not behind
# `oidc.hiveIdentities`. Secrets map: docs/swarm/sso.md.
# #
# Per-service integration — putting authelia's `auth_request` in front # Per-service integration — putting authelia's `auth_request` in front
# of the gateway's existing `auth_basic` locations — is deliberately NOT # of the gateway's existing `auth_basic` locations — is deliberately NOT
@ -96,33 +94,17 @@ let
redirectUris = [ ]; redirectUris = [ ];
}) hyperhiveCfg.swarm.hives; }) 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, # authelia refuses to start with an OIDC provider that has no clients,
# so the provider is derived from the client list rather than carrying # 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 # ⚠️ The derived hive identities are definitions of this same option,
# is enabled: `bridgeClient` above is an unconditional definition of # so they can turn the provider on by themselves. That is only
# `oidc.clients` (see the `config` block), so the list is never empty. # reachable where the queue is already enabled (`hiveIdentities`
# Kept as a derived boolean rather than simplified to a literal `true` # defaults to it) — and a queue-enabled hive already contributes a
# so the OIDC-gated code below stays self-documenting about WHY it is # client, so no existing deployment flips.
# conditional, not just that it happens to always be on today.
oidcEnabled = cfg.oidc.clients != [ ]; oidcEnabled = cfg.oidc.clients != [ ];
# Secrets that are 64 random bytes of hex and nothing more. The OIDC # Secrets that are 64 random bytes of hex and nothing more. The OIDC
@ -353,15 +335,12 @@ in
description = '' description = ''
Path (inside the container) of authelia's file users database. Path (inside the container) of authelia's file users database.
Written by `swarm-authelia-bridge`, not by hand: agents come and Written by swarm-controller, not by hand: agents come and go
go continuously, so the subject set is dynamic and belongs to a continuously, so the subject set is dynamic and belongs to a
program. `swarm-controller` cannot write this file itself a program. This module only guarantees the file *exists* and is
different uid owns it so the bridge is the only writer, valid YAML at first boot, so authelia starts with no subjects
running inside this same container as this file's actual owner. rather than failing to start a provider with nobody in it yet
This module only guarantees the file *exists* and is valid YAML is the correct state before anything has provisioned users.
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 `usersFile` as seen from the **host** the container's root
prefixed onto the path authelia sees. prefixed onto the path authelia sees.
Published for callers that only ever need to *read* the file The distinction is load-bearing: the users database is written
(e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge` from the host by a program that does not live in this container,
itself never uses this path it runs inside the container, as while authelia only ever sees the inner path. Handing the wrong
the file's own owner, and writes the in-container path directly. one to either side yields a file nobody reads rather than an
''; error.
};
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.
''; '';
}; };
}; };
@ -618,16 +550,11 @@ in
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
# The derived half of the client list, declared the same way an # The derived half of the client list, declared the same way an
# operator declares one. Everything downstream then reads a single # 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` # including the assertions below, which is why a hive named `x`
# colliding with a declared `hive-x` is caught rather than rendered # colliding with a declared `hive-x` is caught rather than rendered
# twice. `bridgeClient` is unconditional (plain list concatenation, # twice.
# not `mkIf`-gated like `hiveClients`): the bridge is always present services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf cfg.oidc.hiveIdentities hiveClients;
# 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 ];
# A redirect URI on a machine client is not harmless-but-unused: it # A redirect URI on a machine client is not harmless-but-unused: it
# means whoever wrote it believes a browser is involved. Failing here # 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} = { services.authelia.instances.${instance} = {
enable = true; enable = true;
package = cfg.package; package = cfg.package;

View file

@ -87,14 +87,6 @@ let
SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token"; 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 # Wrapped rather than documented: every one of these values is derived
# from an option this deployment already set, so making the operator # 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 # 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. 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) { config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
@ -512,16 +480,15 @@ in
]; ];
}; };
# Queue coordinates (`queueEnv`), forge coordinates (`forgeEnv`), and # Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`)
# the auth-bridge address (`authBridgeEnv`) merge in last. The # merge in last. The daemon refuses a PARTIAL set of either rather
# daemon refuses a PARTIAL set of any of them rather than treating # than treating it as absent, which is why each is built as one
# it as absent, which is why each is built as one attrset and never # attrset and never assigned individually.
# assigned individually.
# #
# They differ in how absence is prevented: the queue's is checked by # They differ in how absence is prevented: the queue's is checked by
# the assertions above, because a controller without a queue is # the assertions above, because a controller without a queue is
# broken rather than lighter; forge's and the auth bridge's are # broken rather than lighter; the forge's is genuinely optional and
# genuinely optional and stay gated on their own option resolving. # stays gated on `forgeTokenFile` resolving.
environment = { environment = {
SWARM_CONTROLLER_SOCKET = cfg.socketPath; SWARM_CONTROLLER_SOCKET = cfg.socketPath;
# The swarm's hive directory, JSON-encoded — the full directory # The swarm's hive directory, JSON-encoded — the full directory
@ -547,8 +514,7 @@ in
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds;
} }
// queueEnv // queueEnv
// forgeEnv // forgeEnv;
// authBridgeEnv;
}; };
# A systemd credential is a SNAPSHOT: it is materialised into `%d` once, # A systemd credential is a SNAPSHOT: it is materialised into `%d` once,

View file

@ -156,12 +156,6 @@ in
# rather than every hive's. # rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; 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 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 daemon above: it is installed by the swarm-controller module on
# the one host that runs the controller, and belongs in that hive's # the one host that runs the controller, and belongs in that hive's

View file

@ -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

View file

@ -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.

View file

@ -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");
}
}

View file

@ -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

View file

@ -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.

View file

@ -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<bool> {
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::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
}
}

View file

@ -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<Self> {
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<String> {
std::env::var(name).with_context(|| format!("{name} is unset"))
}
fn read_secret_file(path: &str) -> Result<String> {
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<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<BridgeRequest>,
) -> 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<BridgeResponse> {
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 <pw>`: `/proc/<pid>/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<String> {
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()
)
})
}

View file

@ -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<String, User>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
/// 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<String> {
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<UserStore> {
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
}
}

View file

@ -26,12 +26,8 @@ futures-util.workspace = true
# to serve. # to serve.
hive-jobq.workspace = true hive-jobq.workspace = true
hive-jobq-wire.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.workspace = true
serde_json.workspace = true serde_json.workspace = true
swarm-authelia-bridge-sock.workspace = true
# The queue connect (token mint + auth callback + reconnect) is shared with # The queue connect (token mint + auth callback + reconnect) is shared with
# every other participant - a hive publishing its own status runs the same # 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 # code with a different client id. Two copies of credential handling is one

View file

@ -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<Option<Self>> {
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<BridgeResponse> {
// `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()
);
}
}

View file

@ -32,38 +32,31 @@ use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema}; use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_axum::{router::OpenApiRouter, routes};
mod auth;
mod status; mod status;
/// Node payload for the swarm-level job graph. Named `Swarm*` rather than /// Placeholder node payload for the swarm-level job graph — uninhabited on
/// the bare `NodeKind`/`Resource` `hive-c0re::job_queue::model` already /// purpose, and named `Swarm*` rather than the bare `NodeKind`/`Resource`
/// uses, so a grep for either doesn't land on both crates. /// `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
/// `CreateIdentity` is the first real variant — "the minimal shape for /// (`spawn_jobq_worker`, mirroring `hive-c0re/src/job_queue/scheduler.rs`'s
/// agent creation is creating the identity and wiring that in" (the design /// `run_worker`) — what's still missing is a real job to give it: no
/// thread's own framing for why this landed before the forge-node work a /// variant exists yet, so nothing is ever inserted into the graph and
/// standalone `CreateRepo` variant would have started with). Its only /// `claim_next` always returns `None`. Giving this real variants (starting
/// effect is calling `swarm-controller::auth`, which calls /// with `CreateRepo`) is the next slice, landing together with
/// `swarm-authelia-bridge`; nothing forge- or deploy-shaped happens yet. /// `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)] #[derive(Clone, Debug)]
enum SwarmNodeKind { enum SwarmNodeKind {}
/// Ensure `agent` exists as an authelia subject at the swarm level.
CreateIdentity { agent: String },
}
impl hive_jobq_wire::WireNode for SwarmNodeKind { impl hive_jobq_wire::WireNode for SwarmNodeKind {
fn label(&self) -> String { fn label(&self) -> String {
match self { match *self {}
SwarmNodeKind::CreateIdentity { .. } => "create_identity".to_owned(),
}
} }
fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value { fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value {
match self { match *self {}
SwarmNodeKind::CreateIdentity { agent } => {
serde_json::json!({ "agent": agent })
}
}
} }
} }
@ -80,33 +73,20 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind {
/// Run a claimed node's actual work. Mirrors `hive-c0re/src/job_queue/ /// 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` /// exec.rs::run_node`'s role exactly — the one place a `SwarmNodeKind`
/// variant turns into a real effect. /// variant turns into a real effect. Trivially exhaustive today
/// /// (`match kind {}`) because the enum has no variants yet; the first
/// `auth` is `None` on a host that runs a controller split from /// real arm (`CreateRepo`, calling `swarm-controller::forge`) lands
/// `swarm-authelia` (no bridge configured there) — `CreateIdentity` fails /// alongside that variant, not before.
/// explicitly in that case rather than this fn papering over a
/// misconfigured deployment.
async fn run_swarm_node( async fn run_swarm_node(
_id: hive_jobq::NodeId, _id: hive_jobq::NodeId,
kind: SwarmNodeKind, kind: SwarmNodeKind,
builder: hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>, builder: hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
auth: Option<std::sync::Arc<auth::AuthBridge>>,
) -> ( ) -> (
hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>, hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
hive_jobq::scheduler::Outcome, hive_jobq::scheduler::Outcome,
) { ) {
let outcome = match kind { let _ = builder;
SwarmNodeKind::CreateIdentity { agent } => match auth { match kind {}
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)
} }
/// Spawn the swarm-level job-graph scheduler loop. Mirrors `hive-c0re/src/ /// 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. /// every in-flight HTTP request does.
/// ///
/// Cheap to run with an empty graph: `claim_next` on a graph nothing was /// Cheap to run with an empty graph: `claim_next` on a graph nothing was
/// ever inserted into just returns `None` every poll. /// ever inserted into just returns `None` every poll, so this is a
/// /// harmless idle loop until the first real node kind exists.
/// `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.
fn spawn_jobq_worker( fn spawn_jobq_worker(
sched: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>, sched: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
auth: Option<Arc<auth::AuthBridge>>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
let auth = auth.clone(); let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, run_swarm_node);
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
run_swarm_node(id, kind, builder, auth)
});
match runner { match runner {
Some(runner) => { Some(runner) => {
tokio::spawn(async move { tokio::spawn(async move {
@ -203,7 +174,6 @@ fn socket_path() -> PathBuf {
(name = "hives", description = "the swarm's hive directory"), (name = "hives", description = "the swarm's hive directory"),
(name = "links", description = "swarm service quick links"), (name = "links", description = "swarm service quick links"),
(name = "jobq", description = "the swarm-level job graph"), (name = "jobq", description = "the swarm-level job graph"),
(name = "agents", description = "creating agent identities at swarm level"),
) )
)] )]
struct ApiDoc; struct ApiDoc;
@ -252,13 +222,6 @@ struct AppState {
/// is synchronous (no `.await` while held). Always present, never /// is synchronous (no `.await` while held). Always present, never
/// gated on the swarm queue: this is process state, not something /// gated on the swarm queue: this is process state, not something
/// read over the network. /// 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<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>, jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
} }
@ -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<AppState>,
Json(req): Json<CreateAgentRequest>,
) -> Result<Json<CreateAgentResponse>, (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 /// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
/// groups in the named states, same shape `hive_jobq_wire::parse_states` /// groups in the named states, same shape `hive_jobq_wire::parse_states`
/// parses. /// 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( let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),
))); )));
spawn_jobq_worker(Arc::clone(&jobq), auth.clone()); spawn_jobq_worker(Arc::clone(&jobq));
let state = AppState { let state = AppState {
hives: Arc::new(load_hives()), hives: Arc::new(load_hives()),
@ -626,7 +521,6 @@ async fn main() -> Result<()> {
.routes(routes!(get_links)) .routes(routes!(get_links))
.routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_graph))
.routes(routes!(get_jobq_rollup)) .routes(routes!(get_jobq_rollup))
.routes(routes!(create_agent))
.split_for_parts(); .split_for_parts();
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
// the nix store (see the module doc comment above). `api` is // the nix store (see the module doc comment above). `api` is

View file

@ -321,45 +321,6 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<CachedT
}) })
} }
/// Build the HTTP client used to reach `cfg.token_endpoint`, trusting
/// `cfg.ca_file` when set. Shared by [`connect`]'s auth callback and by
/// [`mint_token_for`] — anything presenting this identity's credentials to
/// the token endpoint needs the swarm's own CA trusted the same way, so the
/// trust-anchor logic lives here once rather than once per caller.
fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
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<String, Error> {
let http = build_http_client(cfg)?;
Ok(mint_token(&http, cfg).await?.token)
}
/// Fail fast unless the client is actually connected. /// Fail fast unless the client is actually connected.
/// ///
/// **Call this before every `JetStream` request.** `retry_on_initial_connect` /// **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 /// token expires, the controller keeps serving, its status data quietly stops
/// updating, and nothing says so until someone reads a dashboard. /// updating, and nothing says so until someone reads a dashboard.
pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> { pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
// Trusts `cfg.ca_file` when set (the swarm's own CA, when the token // A timeout, because this client runs INSIDE the auth callback: a token
// endpoint is signed by it) — see `build_http_client`. A timeout too, // endpoint that accepts the connection and then never answers would hang
// because this client runs INSIDE the auth callback: a token endpoint // the callback, and with it the connection attempt that invoked it, with
// that accepts the connection and then never answers would hang the // no retry and nothing in the log to say why. Failing fast lets
// 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. // `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(); let url = cfg.url.clone();
// Shared across every invocation of the callback below, which is the // Shared across every invocation of the callback below, which is the