The loopback listener served one connection at a time with no read timeout, so a connection that never sent a request (a browser preconnect, any local process) held up the real form_post until the 5-minute wait ran out. Each connection now runs on its own task and the first final answer wins. The callback body is capped at 16 KiB; over the cap it reads as empty and can't complete the login. A request the gateway answers 401 now triggers one refresh (under the cache lock, skipped if another process already replaced the refused token) and one retry; a second 401 says to run `swarmctl login`. The login cache directory is a `Swarm` field so tests can point it at a scratch directory. New tests: an idle connection does not block the real callback; an oversized body is refused without ending the wait; two concurrent refreshers refresh once and share the token; a refused token is refreshed once and retried; a second 401 says to log in without a further retry; with no `--swarm`, the verbs use the controller socket.
8.6 KiB
swarmctl
Swarm-level operator CLI. Runs as root on the host running
swarm-controller, and acts on that host directly; or, for the verbs
that talk to the controller, from anywhere as your own SSO account (see
Remote use).
Distinct from hivectl, which drives one hive's hive-c0re over its
admin socket. This crate does not link swarm-controller, for the same
reason hivectl does not link hive-c0re.
Why root, and why no socket
The first verb writes authelia's users database. Making that write
rootless was examined and rejected — relocating the file only turns a
write problem into a read problem. Move users.yml into a directory
the controller owns and the controller can write it, but authelia then
has to read it across the same boundary in the other direction. Making
that work needs either a hand-pinned gid (the container's uids are
allocated inside it, at activation — see the uid-assignment issue) or
world-readable password hashes. Both are worse than root.
So this binary serves no socket, publishes no HTTP route and has no privileged helper.
That rules out a way in, not a way out: agent create is a client of
the controller, because the work it asks for is a job graph only the
controller can queue (see below). Nothing here becomes reachable by that.
One file, two writers
swarmctl reads and writes users.yml — authelia's own users database —
directly. swarm-authelia-bridge writes agent subjects into the same file.
⚠️ The file is round-tripped, so comments and hand-formatting do not
survive a write. Values do, and so do keys this binary does not model.
See swarm-authelia-bridge/README.md for what both writers must uphold.
Configuration
Every path comes from the nix module that installs the binary, because every one is derived from an option that module owns. They are required rather than defaulted — a default would be an address we hope points at something, and one that resolves cleanly to the wrong place is worse than an error.
| variable | what |
|---|---|
SWARMCTL_AUTHELIA_BIN |
the configured authelia; argon2 params must match the verifier's |
SWARMCTL_AUTHELIA_USERS_FILE |
host-side path of the users database |
SWARMCTL_AUTHELIA_MACHINE |
container name, for systemctl -M |
SWARMCTL_AUTHELIA_UNIT |
authelia's unit inside that container |
SWARM_CONTROLLER_SOCKET |
the controller's unix socket, for agent and forge verbs |
SWARMCTL_SWARM |
https://<swarm domain>: reach the controller there, not the socket |
SWARMCTL_CA_FILE |
a PEM CA to trust beside the system roots, for SWARMCTL_SWARM |
Every verb and flag: docs/tools/swarmctl-cli.md.
The sections below cover why each verb behaves as it does.
user add
# swarmctl user add mara --display-name "Mara" --email mara@example.com --group admins
Keep both flags:
--group admins— the swarm UI and other operator surfaces gate on it.--email— the forge won't create an account without one.
user add refuses a username that already exists; fix an existing account
with swarmctl user update mara --add-group admins --email ….
The password is generated by authelia (crypto hash generate argon2 --random) and printed once. It is never passed on a command line:
/proc/<pid>/cmdline is world-readable, so a password in argv is readable
by any local process for the lifetime of the call. user reset-password
generates a new one the same way.
agent create
# swarmctl agent create scribe --hive alpha
queued: job node 42
agent "scribe" will be deployed to hive "alpha" once the job graph runs; `swarmctl` does not wait for it
POST /api/agents on the swarm-controller, over its unix socket
(--controller-socket, else SWARM_CONTROLLER_SOCKET, which the nix
module sets from the daemon's own socketPath). It prints the queued
job's node id and stops there.
It deliberately does not wait. The endpoint queues a DAG — SSO identity,
forge user, forge repo, repo membership, config-repo seed, store identity,
forge token, matrix account, a paused wanted state, then a deploy
message — and the last of those publishes: the hive's hive-c0re picks
it up and converges on its own clock, out of the controller's sight. So
even a fully settled graph would not mean the agent is up, and there is
nothing this CLI could wait for that would let it say so honestly. Watch
the swarm UI's job view for the rest.
The new agent starts paused: it doesn't drive turns until you set it up
in the swarm UI.
No approval gate, for the same reason nothing else here has one: the caller is root on the controller's host, or logged in through the swarm domain's SSO gate.
--hive is required. It is an address — where the deploy message goes
— not an attribute of the agent, so there is no sensible default. The
controller checks it against the swarm's hive roster and names the hives
that would have worked when it misses.
The request and response shapes are mirrored in src/agent.rs rather
than shared: the controller's own types are private to its binary, this
crate does not link it, and there is no wire-type crate between them.
Two fields out, two in, both ends validating — a drift shows up as a
400 naming the field.
agent mint-identity
# swarmctl agent mint-identity ruth
POST /api/agents/{name}/identity on the swarm-controller. Re-runs the
store-identity mint for one agent that already exists — the backfill for an
agent the swarm never created, such as the manager agent hive-c0re makes at
startup. It re-mints the agent's store certificate, which the agent picks up
the next time its container boots, and leaves an existing queue secret alone.
Queues and returns, like agent create.
agent mint-forge-token
# swarmctl agent mint-forge-token ruth
POST /api/agents/{name}/forge-token on the swarm-controller. Checks one
agent's forge token and mints it if it's missing or stale. The controller
already does this for every agent with a store identity at start and every
five minutes; this verb skips the wait. Queues and returns.
forge make-admin
# swarmctl forge make-admin mara
forge: "mara" is now a site admin
POST /api/forge/users/{name}/admin on the swarm-controller, over the same
socket as agent create. It never creates an account: the forge makes a
person's on their first login through authelia, and until then this fails
saying so. Running it on a site admin changes nothing, and an agent's name is
refused. The response shape is mirrored in src/forge.rs, for the same
reason as agent create's.
schedule
# swarmctl schedule add --name standup --agent scribe --body "post the standup summary" --every 1d
# swarmctl schedule list --agent scribe
# swarmctl schedule edit kqzpmwab --at 2026-10-05T09:00:00+02:00 --add-agent iris
# swarmctl schedule pause kqzpmwab
# swarmctl schedule rm kqzpmwab
The /api/schedules routes on the swarm-controller, over the
same socket as agent create; each verb waits for the stored result. What a
schedule does once stored is in
docs/swarm/schedules.md. The shapes are
mirrored in src/schedule.rs, for the same reason as agent create's.
Remote use: swarmctl login
The agent, schedule and forge verbs also work from any machine, as
your own SSO account, through the swarm domain's gateway:
$ export SWARMCTL_SWARM=https://swarm.example.com
$ swarmctl login
Log in to https://swarm.example.com/ in your browser:
…
logged in to https://swarm.example.com/; login kept in ~/.local/state/swarmctl/swarm.example.com.json
$ swarmctl schedule list
login opens the swarm's SSO login in a browser and receives the result on
a loopback port, so run it on the machine whose browser you use. swarmctl
keeps the login, mode 0600, under $XDG_STATE_HOME/swarmctl/ (default
~/.local/state) and refreshes it as it's used, and once more if the gateway
refuses it. When it can't be refreshed, any verb says to run swarmctl login
again. To log out, delete the file.
A swarm whose gateway serves a certificate from the swarm's own CA needs
that CA: --ca-file or SWARMCTL_CA_FILE. The user verbs edit the local
users file and have no remote form.