hyperhive/swarmctl/src/main.rs
atlas 6ca4887af4 docs(#3422): the user store is one file, not two
Six places asserted the old design as fact, and none of them mention the
change by name -- the class of doc breakage that is found by asking what
a diff made untrue, not by grepping for a feature:

- swarmctl/README.md and swarm-authelia-bridge/README.md both described
  their own private canonical store. The bridge's "known limitation"
  section described the seam as unsolved; it is what this fixes, so it
  becomes what both writers must uphold instead.
- docs/swarm/{sso,ui,secrets}.md described a rendered artifact.
- The repo CLAUDE.md entry for swarmctl said the same.
- docs/tools/swarmctl-cli.md is regenerated (CI diffs it against the
  clap tree), picking up the removed --store flag.

Operator-facing where it is read: the hand-editing consequence (values
survive a rewrite, comments do not) is stated in sso.md, where an
operator is being told to edit the file, rather than only in a module doc.
2026-08-18 10:34:00 +02:00

585 lines
22 KiB
Rust

//! `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};
#[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.
///
/// This is the only user store: it is read before every change and
/// written in place, and `swarm-authelia-bridge` writes the same file.
//
// The `--store` flag that named a second, private JSON store is gone
// rather than deprecated — a flag whose only remaining effect would be
// nothing reads as accepted and does nothing, where an unknown-argument
// error is loud. Not in the doc comment: `--help` is an operator
// surface, and the removal's reasoning belongs in the README.
#[arg(long, value_name = "PATH")]
users_file: Option<PathBuf>,
}
struct Paths {
authelia_bin: PathBuf,
users_file: 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")?,
})
}
}
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 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,
},
/// Emit the full CLI reference as `CommonMark` to stdout.
///
/// Hidden tooling command used by the docs build to keep the published
/// `swarmctl` reference in lockstep with the code — same pattern as
/// `hivectl markdown-docs` (`hivectl/src/main.rs`). Deliberately
/// dispatched *before* `PathArgs::resolve()` in `main` below: this
/// verb needs none of the `SWARMCTL_AUTHELIA_*` deployment env vars,
/// and requiring them here would make `swarmctl markdown-docs` fail
/// outside a real deployment — exactly where the docs build runs it.
#[command(hide = true)]
MarkdownDocs,
/// Generate a shell completion script for `swarmctl` and print it to
/// stdout.
///
/// Supports bash, zsh, fish, elvish and powershell. The nix package
/// already installs bash/zsh/fish system-wide; this is for ad-hoc or
/// other-shell use.
///
/// Dispatched before `PathArgs::resolve()` for the same reason as
/// `markdown-docs`: emitting a completion script needs none of the
/// `SWARMCTL_AUTHELIA_*` deployment env vars, and requiring them would
/// make the package's own build-time invocation fail.
Completions {
/// Shell to emit completions for.
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
enum UserVerb {
/// Add a user, generating a password for them.
Add(AddArgs),
/// Change an existing user's attributes.
///
/// Every flag is optional and they compose, so one call can set
/// several things at once. Deliberately does **not** touch the
/// password: regenerating a credential is a different intent from
/// editing an attribute, and folded together an attribute edit can
/// invalidate a login by accident.
Update(UpdateArgs),
/// List every user in authelia's users database.
///
/// Read-only: it never writes the file. Shows every subject in it,
/// including agent identities `swarm-authelia-bridge` created — one
/// line per user: username, display name, email (if set), groups (if
/// any).
List,
}
#[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>,
}
#[derive(Args)]
struct UpdateArgs {
/// Login name of an existing user.
username: String,
/// Name shown in the SSO UI.
#[arg(long, value_name = "TEXT")]
display_name: Option<String>,
#[arg(long, value_name = "ADDRESS")]
email: Option<String>,
/// Repeatable. Adding a group the user is already in is not an error.
#[arg(long = "add-group", value_name = "GROUP")]
add_groups: Vec<String>,
/// Repeatable. Fails if the user is not in the group — a revocation
/// that reports success without revoking is the failure nobody
/// re-checks.
#[arg(long = "remove-group", value_name = "GROUP")]
remove_groups: Vec<String>,
}
fn main() -> Result<()> {
let Cli { paths, command } = Cli::parse();
match command {
// Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong.
Verb::User {
command: UserVerb::Add(args),
} => user_add(&paths.resolve()?, args),
Verb::User {
command: UserVerb::Update(args),
} => user_update(&paths.resolve()?, args),
Verb::User {
command: UserVerb::List,
} => user_list(&paths.resolve()?),
Verb::MarkdownDocs => {
print!("{}", clap_markdown::help_markdown::<Cli>());
Ok(())
}
Verb::Completions { shell } => {
// Generated from the live clap tree — the same single source
// of truth `markdown-docs` renders — so completions cannot
// drift from the actual verbs and flags.
use clap::CommandFactory as _;
clap_complete::generate(
shell,
&mut Cli::command(),
"swarmctl",
&mut std::io::stdout(),
);
Ok(())
}
}
}
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?;
let mut store = load_store(&paths.users_file)?;
if store.users.contains_key(&args.username) {
bail!(
"user {:?} already exists in {}",
args.username,
paths.users_file.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,
extra: std::collections::BTreeMap::new(),
},
);
publish(paths, &mut store)?;
println!("added {} to {}", args.username, paths.users_file.display());
println!("password: {}", generated.password);
println!("this password is stored nowhere — record it now");
Ok(())
}
fn user_update(paths: &Paths, args: UpdateArgs) -> Result<()> {
let mut store = load_store(&paths.users_file)?;
let Some(user) = store.users.get_mut(&args.username) else {
bail!(
"no user {:?} in {} — `swarmctl user add` creates one",
args.username,
paths.users_file.display()
);
};
let changes = users::apply_update(
user,
&users::UserUpdate {
displayname: args.display_name,
email: args.email,
add_groups: args.add_groups,
remove_groups: args.remove_groups,
},
)?;
// Read back before the borrow ends: this is what the operator gets
// instead of a group registry we don't have — a typo'd `--add-group`
// is a real group with nobody reading it, and seeing the resulting
// list is the only way to notice.
let groups = users::fmt_groups(&user.groups);
publish(paths, &mut store)?;
for change in &changes {
println!("{change}");
}
println!("{} is now in groups: {groups}", args.username);
Ok(())
}
/// `swarmctl user list` — read-only: it loads authelia's users file and
/// writes nothing. One line per user, agent identities included.
fn user_list(paths: &Paths) -> Result<()> {
use std::fmt::Write as _;
let store = load_store(&paths.users_file)?;
if store.users.is_empty() {
println!("no users in {}", paths.users_file.display());
return Ok(());
}
for (username, user) in &store.users {
let mut line = format!("{username}\t{}", user.displayname);
if let Some(email) = &user.email {
let _ = write!(line, "\t{email}");
}
if !user.groups.is_empty() {
let _ = write!(line, "\tgroups: {}", users::fmt_groups(&user.groups));
}
println!("{line}");
}
Ok(())
}
/// Write the users database.
///
/// Shared by every verb that mutates it, so the rules below hold for all of
/// them rather than for whichever one was written first.
fn publish(paths: &Paths, store: &mut UserStore) -> Result<()> {
// Before rendering, not at creation: a user can also arrive by being
// *read* — from a file the bridge wrote, or one an operator edited —
// and an authelia subject with no `email` breaks any relying party that
// asks for the claim (grafana's OIDC login is the measured case: it
// fails outright rather than degrading). Filling it here is the only
// place no entry point can skip.
for name in users::fill_missing_emails(store) {
println!("note: {name} had no email; set to a synthetic address");
}
// Render before writing: a value this refuses to emit should stop the
// whole operation rather than land a partial file.
let rendered = users::render_yaml(store)?;
write_atomic(&paths.users_file, &rendered)?;
// No restart: authelia watches this file
// (`authentication_backend.file.watch`). Deliberately not `swarmctl`'s job
// — `swarm-authelia-bridge` writes the same file and *cannot* restart
// anything, since running unprivileged is the whole reason it may write
// it. A reload that depends on which process wrote is not a reload.
Ok(())
}
/// Load the users database, or start empty when it does not exist yet.
///
/// ⚠️ **The overwrite guard that used to live here is gone, and its absence
/// is the fix rather than a regression.** It refused to write when this
/// crate's own JSON store was missing but `users.yml` held users — it
/// existed to police *two* stores that could disagree, and there is now one.
/// Reading the same file we are about to write means we cannot clobber users
/// we did not know about: we just read them.
///
/// An absent or empty file is an empty store, not an error. First boot is a
/// legitimate state, and the seed document authelia's own unit writes
/// (`users: {}`) deserialises to exactly that with no special case.
fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) {
Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
Ok(raw) => serde_norway::from_str(&raw)
.with_context(|| format!("parsing the users database at {}", users_file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", users_file.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())
}
/// 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();
}
/// Replaces `load_store_refuses_to_take_over_a_populated_users_file`,
/// which pinned the guard this change removes.
///
/// The guard refused to write when this crate's own JSON store was
/// missing but `users.yml` held users — the state every real hive is in,
/// and the reason `swarm agent create` failed. Deleting the test with
/// the guard would have left the *new* behaviour unpinned, so it is
/// inverted rather than dropped: a populated file must now be **read**,
/// and users somebody else wrote must survive.
#[test]
fn a_populated_users_file_is_read_rather_than_refused() {
let dir = std::env::temp_dir().join(format!("swarmctl-takeover-{}", std::process::id()));
fs::create_dir_all(&dir).expect("temp dir");
let users_file = dir.join("users.yml");
fs::write(
&users_file,
"users:\n mara:\n displayname: \"mara\"\n password: \"x\"\n",
)
.expect("write");
let store = load_store(&users_file).expect("a populated file is readable");
assert!(
store.users.contains_key("mara"),
"a user this process did not write must be read, not refused"
);
fs::write(&users_file, "users: {}\n").expect("seed");
assert!(
load_store(&users_file)
.expect("the seed loads")
.users
.is_empty(),
"the first-boot seed is an empty store, with no special case"
);
fs::remove_dir_all(&dir).ok();
}
}