//! `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, /// 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, /// Machine name of the authelia container, for `systemctl -M`. #[arg(long, value_name = "NAME")] machine: Option, /// authelia's systemd unit inside that container. #[arg(long, value_name = "UNIT")] unit: Option, /// Canonical user store. #[arg(long, value_name = "PATH")] store: Option, } struct Paths { authelia_bin: PathBuf, users_file: PathBuf, machine: String, unit: String, store: 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")?, 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, env: &str) -> Result { flag.or_else(|| std::env::var_os(env).map(PathBuf::from)) .with_context(|| missing(env)) } fn string_from(flag: Option, env: &str) -> Result { 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, }, /// 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), } #[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::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.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(()) } /// 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)?; restart_authelia(&paths.machine, &paths.unit) } /// 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 { 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 ` 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()) } /// 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(); } }