diff --git a/CLAUDE.md b/CLAUDE.md index 6017d0b8..ae4f653c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index caba39bb..d93491cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 61341ce6..0ba429a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "hive-types", "hivectl", "swarm-controller", + "swarmctl", ] [workspace.package] diff --git a/flake.nix b/flake.nix index 62abd8ad..141187d8 100644 --- a/flake.nix +++ b/flake.nix @@ -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; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 2522d9c3..49ad4d07 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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/` 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 diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 9449e39b..1919ed7c 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -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" ]; diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 6713ae6e..cb121b0c 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -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. diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml new file mode 100644 index 00000000..adacba4e --- /dev/null +++ b/swarmctl/Cargo.toml @@ -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 diff --git a/swarmctl/README.md b/swarmctl/README.md new file mode 100644 index 00000000..18d64166 --- /dev/null +++ b/swarmctl/README.md @@ -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//cmdline` is world-readable, so a password in argv is readable +by any local process for the lifetime of the call. diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs new file mode 100644 index 00000000..0c767e2d --- /dev/null +++ b/swarmctl/src/main.rs @@ -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, + /// 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, + }, +} + +#[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, + #[arg(long, value_name = "ADDRESS")] + email: Option, + /// Repeatable. + #[arg(long = "group", value_name = "GROUP")] + groups: Vec, +} + +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 { + 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(); + } +} diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs new file mode 100644 index 00000000..16c679ad --- /dev/null +++ b/swarmctl/src/users.rs @@ -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, +} + +#[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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// 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 { + 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" + ); + } +}