Compare commits
18 changed files with 92 additions and 1400 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue