feat(#3089): add swarmctl and a user-add verb for the swarm's SSO

The swarm-authelia module states that its users database is written by
swarm-controller, but nothing ever granted the means. This adds the tool
that does it.

swarmctl runs as root on the controller's host and acts directly. The
rootless alternative was examined and does not work: relocating the users
file into a directory the controller owns only turns a write problem into
a read problem, because authelia must then reach across the same boundary
in the other direction. Making that read work needs either a hand-pinned
gid or world-readable password hashes.

The user store is two files, one authoritative: users.json is canonical,
users.yml is a rendered artifact. That split is what lets the crate work
without a YAML parser -- the workspace has none, and adding one costs a
crates.io fetch, a lock update and a vendor hash for a schema we fully
control and only ever emit.

Passwords are generated by authelia rather than passed to it: argv is
world-readable, so a password on a command line is readable by any local
process for the lifetime of the call.

The three derived facts swarmctl needs about the authelia container --
machine, unit and the host-side users path -- become readOnly options on
the authelia module rather than literals repeated at the call site.
This commit is contained in:
atlas 2026-08-10 21:11:41 +02:00 committed by mara
commit 9e44efa01f
11 changed files with 995 additions and 1 deletions

View file

@ -148,6 +148,14 @@ hand-maintained per-file tree drifts out of sync with the code.
unix socket the gateway's nginx proxies to — ⚠️ **the socket's
directory is its access control**; the constraint that governs it is in
the crate's README, and a unit test pins the path.
- **`swarmctl/`** — swarm-level operator CLI, installed by the
swarm-controller module on the host that runs the daemon. Runs as
**root and acts directly** — no socket, no HTTP route, no priv helper;
the crate's README records why the rootless shape was examined and
rejected. Does not link `swarm-controller`, mirroring `hivectl` ÷
`hive-c0re`. ⚠️ Its user store is **two files, one authoritative**:
`users.json` is canonical, authelia's `users.yml` is a *rendered
artifact* that is written and never read back.
### External dependencies with no directory here

10
Cargo.lock generated
View file

@ -4432,6 +4432,16 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "swarmctl"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "1.0.109"

View file

@ -22,6 +22,7 @@ members = [
"hive-types",
"hivectl",
"swarm-controller",
"swarmctl",
]
[workspace.package]

View file

@ -145,6 +145,9 @@
services.hyperhive.swarm.controller.package =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-controller;
services.hyperhive.swarm.controller.swarmctlPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarmctl;
services.hyperhive.gateway.swaggerUiTheme =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;

View file

@ -150,10 +150,54 @@ in
is the correct state before anything has provisioned users.
'';
};
# Derived facts, exposed for consumers that have to act on this
# container **from outside it** — `swarmctl` is the first, and it
# needs all three. Read-only options rather than literals repeated at
# the call site: the machine and unit names are derived from
# `instance` here, so a second copy elsewhere is a second thing to
# keep in step, and the one that drifts is the one nobody tests.
machine = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-authelia";
description = ''
Name of the nixos-container authelia runs in. Read-only: it is
what this module declares, published so callers of
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
have to hardcode it.
'';
};
unit = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "${unitName}.service";
description = ''
authelia's systemd unit *inside* the container. Read-only, and
derived from the instance name exactly like the unit itself.
'';
};
hostUsersFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}";
description = ''
`usersFile` as seen from the **host** the container's root
prefixed onto the path authelia sees.
The distinction is load-bearing: the users database is written
from the host by a program that does not live in this container,
while authelia only ever sees the inner path. Handing the wrong
one to either side yields a file nobody reads rather than an
error.
'';
};
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
containers.swarm-authelia = {
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;
# Shared host netns, like the forge and matrix containers: the

View file

@ -8,12 +8,47 @@
# bind-mount, and a socket that is never bound to an address cannot be
# reached from off-host by mistake.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.swarm.controller;
autheliaCfg = config.services.hyperhive.swarm.authelia;
# What `swarmctl` needs in order to act on authelia from the host.
#
# Only set when authelia actually runs **here**: the controller can be
# enabled on a host that is not the swarm's SSO host, and in that case
# the right behaviour is for `swarmctl user add` to fail saying the
# value is unset. A guessed path would resolve cleanly and write a file
# nothing reads, which is the failure mode that costs an afternoon.
autheliaEnv = lib.optionalAttrs autheliaCfg.enable {
# The CONFIGURED authelia, not whatever is on PATH: the argon2
# parameters baked into a hash have to match the verifier's.
SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia";
SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile;
SWARMCTL_AUTHELIA_MACHINE = autheliaCfg.machine;
SWARMCTL_AUTHELIA_UNIT = autheliaCfg.unit;
};
# Wrapped rather than documented: every one of these values is derived
# from an option this deployment already set, so making the operator
# re-supply them on the command line would be asking them to repeat the
# config back to it — and to get it wrong the day one of them changes.
swarmctlConfigured = pkgs.symlinkJoin {
name = "swarmctl-configured";
paths = [ cfg.swarmctlPackage ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram $out/bin/swarmctl ${
lib.concatStringsSep " " (
lib.mapAttrsToList (name: value: "--set ${name} ${lib.escapeShellArg value}") autheliaEnv
)
}
'';
};
in
{
options.services.hyperhive.swarm.controller = {
@ -37,6 +72,20 @@ in
'';
};
swarmctlPackage = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarmctl";
description = ''
`swarmctl` package the swarm operator's CLI, installed on this
host alongside the daemon and wrapped with the paths it needs.
A separate option from `package` rather than a second binary in
the same derivation: the CLI runs as root and acts directly,
the daemon runs unprivileged and serves a socket, and pinning one
without the other is a thing an operator may legitimately want.
'';
};
socketPath = lib.mkOption {
type = lib.types.str;
default = "/run/swarm-controller/controller.sock";
@ -67,6 +116,11 @@ in
};
users.groups.swarm-controller = { };
# Installed host-wide, not into the daemon's unit: `swarmctl` is run
# by a human on this box and acts as root, so the daemon's sandbox is
# exactly what it must not inherit.
environment.systemPackages = [ swarmctlConfigured ];
systemd.services.swarm-controller = {
description = "hyperhive swarm-level controller daemon";
wantedBy = [ "multi-user.target" ];

View file

@ -143,6 +143,13 @@ in
# Uses the same per-bin extractor, just bound on its own.
swarm-controller = mkBinPackage "swarm-controller" "hyperhive swarm-level controller daemon";
# The swarm operator's CLI, out of `daemonBins` for the same reason as
# the daemon above: it is installed by the swarm-controller module on
# the one host that runs the controller, and belongs in that hive's
# closure only. Kept a separate derivation rather than a second binary
# in the daemon's package so a hive can pin one without the other.
swarmctl = mkBinPackage "swarmctl" "hyperhive swarm-level operator CLI";
# Bundled browser assets — see ./frontend.nix. Output is
# $out/{dashboard,agent}/ which the Rust binaries serve via
# tower_http::ServeDir.

18
swarmctl/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "swarmctl"
version.workspace = true
readme = "README.md"
edition.workspace = true
[[bin]]
name = "swarmctl"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
[lints]
workspace = true

67
swarmctl/README.md Normal file
View file

@ -0,0 +1,67 @@
# swarmctl
Swarm-level operator CLI. Runs as **root on the host running
`swarm-controller`**, and acts on that host directly.
Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
admin socket. This crate does not link `swarm-controller`, for the same
reason `hivectl` does not link `hive-c0re`.
## Why root, and why no socket
The first verb writes authelia's users database. Making that write
rootless was examined and rejected — **relocating the file only turns a
write problem into a read problem.** Move `users.yml` into a directory
the controller owns and the controller can write it, but authelia then
has to read it across the same boundary in the other direction. Making
*that* work needs either a hand-pinned gid (the container's uids are
allocated inside it, at activation — see the uid-assignment issue) or
world-readable password hashes. Both are worse than root.
So there is no socket, no HTTP route and no privileged helper here. When
a verb has to run as a non-root user or from another host, the answer is
a **group-gated admin socket**, separate from the controller's `0666`
gateway-facing one — not a widening of what root does here.
## Two files, one of them authoritative
- `users.json` — canonical, ours, JSON.
- `users.yml` — a **rendered artifact** for authelia. Written, never read
back.
The split is what lets this crate work without a YAML parser: the
workspace has none, and adding one costs a crates.io fetch, a lock update
and a vendor hash for a schema we fully control and only ever emit.
The shortcut of writing JSON into the `.yml` (JSON being a subset of
YAML) is deliberately not taken: authelia refuses to start on a users file
it cannot parse, so that file fronts the whole SSO provider's boot, and
"almost certainly parses" is not a claim worth betting a boot on without
running it.
## Configuration
Every path comes from the nix module that installs the binary, because
every one is derived from an option that module owns. They are required
rather than defaulted — a default would be an address we *hope* points at
something, and one that resolves cleanly to the wrong place is worse than
an error.
| variable | what |
|---|---|
| `SWARMCTL_AUTHELIA_BIN` | the **configured** authelia; argon2 params must match the verifier's |
| `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database |
| `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` |
| `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container |
| `SWARMCTL_STORE` | canonical store (defaults to the controller's state dir) |
## Usage
```console
# swarmctl user add mara --display-name "Mara" --group admins
```
The password is **generated by authelia** (`crypto hash generate argon2
--random`) and printed once. It is never passed on a command line:
`/proc/<pid>/cmdline` is world-readable, so a password in argv is readable
by any local process for the lifetime of the call.

469
swarmctl/src/main.rs Normal file
View file

@ -0,0 +1,469 @@
//! `swarmctl` — the swarm operator's local CLI.
//!
//! Runs as **root on the host the swarm-controller runs on**, and acts
//! directly. That is a deliberate scope, not a shortcut: the alternative
//! examined for the first verb was to make the write rootless by moving
//! authelia's users database into a directory the controller owns, and it
//! does not work — relocating the file only turns a write problem into a
//! read problem, because authelia then has to reach *across the same
//! boundary in the other direction*. Making that read work needs either a
//! hand-pinned gid (the container's uids are allocated inside it, at
//! activation) or world-readable password hashes. Both are worse than
//! root.
//!
//! So there is no socket, no HTTP route and no privileged helper here.
//! When a verb eventually has to run as a non-root user or from another
//! host, the answer is a **group-gated admin socket** — separate from the
//! controller's `0666` gateway-facing one — not a widening of what root
//! does here.
//!
//! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its
//! admin socket. This crate deliberately does not link `swarm-controller`,
//! for the same reason `hivectl` does not link `hive-c0re`.
mod users;
use std::fs::{self, File, Permissions};
use std::io::Write as _;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use users::{User, UserStore};
/// Canonical user store. A compiled-in default is legitimate here for the
/// same reason it is on the daemon's socket path: this is a path this
/// process **creates**, not an address it hopes to find something at. It
/// lives under the controller's state directory because the controller is
/// this store's eventual reader.
const DEFAULT_STORE: &str = "/var/lib/swarm-controller/users.json";
#[derive(Parser)]
#[command(name = "swarmctl", version, about = "swarm-level operator CLI")]
struct Cli {
#[command(flatten)]
paths: PathArgs,
#[command(subcommand)]
command: Verb,
}
/// Where the deployment put the things this CLI has to touch.
///
/// Every one of these is supplied by the nix module that installs
/// `swarmctl`, because every one of them is derived from options the
/// module owns (the container name, the authelia instance name, the
/// package). They are **required rather than defaulted**: a default here
/// would be an address we hope to find something at, and one that
/// resolves cleanly to the wrong place is worse than an error.
#[derive(Args)]
struct PathArgs {
/// authelia binary used to hash passwords. The argon2 parameters must
/// match the verifier's, so this has to be the *configured* package
/// rather than whatever is on `PATH`.
#[arg(long, value_name = "PATH")]
authelia_bin: Option<PathBuf>,
/// Host-side path of authelia's users database — i.e. the path inside
/// the container, prefixed with the container's root.
#[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>,
/// Machine name of the authelia container, for `systemctl -M`.
#[arg(long, value_name = "NAME")]
machine: Option<String>,
/// authelia's systemd unit inside that container.
#[arg(long, value_name = "UNIT")]
unit: Option<String>,
/// Canonical user store.
#[arg(long, value_name = "PATH")]
store: Option<PathBuf>,
}
struct Paths {
authelia_bin: PathBuf,
users_file: PathBuf,
machine: String,
unit: String,
store: PathBuf,
}
impl PathArgs {
fn resolve(self) -> Result<Paths> {
Ok(Paths {
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
machine: string_from(self.machine, "SWARMCTL_AUTHELIA_MACHINE")?,
unit: string_from(self.unit, "SWARMCTL_AUTHELIA_UNIT")?,
store: self
.store
.or_else(|| std::env::var_os("SWARMCTL_STORE").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(DEFAULT_STORE)),
})
}
}
fn path_from(flag: Option<PathBuf>, env: &str) -> Result<PathBuf> {
flag.or_else(|| std::env::var_os(env).map(PathBuf::from))
.with_context(|| missing(env))
}
fn string_from(flag: Option<String>, env: &str) -> Result<String> {
flag.or_else(|| std::env::var(env).ok())
.with_context(|| missing(env))
}
fn missing(env: &str) -> String {
format!(
"{env} is unset and no flag was given — swarmctl is installed and \
configured by the swarm-controller nix module, which supplies it; \
running outside that deployment needs the value passed explicitly"
)
}
/// Named `Verb` rather than the conventional `Command` because
/// [`std::process::Command`] is in scope here and the clash is a
/// confusing one — the compiler reports it as an orphan-rule violation on
/// a derive, several errors away from the actual cause.
#[derive(Subcommand)]
enum Verb {
/// Manage subjects in the swarm's SSO provider.
User {
#[command(subcommand)]
command: UserVerb,
},
}
#[derive(Subcommand)]
enum UserVerb {
/// Add a user, generating a password for them.
Add(AddArgs),
}
#[derive(Args)]
struct AddArgs {
/// Login name. Conservative ASCII only — it is a YAML map key and
/// reaches access-control rules and logs.
username: String,
/// Name shown in the SSO UI. Defaults to the username.
#[arg(long, value_name = "TEXT")]
display_name: Option<String>,
#[arg(long, value_name = "ADDRESS")]
email: Option<String>,
/// Repeatable.
#[arg(long = "group", value_name = "GROUP")]
groups: Vec<String>,
}
fn main() -> Result<()> {
let Cli { paths, command } = Cli::parse();
let paths = paths.resolve()?;
match command {
Verb::User {
command: UserVerb::Add(args),
} => user_add(&paths, args),
}
}
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?;
let mut store = load_store(&paths.store, &paths.users_file)?;
if store.users.contains_key(&args.username) {
bail!(
"user {:?} already exists in {}",
args.username,
paths.store.display()
);
}
let generated = generate_password(&paths.authelia_bin)?;
let display_name = args.display_name.unwrap_or_else(|| args.username.clone());
store.users.insert(
args.username.clone(),
User {
displayname: display_name,
password: generated.digest,
email: args.email,
groups: args.groups,
},
);
// Render before writing anything: a value this refuses to emit should
// stop the whole operation, not leave the canonical store one user
// ahead of the file authelia reads.
let rendered = users::render_yaml(&store)?;
let store_json = serde_json::to_string_pretty(&store).context("serialising the user store")?;
// Store first, and the order matters. If the store lands and the
// users file does not, the next run re-renders and repairs it. The
// other order loses a user: the store would not know about someone
// authelia does, and the next render would silently drop them.
write_atomic(&paths.store, &format!("{store_json}\n"))?;
write_atomic(&paths.users_file, &rendered)?;
restart_authelia(&paths.machine, &paths.unit)?;
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
}
/// Load the canonical store, or start an empty one if this deployment has
/// never had a user added.
///
/// The guard exists because starting empty means the next write
/// **overwrites** authelia's users file. That is only safe when the file
/// is the untouched first-boot seed; anything else is a user database
/// somebody meant to be there, and losing it is the one unrecoverable
/// mistake this tool can make.
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 !users::is_untouched_seed(&existing) => bail!(
"no user store at {} but {} already holds users — refusing to \
overwrite it. Reconstruct the store, or move the file aside if \
it is disposable.",
store_path.display(),
users_file.display()
),
Ok(_) => Ok(UserStore::default()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
Err(e) => Err(e).with_context(|| {
format!(
"reading {} to check it is safe to take over",
users_file.display()
)
}),
}
}
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
}
}
struct Generated {
password: String,
digest: String,
}
/// Generate a password and its argon2 digest using the configured
/// authelia.
///
/// 🚨 `--random` rather than `--password <pw>` is a security requirement,
/// not a convenience: `/proc/<pid>/cmdline` is world-readable, so a
/// password passed on argv is readable by any local process for the
/// lifetime of the call. Letting authelia generate it means the plaintext
/// never crosses a command line at all.
fn generate_password(bin: &Path) -> Result<Generated> {
let out = Command::new(bin)
.args(["crypto", "hash", "generate", "argon2", "--random"])
.output()
.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")?;
let password = parse_field(&stdout, "Random Password:");
let digest = parse_field(&stdout, "Digest:");
// The raw output is deliberately NOT included in this error: it
// contains the freshly generated plaintext, and an error message is
// exactly the thing that ends up in a log or a bug report. Naming the
// missing marker is enough to diagnose an upstream format change —
// run the command by hand to see the rest.
match (password, digest) {
(Some(password), Some(digest)) => Ok(Generated { password, digest }),
(password, digest) => bail!(
"could not parse {}'s output: {}{}missing",
bin.display(),
if password.is_none() {
"'Random Password:' "
} else {
""
},
if digest.is_none() { "'Digest:' " } else { "" }
),
}
}
fn parse_field(stdout: &str, marker: &str) -> Option<String> {
stdout
.lines()
.find_map(|line| line.trim().strip_prefix(marker))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
/// authelia re-reads its file backend at startup, so a users change needs
/// a restart.
///
/// The file watcher (`authentication_backend.file.watch`) would remove
/// this step entirely, and is deliberately not relied on: it could not be
/// verified against the pinned build, and it carries two unknowns —
/// whether the watch survives the `rename(2)` used above, and whether it
/// can observe a partially written file. An explicit restart assumes
/// nothing.
fn restart_authelia(machine: &str, unit: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["-M", machine, "restart", unit])
.status()
.context("running systemctl")?;
if !status.success() {
bail!(
"restarting {unit} in {machine} failed ({status}); the users file is \
already written, so re-running the restart by hand completes the change"
);
}
Ok(())
}
/// Replace `path`'s contents atomically, preserving the existing owner
/// and mode.
///
/// Atomic because a reader must never see a half-written user database,
/// and because the temp file is created in the *same directory* —
/// `rename(2)` is only atomic within a filesystem.
///
/// Owner and mode are read off the existing file rather than asserted:
/// authelia's file is created by its own unit as its own user, and
/// stamping our idea of the right values onto it would silently
/// re-permission a file another service opens. Both are applied to the
/// temp file *before* the rename, so the finished file is never visible
/// with the wrong ones.
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!(".{}.swarmctl.tmp", name.to_string_lossy()));
let existing = fs::metadata(path).ok();
// 0600 only when the file does not exist yet: this content is
// password hashes, so the conservative value is the right default and
// the existing value is the right answer.
let mode = existing
.as_ref()
.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()))?;
if let Some(meta) = existing.as_ref() {
std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid()))
.with_context(|| format!("setting owner on {}", tmp.display()))?;
}
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
// The rename itself is metadata: without this the file can survive a
// crash while the directory entry pointing at it does not.
File::open(dir)
.and_then(|d| d.sync_all())
.with_context(|| format!("flushing directory {}", dir.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_authelia_hash_output() {
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
assert_eq!(
parse_field(out, "Random Password:").as_deref(),
Some("hunter2")
);
assert_eq!(
parse_field(out, "Digest:").as_deref(),
Some("$argon2id$v=19$m=65536$abc")
);
}
/// An upstream format change must read as "missing", not as an empty
/// password silently written into the store.
#[test]
fn an_empty_field_reads_as_missing() {
assert_eq!(parse_field("Digest: \n", "Digest:"), None);
assert_eq!(parse_field("nothing here\n", "Digest:"), None);
}
#[test]
fn write_atomic_preserves_an_existing_files_mode() {
let dir = std::env::temp_dir().join(format!("swarmctl-test-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("users.yml");
fs::write(&path, "users: {}\n").expect("seed");
fs::set_permissions(&path, Permissions::from_mode(0o640)).expect("chmod");
write_atomic(&path, "users:\n mara:\n").expect("rewrite");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o640, "the existing mode must survive the replace");
assert_eq!(
fs::read_to_string(&path).expect("read"),
"users:\n mara:\n"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_atomic_defaults_a_new_file_to_0600() {
let dir = std::env::temp_dir().join(format!("swarmctl-new-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("users.json");
write_atomic(&path, "{}\n").expect("write");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o600, "a new store holds password hashes");
fs::remove_dir_all(&dir).ok();
}
/// The guard that stands between a missing store and an overwritten
/// user database.
#[test]
fn load_store_refuses_to_take_over_a_populated_users_file() {
let dir = std::env::temp_dir().join(format!("swarmctl-guard-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let store = dir.join("absent.json");
let users_file = dir.join("users.yml");
fs::write(&users_file, "users:\n mara:\n password: \"x\"\n").expect("write");
let err = load_store(&store, &users_file).expect_err("must refuse");
assert!(
err.to_string().contains("refusing to overwrite"),
"unexpected error: {err}"
);
fs::write(&users_file, "users: {}\n").expect("seed");
let taken = load_store(&store, &users_file).expect("the seed is takeable");
assert!(taken.users.is_empty());
fs::remove_dir_all(&dir).ok();
}
}

313
swarmctl/src/users.rs Normal file
View file

@ -0,0 +1,313 @@
//! The swarm's SSO user store, and the authelia users database rendered
//! from it.
//!
//! **Two files, and only one of them is authoritative.** `users.json` is
//! ours and canonical; `users.yml` is a rendered artifact for authelia,
//! written and never read back.
//!
//! That split is not a preference, it is what lets this crate exist
//! without a YAML parser: the workspace has none, and adding one costs a
//! crates.io fetch, a lock update and a vendor hash — for a schema we
//! fully control and only ever emit. Rendering in one direction needs no
//! parser at all.
//!
//! The tempting shortcut — *"JSON is a subset of YAML, so just write JSON
//! into the `.yml` and read it back with `serde_json`"* — is rejected on
//! purpose. authelia **refuses to start** on a users file it cannot
//! parse, so it fronts the whole SSO provider's boot, and "go-yaml
//! almost certainly accepts flow style" is not a claim worth betting that
//! on without running it. Block style is what upstream's own examples
//! show.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use anyhow::{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.
///
/// Matched (after trimming) so a first `user add` can take the file over
/// without a flag, while anything *else* already in place is treated as
/// content somebody meant to be there. Overwriting a live SSO user
/// database because a canonical store happened to be missing is the one
/// unrecoverable mistake available here.
pub const SEED_USERS_FILE: &str = "users: {}";
/// The canonical store, serialised as JSON.
///
/// `BTreeMap` rather than `HashMap` so the rendered YAML is stable
/// between runs: a diffable artifact is worth more than the ordering
/// being meaningless, and a random-order rewrite makes every change look
/// like a whole-file change.
#[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** (`$argon2id$v=19$...`), never a plaintext
/// password. Nothing in this crate stores or logs a plaintext: the
/// one that exists is printed to the operator once and dropped.
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>,
}
/// Usernames are map **keys** in a YAML document, and they also reach
/// logs, session cookies and access-control rules. Keeping them to a
/// conservative ASCII set means the emitter never has to reason about a
/// key that needs quoting, and an authelia-side surprise can't be
/// something we handed it.
///
/// Deliberately tighter than authelia allows. Loosening later is a
/// one-line change; discovering that a name broke an access-control rule
/// in production is not.
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 in free text rather than escaping them.
///
/// The emitter below could encode them, but a display name or email
/// carrying a control character is a bug or an injection attempt in every
/// real case, and refusing is both simpler to reason about and impossible
/// to get subtly wrong.
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: the
/// argon2 digest alone contains `$`, `=`, `,` and `/`, and deciding
/// per-value which of those need quoting is exactly the kind of judgement
/// that is right until one day it isn't. Control characters are excluded
/// upstream by [`reject_control_chars`], 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 because it re-runs validation over every value it is about to
/// emit. That is not belt-and-braces: it makes "no control character ever
/// reaches the file" a property of the *only* path that writes it, rather
/// than a rule the call sites have to remember.
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)
}
/// Whether an existing `users.yml` is safe to take over when no canonical
/// store exists yet — i.e. it is the untouched first-boot seed.
///
/// Empty counts: a zero-byte file holds no users to destroy, and authelia
/// would refuse to start on it anyway, so treating it as untouched turns
/// a dead deployment into a working one instead of demanding a flag.
pub fn is_untouched_seed(contents: &str) -> bool {
let trimmed = contents.trim();
trimmed.is_empty() || trimmed == SEED_USERS_FILE
}
#[cfg(test)]
mod tests {
use super::*;
fn user(password: &str) -> User {
User {
displayname: "Test User".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("empty store renders");
assert_eq!(out, "users: {}\n");
assert!(
is_untouched_seed(&out),
"the empty rendering must itself read as the seed, or removing the \
last user would produce a file the next run refuses to take over"
);
}
/// The digest is the value most likely to break a naive emitter: it
/// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a
/// YAML flow scalar.
#[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("mara".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(&format!("password: \"{digest}\"")),
"digest must be emitted verbatim inside double quotes, got:\n{out}"
);
}
#[test]
fn quoting_escapes_backslash_and_quote() {
assert_eq!(quote(r#"a"b\c"#), r#""a\"b\\c""#);
}
#[test]
fn optional_fields_are_omitted_rather_than_emitted_empty() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
!out.contains("email"),
"absent email must not appear:\n{out}"
);
assert!(
!out.contains("groups"),
"an empty group list must not appear:\n{out}"
);
}
#[test]
fn groups_render_as_a_block_sequence() {
let mut u = user("$argon2id$x");
u.email = Some("mara@example.com".to_owned());
u.groups = vec!["admins".to_owned(), "operators".to_owned()];
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders");
assert_eq!(
out,
concat!(
"users:\n",
" mara:\n",
" displayname: \"Test User\"\n",
" password: \"$argon2id$x\"\n",
" email: \"mara@example.com\"\n",
" groups:\n",
" - \"admins\"\n",
" - \"operators\"\n",
)
);
}
/// Ordering is a property of the artifact, not an accident: an
/// unordered rewrite makes every one-user change look like a
/// whole-file change to anyone diffing it.
#[test]
fn users_render_in_a_stable_order() {
let mut store = UserStore::default();
for name in ["zoe", "atlas", "mara"] {
store.users.insert(name.to_owned(), user("$argon2id$x"));
}
let out = render_yaml(&store).expect("renders");
let order: Vec<&str> = out
.lines()
.filter_map(|l| l.strip_prefix(" ").and_then(|l| l.strip_suffix(':')))
.collect();
assert_eq!(order, ["atlas", "mara", "zoe"]);
}
#[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("mara".to_owned(), u);
let err = render_yaml(&store).expect_err("a control character must not render");
assert!(
err.to_string().contains("control character"),
"unexpected error: {err}"
);
}
#[test]
fn usernames_outside_the_conservative_set_are_refused() {
for bad in ["", "-leading", "has space", "quote\"d", "sla/sh", "üni"] {
assert!(
validate_username(bad).is_err(),
"{bad:?} should have been rejected"
);
}
for good in ["mara", "atlas", "svc-agent_1", "a.b"] {
validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}"));
}
}
/// The seed check gates an irreversible overwrite, so it has to be
/// tolerant of trailing whitespace and intolerant of everything else.
#[test]
fn only_the_untouched_seed_reads_as_takeable() {
assert!(is_untouched_seed("users: {}"));
assert!(is_untouched_seed("users: {}\n"));
assert!(!is_untouched_seed("users:\n mara:\n"));
assert!(
is_untouched_seed(""),
"a zero-byte file has no users to lose, and authelia will not start \
on it refusing here would strand the deployment"
);
}
}