//! `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, /// 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, } struct Paths { authelia_bin: PathBuf, users_file: PathBuf, } impl PathArgs { fn resolve(self) -> Result { 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, env: &str) -> Result { 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, #[arg(long, value_name = "ADDRESS")] email: Option, /// Repeatable. #[arg(long = "group", value_name = "GROUP")] groups: Vec, } #[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, #[arg(long, value_name = "ADDRESS")] email: Option, /// Repeatable. Adding a group the user is already in is not an error. #[arg(long = "add-group", value_name = "GROUP")] add_groups: Vec, /// 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, } 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::()); 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 { 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 ` is a security requirement, /// not a convenience: `/proc//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 { 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 { 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(); } }