Permission::default_for and hive-sh4re's MANAGER_AGENT are gone: every agent with no permissions key holds Permission::DEFAULT, ruth included. The agent-name parameters that only fed default_for go with it (permissions::effective, HiveCopy::before_read/after_read and their callers). swarm-controller seeds the grant ruth held by name: at start, a declared ruth whose permissions are unset gets DEFAULT plus manage_root_agent written into her key, once, logged. A set key is never touched. It is a one-shot to delete once every swarm has passed it. The swarm UI setup assistant opens the create form with manage_root_agent checked, so a new ruth is created with the grant. permissions_import no longer adds ruth to the import or splits her default. Docs and help text no longer say ruth holds the grant by default; swarmctl-cli.md regenerated.
1446 lines
54 KiB
Rust
1446 lines
54 KiB
Rust
//! `swarmctl` — the swarm operator's local CLI.
|
||
//!
|
||
//! Runs as **root on the host the swarm-controller runs on**, and acts
|
||
//! directly. That is a deliberate scope, not a shortcut: the alternative
|
||
//! examined for the first verb was to make the write rootless by moving
|
||
//! authelia's users database into a directory the controller owns, and it
|
||
//! does not work — relocating the file only turns a write problem into a
|
||
//! read problem, because authelia then has to reach *across the same
|
||
//! boundary in the other direction*. Making that read work needs either a
|
||
//! hand-pinned gid (the container's uids are allocated inside it, at
|
||
//! activation) or world-readable password hashes. Both are worse than
|
||
//! root.
|
||
//!
|
||
//! So 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 forge;
|
||
mod inference;
|
||
mod login;
|
||
mod permissions;
|
||
mod remote;
|
||
mod schedule;
|
||
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 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://<domain>` 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<String>,
|
||
/// 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<PathBuf>,
|
||
}
|
||
|
||
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<String>) -> Option<String> {
|
||
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<Option<remote::Swarm>> {
|
||
self.swarm_from(SWARM_ENVS)
|
||
}
|
||
|
||
fn login_swarm(&self) -> Result<Option<remote::Swarm>> {
|
||
self.swarm_from(LOGIN_SWARM_ENVS)
|
||
}
|
||
|
||
fn swarm_from(&self, envs: &[&str]) -> Result<Option<remote::Swarm>> {
|
||
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<PathBuf>) -> Result<Target> {
|
||
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<PathBuf>,
|
||
/// 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<PathBuf>,
|
||
}
|
||
|
||
struct Paths {
|
||
authelia_bin: PathBuf,
|
||
users_file: PathBuf,
|
||
}
|
||
|
||
impl PathArgs {
|
||
fn resolve(self) -> Result<Paths> {
|
||
Ok(Paths {
|
||
authelia_bin: path_from(self.authelia_bin, "SWARMCTL_AUTHELIA_BIN")?,
|
||
users_file: path_from(self.users_file, "SWARMCTL_AUTHELIA_USERS_FILE")?,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn path_from(flag: Option<PathBuf>, env: &str) -> Result<PathBuf> {
|
||
flag.or_else(|| std::env::var_os(env).map(PathBuf::from))
|
||
.with_context(|| missing(env))
|
||
}
|
||
|
||
fn missing(env: &str) -> String {
|
||
format!(
|
||
"{env} is unset and no flag was given — swarmctl is installed and \
|
||
configured by the swarm-controller nix module, which supplies it; \
|
||
running outside that deployment needs the value passed explicitly"
|
||
)
|
||
}
|
||
|
||
/// Named `Verb` rather than the conventional `Command` because
|
||
/// [`std::process::Command`] is in scope here and the clash is a
|
||
/// confusing one — the compiler reports it as an orphan-rule violation on
|
||
/// a derive, several errors away from the actual cause.
|
||
#[derive(Subcommand)]
|
||
enum Verb {
|
||
/// Manage 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,
|
||
},
|
||
}
|
||
|
||
#[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<String>,
|
||
#[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<String>,
|
||
/// The reasoning effort its turns run at.
|
||
#[arg(long, value_name = "LEVEL", value_parser = ["low", "medium", "high", "xhigh", "max"])]
|
||
effort: Option<String>,
|
||
/// 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<Vec<String>>,
|
||
#[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,
|
||
/// 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<PathBuf>,
|
||
}
|
||
|
||
#[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<PathBuf>,
|
||
}
|
||
|
||
#[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<PathBuf>,
|
||
}
|
||
|
||
#[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<String>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
#[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<String>,
|
||
/// New prompt text.
|
||
#[arg(long, value_name = "TEXT")]
|
||
body: Option<String>,
|
||
/// New first fire time, in the form `schedule add --at` takes.
|
||
#[arg(long, value_name = "TIME")]
|
||
at: Option<String>,
|
||
/// New time between fires, in the form `schedule add --every` takes.
|
||
#[arg(long, value_name = "DURATION", conflicts_with = "once")]
|
||
every: Option<String>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
#[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<PathBuf>,
|
||
}
|
||
|
||
impl ControllerSocketArg {
|
||
fn resolve(self, remote: &RemoteArgs) -> Result<Target> {
|
||
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<String>,
|
||
#[arg(long, value_name = "ADDRESS")]
|
||
email: Option<String>,
|
||
/// Repeatable.
|
||
#[arg(long = "group", value_name = "GROUP")]
|
||
groups: Vec<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
#[arg(long, value_name = "ADDRESS")]
|
||
email: Option<String>,
|
||
/// Repeatable. Adding a group the user is already in isn't an error.
|
||
#[arg(long = "add-group", value_name = "GROUP")]
|
||
add_groups: Vec<String>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
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)
|
||
}
|
||
// 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),
|
||
// 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::<Cli>(&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 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::EditRequest::new(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 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 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 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<UserStore> {
|
||
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 <pw>` is a security requirement,
|
||
/// not a convenience: `/proc/<pid>/cmdline` is world-readable, so a
|
||
/// password passed on argv is readable by any local process for the
|
||
/// lifetime of the call. Letting authelia generate it means the plaintext
|
||
/// never crosses a command line at all.
|
||
fn generate_password(bin: &Path) -> Result<Generated> {
|
||
let out = Command::new(bin)
|
||
.args(["crypto", "hash", "generate", "argon2", "--random"])
|
||
.output()
|
||
.with_context(|| format!("running {}", bin.display()))?;
|
||
|
||
if !out.status.success() {
|
||
bail!(
|
||
"{} failed ({}): {}",
|
||
bin.display(),
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr).trim()
|
||
);
|
||
}
|
||
|
||
let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?;
|
||
let password = parse_field(&stdout, "Random Password:");
|
||
let digest = parse_field(&stdout, "Digest:");
|
||
|
||
// The raw output is deliberately NOT included in this error: it
|
||
// contains the freshly generated plaintext, and an error message is
|
||
// exactly the thing that ends up in a log or a bug report. Naming the
|
||
// missing marker is enough to diagnose an upstream format change —
|
||
// run the command by hand to see the rest.
|
||
match (password, digest) {
|
||
(Some(password), Some(digest)) => Ok(Generated { password, digest }),
|
||
(password, digest) => bail!(
|
||
"could not parse {}'s output: {}{}missing",
|
||
bin.display(),
|
||
if password.is_none() {
|
||
"'Random Password:' "
|
||
} else {
|
||
""
|
||
},
|
||
if digest.is_none() { "'Digest:' " } else { "" }
|
||
),
|
||
}
|
||
}
|
||
|
||
fn parse_field(stdout: &str, marker: &str) -> Option<String> {
|
||
stdout
|
||
.lines()
|
||
.find_map(|line| line.trim().strip_prefix(marker))
|
||
.map(|value| value.trim().to_owned())
|
||
.filter(|value| !value.is_empty())
|
||
}
|
||
|
||
/// Replace `path`'s contents atomically, preserving the existing owner
|
||
/// and mode.
|
||
///
|
||
/// Atomic because a reader must never see a half-written user database,
|
||
/// and because the temp file is created in the *same directory* —
|
||
/// `rename(2)` is only atomic within a filesystem.
|
||
///
|
||
/// Owner and mode are read off the existing file rather than asserted:
|
||
/// authelia's file is created by its own unit as its own user, and
|
||
/// stamping our idea of the right values onto it would silently
|
||
/// re-permission a file another service opens. Both are applied to the
|
||
/// temp file *before* the rename, so the finished file is never visible
|
||
/// with the wrong ones.
|
||
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
|
||
let dir = path
|
||
.parent()
|
||
.with_context(|| format!("{} has no parent directory", path.display()))?;
|
||
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||
|
||
let name = path
|
||
.file_name()
|
||
.with_context(|| format!("{} has no file name", path.display()))?;
|
||
let tmp = dir.join(format!(".{}.swarmctl.tmp", name.to_string_lossy()));
|
||
|
||
let existing = fs::metadata(path).ok();
|
||
// 0600 only when the file does not exist yet: this content is
|
||
// password hashes, so the conservative value is the right default and
|
||
// the existing value is the right answer.
|
||
let mode = existing
|
||
.as_ref()
|
||
.map_or(0o600, |meta| meta.permissions().mode() & 0o7777);
|
||
|
||
let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
|
||
file.write_all(contents.as_bytes())
|
||
.with_context(|| format!("writing {}", tmp.display()))?;
|
||
file.sync_all()
|
||
.with_context(|| format!("flushing {}", tmp.display()))?;
|
||
drop(file);
|
||
|
||
fs::set_permissions(&tmp, Permissions::from_mode(mode))
|
||
.with_context(|| format!("setting mode on {}", tmp.display()))?;
|
||
if let Some(meta) = existing.as_ref() {
|
||
std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid()))
|
||
.with_context(|| format!("setting owner on {}", tmp.display()))?;
|
||
}
|
||
|
||
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
|
||
|
||
// The rename itself is metadata: without this the file can survive a
|
||
// crash while the directory entry pointing at it does not.
|
||
File::open(dir)
|
||
.and_then(|d| d.sync_all())
|
||
.with_context(|| format!("flushing directory {}", dir.display()))?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 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<hyper::body::Incoming>| {
|
||
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 <URL>"), "{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();
|
||
}
|
||
|
||
#[test]
|
||
fn parses_authelia_hash_output() {
|
||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||
assert_eq!(
|
||
parse_field(out, "Random Password:").as_deref(),
|
||
Some("hunter2")
|
||
);
|
||
assert_eq!(
|
||
parse_field(out, "Digest:").as_deref(),
|
||
Some("$argon2id$v=19$m=65536$abc")
|
||
);
|
||
}
|
||
|
||
/// An upstream format change must read as "missing", not as an empty
|
||
/// password silently written into the store.
|
||
#[test]
|
||
fn an_empty_field_reads_as_missing() {
|
||
assert_eq!(parse_field("Digest: \n", "Digest:"), None);
|
||
assert_eq!(parse_field("nothing here\n", "Digest:"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn write_atomic_preserves_an_existing_files_mode() {
|
||
let dir = std::env::temp_dir().join(format!("swarmctl-test-{}", std::process::id()));
|
||
fs::create_dir_all(&dir).expect("temp dir");
|
||
let path = dir.join("users.yml");
|
||
|
||
fs::write(&path, "users: {}\n").expect("seed");
|
||
fs::set_permissions(&path, Permissions::from_mode(0o640)).expect("chmod");
|
||
|
||
write_atomic(&path, "users:\n mara:\n").expect("rewrite");
|
||
|
||
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
|
||
assert_eq!(mode, 0o640, "the existing mode must survive the replace");
|
||
assert_eq!(
|
||
fs::read_to_string(&path).expect("read"),
|
||
"users:\n mara:\n"
|
||
);
|
||
|
||
fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn write_atomic_defaults_a_new_file_to_0600() {
|
||
let dir = std::env::temp_dir().join(format!("swarmctl-new-{}", std::process::id()));
|
||
fs::create_dir_all(&dir).expect("temp dir");
|
||
let path = dir.join("users.json");
|
||
|
||
write_atomic(&path, "{}\n").expect("write");
|
||
|
||
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o7777;
|
||
assert_eq!(mode, 0o600, "a new store holds password hashes");
|
||
|
||
fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
/// Replaces `load_store_refuses_to_take_over_a_populated_users_file`,
|
||
/// which pinned the guard this change removes.
|
||
///
|
||
/// The guard refused to write when this crate's own JSON store was
|
||
/// missing but `users.yml` held users — the state every real hive is in,
|
||
/// and the reason `swarm agent create` failed. Deleting the test with
|
||
/// the guard would have left the *new* behaviour unpinned, so it is
|
||
/// inverted rather than dropped: a populated file must now be **read**,
|
||
/// and users somebody else wrote must survive.
|
||
#[test]
|
||
fn a_populated_users_file_is_read_rather_than_refused() {
|
||
let dir = std::env::temp_dir().join(format!("swarmctl-takeover-{}", std::process::id()));
|
||
fs::create_dir_all(&dir).expect("temp dir");
|
||
let users_file = dir.join("users.yml");
|
||
|
||
fs::write(
|
||
&users_file,
|
||
"users:\n mara:\n displayname: \"mara\"\n password: \"x\"\n",
|
||
)
|
||
.expect("write");
|
||
let store = load_store(&users_file).expect("a populated file is readable");
|
||
assert!(
|
||
store.users.contains_key("mara"),
|
||
"a user this process did not write must be read, not refused"
|
||
);
|
||
|
||
fs::write(&users_file, "users: {}\n").expect("seed");
|
||
assert!(
|
||
load_store(&users_file)
|
||
.expect("`users: {}` loads")
|
||
.users
|
||
.is_empty(),
|
||
"`users: {{}}` is an empty store, with no special case"
|
||
);
|
||
|
||
fs::remove_dir_all(&dir).ok();
|
||
}
|
||
}
|