hyperhive/swarmctl/src/main.rs
atlas 4c7865cd8b docs: trim the reload rationale to the live constraint
Keeps why the setting exists (the bridge writes this file and cannot
restart authelia) and drops the incident narrative and the answered
objections -- that history belongs in the PR discussion, not in three
comment blocks a future reader has to scroll past.
2026-08-17 19:25:27 +02:00

587 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};
/// 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>,
/// Canonical user store.
#[arg(long, value_name = "PATH")]
store: Option<PathBuf>,
}
struct Paths {
authelia_bin: PathBuf,
users_file: PathBuf,
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")?,
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 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 the canonical store.
///
/// Reads `store` only — never authelia's rendered `users.yml`, which
/// is a derived artifact this crate writes and never reads back (see
/// the crate doc comment). 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.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,
},
);
publish(paths, &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.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.store.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, &store)?;
for change in &changes {
println!("{change}");
}
println!("{} is now in groups: {groups}", args.username);
Ok(())
}
/// `swarmctl user list` — read-only, never touches authelia's users file
/// or restarts it. Prints one line per user from the canonical store.
fn user_list(paths: &Paths) -> Result<()> {
use std::fmt::Write as _;
let store = load_store(&paths.store, &paths.users_file)?;
if store.users.is_empty() {
println!("no users in {}", paths.store.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 store + the rendered users file, then restart authelia.
///
/// Shared by every verb that mutates the store, so the ordering rules
/// below hold for all of them rather than for whichever one was written
/// first.
fn publish(paths: &Paths, store: &UserStore) -> Result<()> {
// 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)?;
// 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 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())
}
/// 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();
}
}