//! `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 this binary **serves** no socket, publishes no HTTP route and has //! no privileged helper. //! //! Acting directly is not the same as acting *alone*, though: the `agent`, //! `schedule` and `forge` verbs are clients of the controller, because the //! work they ask for is only the controller's to do (see [`agent`]). They //! reach it over its unix socket on its host, or, with `--swarm`, through //! the swarm domain's gateway as the operator's own SSO account //! ([`remote`]). The `user` verbs act on the local users file only. //! //! 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 agent; mod agents_md; mod forge; mod inference; mod login; mod permissions; mod remote; mod schedule; mod users; use std::fs::{self, File, Permissions}; use std::io::{self, Write as _}; use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand}; use remote::Target; use users::{User, UserStore}; #[derive(Parser)] #[command(name = "swarmctl", version, about = "swarm-level operator CLI")] struct Cli { #[command(flatten)] paths: PathArgs, #[command(flatten)] remote: RemoteArgs, #[command(subcommand)] command: Verb, } /// Where a verb that can reach a swarm looks for it when `--swarm` is absent. const SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM"]; /// The same for `login`. The swarm-controller nix module sets /// `SWARMCTL_LOGIN_DEFAULT_SWARM` to its host's own swarm; setting /// `SWARMCTL_SWARM` there instead would send every other verb through the /// gateway rather than the local socket. const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SWARM"]; /// Which swarm to reach through its domain rather than the local socket. #[derive(Args)] struct RemoteArgs { /// The swarm's domain, as `https://` or the bare domain. Given, /// the `agent`, `schedule` and `forge` verbs go through its gateway as the /// account `swarmctl login` logged in, instead of over the controller's /// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to /// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module /// sets to its host's own swarm. #[arg(long, value_name = "URL", global = true)] swarm: Option, /// A PEM CA certificate to trust beside the system roots, for a swarm /// whose gateway serves a certificate from the swarm's own CA. Falls back /// to `SWARMCTL_CA_FILE`. #[arg(long, value_name = "PATH", global = true)] ca_file: Option, } impl RemoteArgs { /// `--swarm`, else the first of `envs` that `env` finds set and /// non-empty. An empty `--swarm` names no swarm and skips the env. fn swarm_url(&self, envs: &[&str], env: impl Fn(&str) -> Option) -> Option { match &self.swarm { Some(flag) => Some(flag.clone()), None => envs .iter() .find_map(|name| env(name).filter(|u| !u.is_empty())), } .filter(|u| !u.is_empty()) } fn swarm(&self) -> Result> { self.swarm_from(SWARM_ENVS) } fn login_swarm(&self) -> Result> { self.swarm_from(LOGIN_SWARM_ENVS) } fn swarm_from(&self, envs: &[&str]) -> Result> { let url = self.swarm_url(envs, |name| std::env::var(name).ok()); let ca_file = self .ca_file .clone() .or_else(|| std::env::var_os("SWARMCTL_CA_FILE").map(PathBuf::from)); url.map(|u| remote::Swarm::parse(&u, ca_file, remote::state_dir()?)) .transpose() } /// The swarm when one is named, the controller's socket otherwise. fn target(&self, socket: Option) -> Result { Ok(match self.swarm()? { Some(swarm) => Target::Swarm(swarm), None => Target::Socket(path_from(socket, "SWARM_CONTROLLER_SOCKET")?), }) } } /// 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 — that is, the path /// inside the container, prefixed with the container's root. /// /// This is the only user store: it's 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 agents across the swarm. Agent { #[command(subcommand)] command: AgentVerb, }, /// Manage scheduled prompts. /// /// The swarm keeps each prompt, and every agent it names fires it in its /// own container. Fire times are the schedule's start time plus whole /// intervals: changing the body never moves them, and an agent that was /// down fires the latest missed one once. Schedule { #[command(subcommand)] command: ScheduleVerb, }, /// Manage subjects in the swarm's SSO provider. User { #[command(subcommand)] command: UserVerb, }, /// Manage human accounts on the swarm's forge. Forge { #[command(subcommand)] command: ForgeVerb, }, /// Log in to the swarm named by `--swarm` as your own SSO account. /// /// With no `--swarm` or `SWARMCTL_SWARM`, logs in to /// `SWARMCTL_LOGIN_DEFAULT_SWARM`: on a swarm-controller host, the swarm /// that host belongs to. /// /// Opens the swarm's SSO login in your browser (and prints the URL) and /// receives the answer on a loopback port, so run it where that browser /// runs. swarmctl keeps the login, mode 0600, under /// `$XDG_STATE_HOME/swarmctl/` and refreshes it as it's used. To log out, /// delete that file. Login, /// 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 AgentVerb { /// Queue creation of a new agent on a hive in this swarm. /// /// Asks the swarm-controller to insert its agent-creation job graph — /// SSO identity, forge user, config repo, and the deploy message that /// puts the agent on `--hive` — and prints the queued job's node id. /// /// **This returns as soon as it queues the work.** It doesn't wait, /// and a finished graph would not mean the agent is up either: the /// last node publishes a deploy, after which the hive converges on its /// own clock. Watch the swarm UI's job view, or the hive itself, for /// the rest. /// /// No approval gate guards this: the caller is root on the controller's /// host, or logged in through the swarm domain's SSO gate. Create(AgentCreateArgs), /// Check one agent's forge token, and mint it if it's missing or stale. /// /// swarm-controller does this for every agent with a store identity at /// start and every five minutes; this is for when waiting isn't an /// option. It leaves a current token alone. /// /// Queues and returns, the same way `agent create` does — watch the /// swarm UI's job view for the outcome. MintForgeToken(AgentMintForgeTokenArgs), /// Show the model, effort, subagent models and subagent cap the swarm sets /// for an agent, or change the model, effort and subagent models. /// /// `get` prints the subagent cap (`max_subagents`); `set` has no flag for /// it and keeps a stored one. Only swarm-ui sets it. /// /// A field the swarm sets wins over the agent's own value (its agent.nix /// or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on; /// the agent reads its settings every ten seconds. A field the swarm /// doesn't set, or clears, leaves the agent on its own value. Inference { #[command(subcommand)] command: InferenceVerb, }, /// Show or change the permissions the swarm grants an agent. /// /// The agent applies its tool permissions at its next turn; the hive /// running it restarts it, after its current turn, when /// `manage_root_agent` changes. Unset leaves the agent on the default: /// `messaging`, `meta`, `inbox`, `execution`, whatever its name. Permissions { #[command(subcommand)] command: PermissionsVerb, }, /// Show an agent's AGENTS.md, or edit it with a diff. /// /// `get` shows the copy the agent last published, so it works while the /// agent isn't running. `apply` hands the diff to the agent, which applies /// it to the file with `git apply`; it needs the agent running. AgentsMd { #[command(subcommand)] command: AgentsMdVerb, }, } #[derive(Subcommand)] enum AgentsMdVerb { /// Print an agent's AGENTS.md on stdout, and its revision on stderr. Get(InferenceAgentArgs), /// Apply a unified diff to an agent's AGENTS.md. /// /// The diff names `a/AGENTS.md` and `b/AGENTS.md`, as /// `diff -u a/AGENTS.md b/AGENTS.md` writes it. The agent refuses it when /// the file is no longer at `--base`, or when it doesn't apply, and /// leaves the file as it was. Apply(AgentsMdApplyArgs), } #[derive(Args)] struct AgentsMdApplyArgs { /// Agent name. agent: String, /// The file holding the diff. diff_file: PathBuf, /// The revision you made the diff against, as `get` printed it. It's /// the git blob id of that text, so `git hash-object` of the unedited /// file prints it too. #[arg(long, value_name = "REVISION")] base: String, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Subcommand)] enum PermissionsVerb { /// Show the permissions the swarm grants an agent, or that it sets none. Get(InferenceAgentArgs), /// Replace the permissions the swarm grants an agent. Set(PermissionsSetArgs), /// Clear the agent's permissions, so the default applies again. Clear(InferenceAgentArgs), } #[derive(Args)] struct PermissionsSetArgs { /// Agent name. A hive must declare it. agent: String, /// The permissions to grant, comma-separated: any of `messaging`, /// `meta`, `inbox`, `execution`, `web_tools`, `manage_root_agent`. /// `--permissions ''` grants none. #[arg( long, value_name = "PERMISSIONS", value_delimiter = ',', required = true )] permissions: Vec, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Subcommand)] enum InferenceVerb { /// Show what the swarm sets for an agent, naming the fields it doesn't /// set, and the model and effort the agent runs on, each with where it /// comes from. Get(InferenceAgentArgs), /// Set fields for an agent. Only the flags given change; the others keep /// what's stored. Set(InferenceSetArgs), /// Clear every field for an agent, so it goes back to its own values. Clear(InferenceAgentArgs), } #[derive(Args)] struct InferenceAgentArgs { /// Agent name. agent: String, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct InferenceSetArgs { /// Agent name. A hive must declare it. agent: String, /// The model its turns run on, as claude's `--model` takes it. #[arg(long, value_name = "MODEL")] model: Option, /// The reasoning effort its turns run at. #[arg(long, value_name = "LEVEL", value_parser = ["low", "medium", "high", "xhigh", "max"])] effort: Option, /// The models it may start subagents on, comma-separated. Stored and /// shown; the agent doesn't apply it yet. #[arg(long, value_name = "MODELS", value_delimiter = ',')] subagent_models: Option>, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct AgentCreateArgs { /// Name for the new agent: 1–63 characters of `[a-z0-9-]`. /// /// Becomes an SSO subject, a forge user and a repository name, so /// it's validated here before queuing. The controller refuses a name /// the swarm has already placed on a different hive; the same name on /// the same hive re-creates that agent. name: String, /// Hive in this swarm to deploy the agent to. /// /// Required, and deliberately not defaulted: it's an *address* — the /// hive that gets the deploy message — and only the operator knows /// which one they mean. The controller checks it against the swarm's /// hive roster and names the known hives if it misses. #[arg(long, value_name = "HIVE")] hive: String, /// A text file the agent's `AGENTS.md` starts with. /// /// The agent writes it on its first start, and only when it has no /// `AGENTS.md` yet: re-creating an agent never replaces an existing file. /// The controller refuses a file over 64 KiB. #[arg(long, value_name = "FILE")] agents_md: Option, /// swarm-controller's unix socket. /// /// Supplied by the nix module that installs this binary, from the same /// `socketPath` option the daemon binds; falls back to /// `SWARM_CONTROLLER_SOCKET`. #[arg(long, value_name = "PATH")] controller_socket: Option, } #[derive(Args)] struct AgentMintForgeTokenArgs { /// Name of an agent that already exists. name: String, /// swarm-controller's unix socket. /// /// Supplied by the nix module that installs this binary, from the same /// `socketPath` option the daemon binds; falls back to /// `SWARM_CONTROLLER_SOCKET`. #[arg(long, value_name = "PATH")] controller_socket: Option, } #[derive(Subcommand)] enum ForgeVerb { /// Make an existing forge user a site admin. /// /// The forge creates a human's account on their first SSO login, and /// this fails until that login has happened. Running it on a site admin /// changes nothing. Refused for an agent. MakeAdmin(ForgeMakeAdminArgs), } #[derive(Args)] struct ForgeMakeAdminArgs { /// Forge username, the same as the user's SSO username. name: String, /// swarm-controller's unix socket. /// /// Supplied by the nix module that installs this binary, from the same /// `socketPath` option the daemon binds; falls back to /// `SWARM_CONTROLLER_SOCKET`. #[arg(long, value_name = "PATH")] controller_socket: Option, } #[derive(Subcommand)] enum ScheduleVerb { /// Add a scheduled prompt and print it with its new id. /// /// Without `--every` it fires once. Without `--at` its first fire is now. Add(ScheduleAddArgs), /// List scheduled prompts, one row each, with their agents and next fire /// time. List(ScheduleListArgs), /// Change a scheduled prompt. /// /// Only the flags given change, and the id stays. `--at` and `--every` /// move the fire times; `--name`, `--body` and the agent flags don't. Edit(ScheduleEditArgs), /// Stop a scheduled prompt firing. Times that pass while it's paused /// aren't fired when it resumes. Pause(ScheduleIdArgs), /// Let a paused scheduled prompt fire again, from its next time on. Resume(ScheduleIdArgs), /// Remove a scheduled prompt from every agent it names. Rm(ScheduleIdArgs), } #[derive(Args)] struct ScheduleAddArgs { /// The prompt's title. #[arg(long, value_name = "TEXT")] name: String, /// Agent to fire it in. Repeatable; at least one. #[arg(long = "agent", value_name = "AGENT", required = true)] agents: Vec, /// The prompt, delivered to each agent verbatim. #[arg(long, value_name = "TEXT")] body: String, /// First fire time: an RFC 3339 time with its offset, such as /// `2026-10-05T09:00:00+02:00`, or unix seconds. Defaults to now. #[arg(long, value_name = "TIME")] at: Option, /// Time between fires: a count with a unit of `s`, `m`, `h` or `d`, /// such as `1h` or `7d`. Leave out for a one-shot. #[arg(long, value_name = "DURATION")] every: Option, /// Add it paused. #[arg(long)] paused: bool, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct ScheduleListArgs { /// Show only the prompts that fire in this agent. #[arg(long, value_name = "AGENT")] agent: Option, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct ScheduleEditArgs { /// The prompt's id, as `schedule list` shows it. id: String, /// New title. #[arg(long, value_name = "TEXT")] name: Option, /// New prompt text. #[arg(long, value_name = "TEXT")] body: Option, /// New first fire time, in the form `schedule add --at` takes. #[arg(long, value_name = "TIME")] at: Option, /// New time between fires, in the form `schedule add --every` takes. #[arg(long, value_name = "DURATION", conflicts_with = "once")] every: Option, /// Make it a one-shot. #[arg(long)] once: bool, /// Agent to start firing it in. Repeatable. #[arg(long = "add-agent", value_name = "AGENT")] add_agents: Vec, /// Agent to stop firing it in. Repeatable. Fails if the agent isn't a /// target, or if no agent would remain. #[arg(long = "remove-agent", value_name = "AGENT")] remove_agents: Vec, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct ScheduleIdArgs { /// The prompt's id, as `schedule list` shows it. id: String, #[command(flatten)] socket: ControllerSocketArg, } #[derive(Args)] struct ControllerSocketArg { /// swarm-controller's unix socket. /// /// Supplied by the nix module that installs this binary, from the same /// `socketPath` option the daemon binds; falls back to /// `SWARM_CONTROLLER_SOCKET`. #[arg(long, value_name = "PATH")] controller_socket: Option, } impl ControllerSocketArg { fn resolve(self, remote: &RemoteArgs) -> Result { remote.target(self.controller_socket) } } #[derive(Subcommand)] enum UserVerb { /// Add a user, generating a password for them. Add(AddArgs), /// Set a new password for an existing user, generating it the same way /// `user add` does. /// /// authelia's file store has no self-service reset (no SMTP notifier), /// so this is the only way a human account gets a new password once an /// operator forgets the old one. Refuses if the user doesn't exist — /// there's no separate "create" path here. ResetPassword(ResetPasswordArgs), /// Change an existing user's attributes. /// /// Every flag is optional and they compose, so one call can set /// multiple things at once. Deliberately **doesn't** 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's 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 ResetPasswordArgs { /// Login name of an existing user. username: 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, #[arg(long, value_name = "ADDRESS")] email: Option, /// Repeatable. Adding a group the user is already in isn't an error. #[arg(long = "add-group", value_name = "GROUP")] add_groups: Vec, /// Repeatable. Fails if the user isn't 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, remote, command, } = Cli::parse(); match command { // Resolves its own socket path, not `PathArgs`: this verb needs // none of the `SWARMCTL_AUTHELIA_*` values, and requiring them // would make agent creation fail on a controller host that is not // also the swarm's SSO host. Verb::Agent { command: AgentVerb::Create(args), } => { let target = remote.target(args.controller_socket)?; agent::create(&target, &args.name, &args.hive, args.agents_md.as_deref()) } // Same socket-resolution reasoning as `Create` above. Verb::Agent { command: AgentVerb::MintForgeToken(args), } => { let target = remote.target(args.controller_socket)?; agent::mint_forge_token(&target, &args.name) } Verb::Agent { command: AgentVerb::Inference { command }, } => inference(&remote, command), Verb::Agent { command: AgentVerb::Permissions { command }, } => permissions(&remote, command), Verb::Agent { command: AgentVerb::AgentsMd { command }, } => agents_md(&remote, command), // Same socket-resolution reasoning as `Create` above. Verb::Forge { command: ForgeVerb::MakeAdmin(args), } => { let target = remote.target(args.controller_socket)?; forge::make_admin(&target, &args.name) } // Same socket-resolution reasoning as `Create` above. Verb::Schedule { command } => schedule(&remote, command), Verb::Login => login::login( &remote .login_swarm()? .context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?, ), // 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::ResetPassword(args), } => user_reset_password(&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 => { // `show_footer(false)`: drop clap-markdown's own fixed // "This document was generated automatically by..." footer — // it's the one string in this doc that isn't ours to reword. let options = clap_markdown::MarkdownOptions::new().show_footer(false); print!("{}", clap_markdown::help_markdown_custom::(&options)); 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 inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> { match command { InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent), InferenceVerb::Set(a) => inference::set( &a.socket.resolve(remote)?, &a.agent, inference::SetFlags { model: a.model, effort: a.effort, subagent_models: a.subagent_models, }, ), InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve(remote)?, &a.agent), } } fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> { match command { PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent), PermissionsVerb::Set(a) => { permissions::set(&a.socket.resolve(remote)?, &a.agent, &a.permissions) } PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent), } } fn agents_md(remote: &RemoteArgs, command: AgentsMdVerb) -> Result<()> { match command { AgentsMdVerb::Get(a) => agents_md::get(&a.socket.resolve(remote)?, &a.agent), AgentsMdVerb::Apply(a) => { agents_md::apply(&a.socket.resolve(remote)?, &a.agent, &a.base, &a.diff_file) } } } fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> { match command { ScheduleVerb::Add(a) => schedule::add( &a.socket.resolve(remote)?, &schedule::AddFlags { name: &a.name, agents: &a.agents, body: &a.body, at: a.at.as_deref(), every: a.every.as_deref(), paused: a.paused, }, ), ScheduleVerb::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()), ScheduleVerb::Edit(a) => { let edit = schedule::edit_request(schedule::EditFlags { name: a.name, body: a.body, at: a.at.as_deref(), every: a.every.as_deref(), once: a.once, add_agents: a.add_agents, remove_agents: a.remove_agents, })?; schedule::edit(&a.socket.resolve(remote)?, &a.id, &edit) } ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, true), ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, false), ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve(remote)?, &a.id), } } fn user_add(paths: &Paths, args: AddArgs) -> Result<()> { users::validate_username(&args.username)?; let _lock = lock_users_file(&paths.users_file)?; 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_reset_password(paths: &Paths, args: &ResetPasswordArgs) -> Result<()> { let _lock = lock_users_file(&paths.users_file)?; 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 generated = generate_password(&paths.authelia_bin)?; users::reset_password(user, generated.digest); publish(paths, &mut store)?; println!( "reset password for {} in {}", 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 _lock = lock_users_file(&paths.users_file)?; 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, and so is /// `users: {}`, with no special case. The `swarm-authelia` module seeds an /// empty database with one disabled placeholder subject, which loads like any /// other user. 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()) } /// How long a writer waits for the other process's `users.yml` update. const USERS_LOCK_WAIT: Duration = Duration::from_secs(10); /// Take the exclusive `flock` on `.lock`, held until the /// returned file drops. /// /// `swarm-authelia-bridge` rewrites the same file from inside the /// `swarm-authelia` container and takes the same lock on the same inode. /// Every verb holds it across load → modify → [`publish`]: the rename alone /// would not stop two writers loading the same snapshot, and the second /// rename would drop the first one's user. /// /// The lock file is a sibling of `users_file`, not `users_file` itself, /// because `write_atomic` replaces that inode on every write. The directory /// belongs to a less-privileged uid, so see [`open_lock_file`] for how the /// open resists a planted link. Only a file this call created is handed to /// the directory's owner (`fchown` by descriptor), so that uid can open it /// too; an existing entry is never chowned. fn lock_users_file(users_file: &Path) -> Result { lock_users_file_within(users_file, USERS_LOCK_WAIT) } /// Open `.lock`, reporting whether this call created it. /// /// `O_EXCL` first, so a file that is ours is told apart from one that was /// already there. An existing entry is reopened read-only: `flock` needs no /// write access, and a lock file left behind owned by root (a crash between /// create and `fchown`) stays openable by the bridge. An existing entry must /// be a regular file with one link; a hard link planted by the directory's /// owner to some other file is refused. fn open_lock_file(lock_path: &Path) -> Result<(File, bool)> { let nofollow = (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK) .bits() .cast_signed(); for _ in 0..2 { // Mode 0644 so the lock stays readable by the bridge if we die before // `fchown`; the directory is 0700, so nobody else reaches it. match fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o644) .custom_flags(nofollow) .open(lock_path) { Ok(file) => return Ok((file, true)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e).with_context(|| format!("creating {}", lock_path.display())), } match fs::OpenOptions::new() .read(true) .custom_flags(nofollow) .open(lock_path) { Ok(file) => { let meta = file .metadata() .with_context(|| format!("stat {}", lock_path.display()))?; if !meta.is_file() { bail!("{} is not a regular file", lock_path.display()); } if meta.nlink() != 1 { bail!( "{} has {} hard links; refusing to lock a file that is not ours", lock_path.display(), meta.nlink() ); } return Ok((file, false)); } // Removed between the two opens: create it again. Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("opening {}", lock_path.display())), } } bail!("{} keeps changing under us", lock_path.display()) } fn lock_users_file_within(users_file: &Path, wait: Duration) -> Result { let dir = users_file .parent() .with_context(|| format!("{} has no parent directory", users_file.display()))?; fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; let name = users_file .file_name() .with_context(|| format!("{} has no file name", users_file.display()))?; let mut lock_name = name.to_os_string(); lock_name.push(".lock"); let lock_path = dir.join(lock_name); let (file, created) = open_lock_file(&lock_path)?; if created { let dir_meta = fs::metadata(dir).with_context(|| format!("stat {}", dir.display()))?; std::os::unix::fs::fchown(&file, Some(dir_meta.uid()), Some(dir_meta.gid())) .with_context(|| format!("setting owner on {}", lock_path.display()))?; } let deadline = Instant::now() + wait; loop { match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) { Ok(()) => return Ok(file), Err(e) if e == rustix::io::Errno::WOULDBLOCK && Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(50)); } Err(e) if e == rustix::io::Errno::WOULDBLOCK => bail!( "timed out after {wait:?} waiting for {}: another process (the \ authelia bridge or another `swarmctl user` verb) is updating {}", lock_path.display(), users_file.display() ), Err(e) => { return Err(e).with_context(|| format!("locking {}", lock_path.display())); } } } } /// 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 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, tmp) = create_temp(dir, &name.to_string_lossy())?; let written = (|| -> Result<()> { file.write_all(contents.as_bytes()) .with_context(|| format!("writing {}", tmp.display()))?; // Owner before mode: chown(2) clears setuid/setgid bits. if let Some(meta) = existing.as_ref() { std::os::unix::fs::fchown(&file, Some(meta.uid()), Some(meta.gid())) .with_context(|| format!("setting owner on {}", tmp.display()))?; } file.set_permissions(Permissions::from_mode(mode)) .with_context(|| format!("setting mode on {}", tmp.display()))?; file.sync_all() .with_context(|| format!("flushing {}", tmp.display()))?; fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display())) })(); if written.is_err() { let _ = fs::remove_file(&tmp); } written?; // 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(()) } /// Creates `...swarmctl.tmp` in `dir`, mode 0600. /// /// `dir` can be writable by a less-privileged uid (authelia's state /// directory), so the name must be unguessable and the open must not /// follow a link planted there: see [`open_new_nofollow`]. fn create_temp(dir: &Path, name: &str) -> Result<(File, PathBuf)> { for _ in 0..8 { let mut raw = [0u8; 8]; getrandom::fill(&mut raw) .map_err(|e| anyhow::anyhow!("drawing randomness from the kernel: {e}"))?; let suffix = format!("{:016x}", u64::from_le_bytes(raw)); let tmp = dir.join(format!(".{name}.{suffix}.swarmctl.tmp")); match open_new_nofollow(&tmp) { Ok(file) => return Ok((file, tmp)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e).with_context(|| format!("creating {}", tmp.display())), } } bail!("no unused temp name for {name} in {}", dir.display()) } /// Opens `tmp` for writing, mode 0600, failing if anything exists there. /// /// `O_EXCL` fails the open on any existing entry, a symlink included, and /// `O_NOFOLLOW` fails it on a symlink in the final component regardless; /// neither truncates or touches the link's target. fn open_new_nofollow(tmp: &Path) -> io::Result { fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits().cast_signed()) .open(tmp) } #[cfg(test)] mod tests { use super::*; /// Root on the controller host, with no `--swarm` and no login: the verbs /// still go over the unix socket, unchanged by the remote path. #[test] fn with_no_swarm_named_the_verbs_use_the_controller_socket() { assert!( std::env::var_os("SWARMCTL_SWARM").is_none(), "this test needs SWARMCTL_SWARM unset" ); let dir = std::env::temp_dir().join(format!("swarmctl-socket-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("dir"); let socket = dir.join("controller.sock"); let listener = std::os::unix::net::UnixListener::bind(&socket).expect("bind"); listener.set_nonblocking(true).expect("nonblocking"); let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let server_seen = std::sync::Arc::clone(&seen); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("runtime"); rt.block_on(async move { let listener = tokio::net::UnixListener::from_std(listener).expect("listener"); let (stream, _) = listener.accept().await.expect("accept"); let service = hyper::service::service_fn( move |req: hyper::Request| { server_seen.lock().expect("seen").push(format!( "{} {} auth={}", req.method(), req.uri(), req.headers().contains_key(hyper::header::AUTHORIZATION) )); async { Ok::<_, std::convert::Infallible>(hyper::Response::new( http_body_util::Full::new(bytes::Bytes::from_static(b"[]")), )) } }, ); let _ = hyper::server::conn::http1::Builder::new() .serve_connection(hyper_util::rt::TokioIo::new(stream), service) .await; }); }); let cli = Cli::try_parse_from([ "swarmctl", "schedule", "list", "--controller-socket", socket.to_str().expect("utf-8"), ]) .expect("parses"); let Verb::Schedule { command: ScheduleVerb::List(args), } = cli.command else { panic!("parsed as another verb"); }; let target = args.socket.resolve(&cli.remote).expect("resolves"); assert!(matches!(&target, Target::Socket(p) if *p == socket)); schedule::list(&target, None).expect("lists over the socket"); assert_eq!( *seen.lock().expect("seen"), ["GET /api/schedules auth=false"] ); fs::remove_dir_all(&dir).expect("cleanup"); } /// clap's own consistency checks over the whole tree — a duplicated /// long flag or a malformed `value_name` panics at parse time in /// production and is otherwise only found by running the binary. #[test] fn the_clap_tree_is_well_formed() { use clap::CommandFactory as _; Cli::command().debug_assert(); } #[test] fn swarm_parses_before_and_after_the_subcommand() { for argv in [ ["swarmctl", "--swarm", "s.test", "login"], ["swarmctl", "login", "--swarm", "s.test"], ] { let cli = Cli::try_parse_from(argv).expect("parses"); assert_eq!(cli.remote.swarm.as_deref(), Some("s.test"), "{argv:?}"); } let cli = Cli::try_parse_from(["swarmctl", "schedule", "list", "--ca-file", "/ca.pem"]) .expect("parses"); assert_eq!(cli.remote.ca_file.as_deref(), Some(Path::new("/ca.pem"))); } #[test] fn login_help_lists_swarm_and_its_env_fallback() { let Err(err) = Cli::try_parse_from(["swarmctl", "login", "--help"]) else { panic!("--help parsed as a command"); }; assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); let help = err.to_string(); assert!(help.contains("--swarm "), "{help}"); assert!(help.contains("SWARMCTL_SWARM"), "{help}"); } /// `--swarm` over `SWARMCTL_SWARM` over the login-only default, and only /// `login` reads the default: anywhere else it would turn the socket verbs /// into gateway calls. #[test] fn the_swarm_resolves_flag_then_env_then_the_login_default() { let env = |set: &'static [(&'static str, &'static str)]| { move |name: &str| { set.iter() .find(|(k, _)| *k == name) .map(|(_, v)| (*v).to_owned()) } }; let both = env(&[ ("SWARMCTL_SWARM", "env.test"), ("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"), ]); let only_default = env(&[ ("SWARMCTL_SWARM", ""), ("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"), ]); let flag = Cli::try_parse_from(["swarmctl", "login", "--swarm", "flag.test"]).expect("parses"); assert_eq!( flag.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(), Some("flag.test") ); let bare = Cli::try_parse_from(["swarmctl", "login"]).expect("parses"); assert_eq!( bare.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(), Some("env.test") ); assert_eq!( bare.remote.swarm_url(SWARM_ENVS, both).as_deref(), Some("env.test") ); assert_eq!( bare.remote .swarm_url(LOGIN_SWARM_ENVS, only_default) .as_deref(), Some("default.test") ); assert_eq!(bare.remote.swarm_url(SWARM_ENVS, only_default), None); let empty = Cli::try_parse_from(["swarmctl", "login", "--swarm", ""]).expect("parses"); assert_eq!(empty.remote.swarm_url(LOGIN_SWARM_ENVS, both), None); } /// A schedule with no agent would fire nowhere, so `--agent` is required /// at parse time, and repeats collect into the target list. #[test] fn schedule_add_takes_one_or_more_agents() { let base = ["swarmctl", "schedule", "add", "--name", "n", "--body", "b"]; assert!(Cli::try_parse_from(base).is_err()); let cli = Cli::try_parse_from(base.iter().copied().chain(["--agent", "a", "--agent", "b"])) .expect("parses"); let Verb::Schedule { command: ScheduleVerb::Add(args), } = cli.command else { panic!("parsed as another verb"); }; assert_eq!(args.agents, ["a", "b"]); } /// `--hive` carries the address the agent is deployed to and the /// endpoint has no default for it, so omitting it has to fail at parse /// time rather than reach the controller as an empty string. #[test] fn agent_create_requires_a_hive() { assert!( Cli::try_parse_from(["swarmctl", "agent", "create", "scribe"]).is_err(), "a create with no --hive must not parse" ); } #[test] fn agent_create_parses_its_name_hive_and_socket() { let cli = Cli::try_parse_from([ "swarmctl", "agent", "create", "scribe", "--hive", "alpha", "--controller-socket", "/run/elsewhere/controller.sock", ]) .expect("the full form parses"); let Verb::Agent { command: AgentVerb::Create(args), } = cli.command else { panic!("expected `agent create`"); }; assert_eq!(args.name, "scribe"); assert_eq!(args.hive, "alpha"); assert_eq!( args.controller_socket.as_deref(), Some(Path::new("/run/elsewhere/controller.sock")) ); } /// The socket is optional on the command line because the nix wrapper /// sets `SWARM_CONTROLLER_SOCKET`; `path_from` is what turns an absent /// pair into an error rather than a guess. #[test] fn an_omitted_socket_flag_leaves_the_env_to_supply_it() { let cli = Cli::try_parse_from(["swarmctl", "agent", "create", "scribe", "--hive", "alpha"]) .expect("the minimal form parses"); let Verb::Agent { command: AgentVerb::Create(args), } = cli.command else { panic!("expected `agent create`"); }; assert!(args.controller_socket.is_none()); } #[test] fn the_forge_token_verb_takes_an_agent_and_no_hive() { let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"]) .expect("the minimal form parses"); let Verb::Agent { command: AgentVerb::MintForgeToken(args), } = cli.command else { panic!("expected `agent mint-forge-token`"); }; assert_eq!(args.name, "scribe"); assert!( Cli::try_parse_from([ "swarmctl", "agent", "mint-forge-token", "scribe", "--hive", "a" ]) .is_err(), "the token has no hive, so the verb must not take one" ); } #[test] fn the_make_admin_verb_takes_a_user_name() { let cli = Cli::try_parse_from(["swarmctl", "forge", "make-admin", "mara"]) .expect("the minimal form parses"); let Verb::Forge { command: ForgeVerb::MakeAdmin(args), } = cli.command else { panic!("expected `forge make-admin`"); }; assert_eq!(args.name, "mara"); assert!(args.controller_socket.is_none()); assert!( Cli::try_parse_from(["swarmctl", "forge", "make-admin"]).is_err(), "the user must be named" ); } #[test] fn reset_password_takes_only_a_username() { let cli = Cli::try_parse_from(["swarmctl", "user", "reset-password", "mara"]) .expect("the minimal form parses"); let Verb::User { command: UserVerb::ResetPassword(args), } = cli.command else { panic!("expected `user reset-password`"); }; assert_eq!(args.username, "mara"); assert!( Cli::try_parse_from(["swarmctl", "user", "reset-password"]).is_err(), "the user must be named" ); } /// The unknown-user check runs before authelia is ever invoked, so this /// exercises it with a bogus `authelia_bin` — it would fail loudly if /// this ever started shelling out before checking the user exists. #[test] fn reset_password_on_an_unknown_user_is_an_error_before_touching_authelia() { let dir = std::env::temp_dir().join(format!("swarmctl-reset-{}", std::process::id())); fs::create_dir_all(&dir).expect("temp dir"); let users_file = dir.join("users.yml"); fs::write(&users_file, "users: {}\n").expect("seed"); let paths = Paths { authelia_bin: PathBuf::from("/nonexistent/authelia"), users_file, }; let err = user_reset_password( &paths, &ResetPasswordArgs { username: "mara".to_owned(), }, ) .expect_err("resetting a user that doesn't exist must fail"); assert!(err.to_string().contains("no user"), "{err}"); fs::remove_dir_all(&dir).ok(); } /// A scratch directory holding a `users.yml` with one user, `carol`, and /// a stand-in `authelia` that takes `delay` seconds to mint a password. fn locked_users_fixture(tag: &str, delay: &str) -> Paths { let dir = std::env::temp_dir().join(format!("swarmctl-lock-{tag}-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("temp dir"); let authelia_bin = dir.join("authelia"); fs::write( &authelia_bin, format!( "#!/bin/sh\nsleep {delay}\nprintf 'Random Password: pw\\nDigest: $argon2id$x\\n'\n" ), ) .expect("fake authelia"); fs::set_permissions(&authelia_bin, Permissions::from_mode(0o755)).expect("chmod"); let users_file = dir.join("users.yml"); fs::write( &users_file, "users:\n carol:\n displayname: carol\n password: x\n groups: []\n", ) .expect("seed"); Paths { authelia_bin, users_file, } } fn add_args(username: &str) -> AddArgs { AddArgs { username: username.to_owned(), display_name: None, email: None, groups: Vec::new(), } } #[test] fn the_users_lock_excludes_a_second_holder_until_it_is_released() { let paths = locked_users_fixture("exclusive", "0"); let held = lock_users_file(&paths.users_file).expect("first lock"); let err = lock_users_file_within(&paths.users_file, Duration::from_millis(200)) .expect_err("a held lock must not be granted twice"); assert!(err.to_string().contains("timed out"), "{err}"); drop(held); lock_users_file_within(&paths.users_file, Duration::from_millis(200)) .expect("a released lock is granted"); fs::remove_dir_all(paths.users_file.parent().unwrap()).ok(); } #[test] fn the_users_lock_refuses_a_symlink_planted_at_the_lock_path() { let paths = locked_users_fixture("symlink", "0"); let dir = paths.users_file.parent().unwrap(); let target = dir.join("victim"); fs::write(&target, "keep").expect("victim"); std::os::unix::fs::symlink(&target, dir.join("users.yml.lock")).expect("plant"); lock_users_file(&paths.users_file).expect_err("a symlinked lock path must be refused"); assert_eq!(fs::read_to_string(&target).expect("read"), "keep"); fs::remove_dir_all(dir).ok(); } #[test] fn an_existing_lock_file_is_opened_not_created_or_chowned() { let paths = locked_users_fixture("existing", "0"); let lock_path = paths.users_file.with_file_name("users.yml.lock"); let (_, created) = open_lock_file(&lock_path).expect("first open"); assert!(created, "a missing lock file is created by this call"); let (_, created) = open_lock_file(&lock_path).expect("second open"); assert!( !created, "an existing lock file must be reported as not ours" ); fs::remove_dir_all(paths.users_file.parent().unwrap()).ok(); } /// The directory's owner can plant a hard link; a root-side `fchown` on it /// would re-own the link's target. #[test] fn the_users_lock_refuses_a_hard_link_planted_at_the_lock_path() { let paths = locked_users_fixture("hardlink", "0"); let dir = paths.users_file.parent().unwrap(); let target = dir.join("victim"); fs::write(&target, "keep").expect("victim"); fs::hard_link(&target, dir.join("users.yml.lock")).expect("plant"); let before = fs::metadata(&target).expect("stat"); let err = lock_users_file(&paths.users_file).expect_err("a linked lock path is refused"); assert!(err.to_string().contains("hard links"), "{err}"); let after = fs::metadata(&target).expect("stat"); assert_eq!((after.uid(), after.gid()), (before.uid(), before.gid())); assert_eq!(fs::read_to_string(&target).expect("read"), "keep"); fs::remove_dir_all(dir).ok(); } /// A lock file left behind without write access (root-owned after a crash /// before `fchown`) can still be locked: `flock` needs only a read fd. #[test] fn a_read_only_lock_file_can_still_be_locked() { let paths = locked_users_fixture("readonly", "0"); let lock_path = paths.users_file.with_file_name("users.yml.lock"); fs::write(&lock_path, "").expect("seed"); fs::set_permissions(&lock_path, Permissions::from_mode(0o444)).expect("chmod"); lock_users_file(&paths.users_file).expect("lock a read-only lock file"); fs::remove_dir_all(paths.users_file.parent().unwrap()).ok(); } /// Both adds load the file before either publishes (each spends 0.5s in /// authelia), so without the lock the second rename drops the first user. #[test] fn two_concurrent_user_adds_both_land() { let paths = std::sync::Arc::new(locked_users_fixture("adds", "0.5")); let handles: Vec<_> = ["alice", "bob"] .into_iter() .map(|name| { let paths = std::sync::Arc::clone(&paths); std::thread::spawn(move || user_add(&paths, add_args(name))) }) .collect(); for handle in handles { handle.join().expect("thread").expect("user add"); } let store = load_store(&paths.users_file).expect("reload"); let names: Vec<_> = store.users.keys().map(String::as_str).collect(); assert_eq!(names, ["alice", "bob", "carol"]); fs::remove_dir_all(paths.users_file.parent().unwrap()).ok(); } #[test] fn every_user_verb_waits_for_the_users_lock() { let paths = std::sync::Arc::new(locked_users_fixture("verbs", "0")); let held = lock_users_file(&paths.users_file).expect("lock"); let (add_tx, add_rx) = std::sync::mpsc::channel(); let (reset_tx, reset_rx) = std::sync::mpsc::channel(); let (update_tx, update_rx) = std::sync::mpsc::channel(); let p = std::sync::Arc::clone(&paths); std::thread::spawn(move || add_tx.send(user_add(&p, add_args("dave")))); let p = std::sync::Arc::clone(&paths); std::thread::spawn(move || { let args = ResetPasswordArgs { username: "carol".to_owned(), }; reset_tx.send(user_reset_password(&p, &args)) }); let p = std::sync::Arc::clone(&paths); std::thread::spawn(move || { let args = UpdateArgs { username: "carol".to_owned(), display_name: None, email: None, add_groups: vec!["ops".to_owned()], remove_groups: Vec::new(), }; update_tx.send(user_update(&p, args)) }); let wait = Duration::from_millis(300); assert!(add_rx.recv_timeout(wait).is_err(), "user add ran unlocked"); assert!( reset_rx.recv_timeout(wait).is_err(), "user reset-password ran unlocked" ); assert!( update_rx.recv_timeout(wait).is_err(), "user update ran unlocked" ); drop(held); let done = Duration::from_secs(5); add_rx .recv_timeout(done) .expect("add finishes") .expect("add"); reset_rx .recv_timeout(done) .expect("reset finishes") .expect("reset"); update_rx .recv_timeout(done) .expect("update finishes") .expect("update"); let store = load_store(&paths.users_file).expect("reload"); assert!(store.users.contains_key("dave")); assert_eq!(store.users["carol"].groups, ["ops"]); fs::remove_dir_all(paths.users_file.parent().unwrap()).ok(); } #[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(); } fn tmp_entries(dir: &Path) -> Vec { let mut names: Vec = fs::read_dir(dir) .expect("read dir") .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) .filter(|n| n.ends_with(".swarmctl.tmp")) .collect(); names.sort(); names } #[test] fn write_atomic_ignores_a_symlink_planted_at_the_old_fixed_temp_name() { let dir = std::env::temp_dir().join(format!("swarmctl-plant-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); 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"); let sentinel = dir.join("sentinel"); fs::write(&sentinel, "keep me\n").expect("sentinel"); fs::set_permissions(&sentinel, Permissions::from_mode(0o644)).expect("chmod"); let planted = dir.join(".users.yml.swarmctl.tmp"); std::os::unix::fs::symlink(&sentinel, &planted).expect("plant"); write_atomic(&path, "users:\n mara:\n").expect("rewrite"); assert_eq!(fs::read_to_string(&sentinel).expect("read"), "keep me\n"); let mode = fs::metadata(&sentinel).expect("stat").permissions().mode() & 0o7777; assert_eq!(mode, 0o644, "the link's target must keep its mode"); assert!( fs::symlink_metadata(&planted) .expect("stat link") .is_symlink(), "the planted link is not ours to remove or rename" ); assert_eq!( fs::read_to_string(&path).expect("read"), "users:\n mara:\n" ); let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777; assert_eq!(mode, 0o640); fs::remove_dir_all(&dir).ok(); } #[test] fn the_temp_open_refuses_a_planted_link_and_leaves_its_target_alone() { let dir = std::env::temp_dir().join(format!("swarmctl-excl-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("temp dir"); let sentinel = dir.join("sentinel"); fs::write(&sentinel, "keep me\n").expect("sentinel"); let to_file = dir.join("to-file"); std::os::unix::fs::symlink(&sentinel, &to_file).expect("plant"); let dangling = dir.join("dangling"); std::os::unix::fs::symlink(dir.join("absent"), &dangling).expect("plant"); for planted in [&to_file, &dangling] { let err = open_new_nofollow(planted).expect_err("must refuse a link"); assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); } assert_eq!(fs::read_to_string(&sentinel).expect("read"), "keep me\n"); assert!( !dir.join("absent").exists(), "a dangling link is not created through" ); // Control: the same call on a free name succeeds, so the refusals // above are the link's doing and not a broken open. let mut fresh = open_new_nofollow(&dir.join("fresh")).expect("free name opens"); fresh.write_all(b"ok").expect("write"); let mode = fs::metadata(dir.join("fresh")) .expect("stat") .permissions() .mode() & 0o7777; assert_eq!(mode, 0o600); fs::remove_dir_all(&dir).ok(); } #[test] fn write_atomic_removes_its_temp_file_when_the_rename_fails() { let dir = std::env::temp_dir().join(format!("swarmctl-cleanup-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("temp dir"); // A non-empty directory at the target makes rename(2) fail. let path = dir.join("users.yml"); fs::create_dir_all(path.join("inner")).expect("blocker"); write_atomic(&path, "x\n").expect_err("rename over a directory fails"); assert_eq!(tmp_entries(&dir), Vec::::new()); // Control: the helper does see a stray temp file. fs::write(dir.join(".stray.swarmctl.tmp"), "").expect("stray"); assert_eq!(tmp_entries(&dir), [".stray.swarmctl.tmp"]); 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("`users: {}` loads") .users .is_empty(), "`users: {{}}` is an empty store, with no special case" ); fs::remove_dir_all(&dir).ok(); } }