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.
`swarmctl --swarm https://<swarm> login` runs the OAuth 2.0
authorization-code flow with PKCE against the swarm's authelia as a new
public `swarmctl` client: a pushed authorization request, the browser login
and explicit consent, and the code delivered by form_post to a loopback
port. The access and refresh tokens are cached at mode 0600 under
$XDG_STATE_HOME/swarmctl/ and refreshed under a file lock, since authelia
rotates the refresh token and revokes the login when a used one is
presented again. With --swarm (or SWARMCTL_SWARM), the agent, schedule and
forge verbs send their requests to https://<swarm>/api/ with the bearer
token instead of over the controller's unix socket, which stays the default.
authelia registers the client on every authelia host with a swarm domain:
`kind = "native"`, public, no minted secret, scopes offline_access and
authelia.bearer.authz, audience https://<swarm>/api/ (matched as a URL
prefix), redirect http://127.0.0.1/callback (any loopback port). Every
field is one authelia 4.39.20's validator requires for a public client
holding the bearer scope.
The swarm apex's /api/ answers a refused caller that sent an
Authorization header with the 401 itself instead of the login redirect,
so the CLI can tell "log in again" apart from an answer; a browser still
gets the redirect. The bearer token is not forwarded to the controller.
Refs #4409 (mara: c94764, c94782).
swarm-controller keeps every scheduled prompt (id, name, agents, body,
anchor, interval, paused) as one document under one key of a new
`schedule-definitions` KV bucket, so each change is a single
compare-and-set and a prompt exists for all of its agents or none. It
writes each agent's key of a new `agent-schedules` bucket as the prompts
naming that agent, without names or other agents, after every change and
on a one-minute reconcile pass. It is the only writer of both, behind
GET/POST /api/schedules and PATCH/DELETE /api/schedules/{id}.
`swarmctl schedule add|list|edit|pause|resume|rm` drives those routes over
the controller socket.
An agent's own queue credential is granted one more subject, the direct
get of its own key in `agent-schedules`. A KV watch is not granted: its
consumer create subject carries no key, so it would cover every agent's.
hive-agent's new schedule ticker reads that key every 10 seconds and fires
due slots as the same `schedule:<id>` todo hive-c0re's worker pushes. Fire
times are anchor + k*interval; the last fired slot per schedule id is kept
in the state db, so a body edit or list refresh never moves or refires a
slot, downtime fires the latest missed slot once, a restart refires
nothing, a one-shot fires once, and paused slots are skipped.
Refs #4914
swarm/README.md opens with the swarm and its control plane; hive identity
and the directory follow as the substrate. Upgrade notes move into a
<details> block, the per-agent queue publishing detail into another, and
the one-paragraph pointer sections collapse into a link list.
Fact fixes, checked against origin/main:
- an empty swarm.hives fails eval (swarm.nix:341-354); it does not mean
"not in a swarm"
- swarm.domain is required with a hive (hive-network.nix:156,188), hiveName
with a hive, store or homeserver (hyperhive.nix:161-166)
- the matrix container trusts the hive's trust-bundle.pem at runtime under
self-signed certs (hive-matrix.nix:1046-1052, lib/hive-ca-trust.nix:76-85)
- singleHostSwarm also defaults the controller, localHostsEntry, the nats
callout keys and the bao bootstrap token path (local-defaults.nix:72-129)
- swarm-controller serves far more than /health: roster, wanted state, job
graph, agent creation and credential mints (main.rs:2874-2899)
- swarmctl user add needs --email for the forge account and refuses an
existing user (setup.md:67-71, swarmctl/src/main.rs:425-430); document
agent mint-identity and mint-forge-token
- agent creation also mints store identity, forge token and matrix
account, and declares the agent paused (main.rs:1822-1920, 247-248)
Refs #3902
setup.md said Swarm SSO creates the operator's forge account, which was
not true until the previous commits. It now says how: sign in to the
forge once through authelia, then `swarmctl forge make-admin <you>`.
sso.md says what that first login does and why ACCOUNT_LINKING is
`login`. README, hivectl.md and forge.md drop `hivectl forge
create-user`, and the swarmctl README gains `forge make-admin`.
Refs #3782
`swarmctl agent create <name> --hive <hive>` POSTs `/api/agents` to
swarm-controller over the daemon's unix socket and prints the queued
job's node id.
It deliberately does not wait. The endpoint queues a DAG whose last node
*publishes* a deploy message; the hive's `hive-c0re` then 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 claim otherwise. Printing the id is
exactly what the response says and all of what it says.
Transport is a bare hyper HTTP/1.1 client handshaked onto a tokio
`UnixStream` via `hyper_util::rt::TokioIo` — the same crate family
`hivectl/src/watch.rs` and `hive-agent/src/web_ui/proxy.rs` already use,
all of it already workspace-pinned. The request/response shapes are a
local mirror rather than a shared crate: the controller's own types are
private to its binary and this crate does not link it, the same
separation `hivectl` keeps from `hive-c0re`.
Errors are reduced to one actionable line — the controller answers
RFC 9457 problem+json, so an unknown `--hive` reaches the operator as
the roster of hives that would have worked rather than a body dump.
Response `warnings` are printed when non-empty.
The nix module wraps the binary with `SWARM_CONTROLLER_SOCKET`, read
from the same `socketPath` the daemon binds.
Refs #4399
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.
Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.
None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
Six places asserted the old design as fact, and none of them mention the
change by name -- the class of doc breakage that is found by asking what
a diff made untrue, not by grepping for a feature:
- swarmctl/README.md and swarm-authelia-bridge/README.md both described
their own private canonical store. The bridge's "known limitation"
section described the seam as unsolved; it is what this fixes, so it
becomes what both writers must uphold instead.
- docs/swarm/{sso,ui,secrets}.md described a rendered artifact.
- The repo CLAUDE.md entry for swarmctl said the same.
- docs/tools/swarmctl-cli.md is regenerated (CI diffs it against the
clap tree), picking up the removed --store flag.
Operator-facing where it is read: the hand-editing consequence (values
survive a rewrite, comments do not) is stated in sso.md, where an
operator is being told to edit the file, rather than only in a module doc.
The swarm-authelia module states that its users database is written by
swarm-controller, but nothing ever granted the means. This adds the tool
that does it.
swarmctl runs as root on the controller's host and acts directly. The
rootless alternative was examined and does not work: relocating the users
file into a directory the controller owns only turns a write problem into
a read problem, because authelia must then reach across the same boundary
in the other direction. Making that read work needs either a hand-pinned
gid or world-readable password hashes.
The user store is two files, one authoritative: users.json is canonical,
users.yml is a rendered artifact. That split is what lets the crate work
without a YAML parser -- the workspace has none, and adding one costs a
crates.io fetch, a lock update and a vendor hash for a schema we fully
control and only ever emit.
Passwords are generated by authelia rather than passed to it: argv is
world-readable, so a password on a command line is readable by any local
process for the lifetime of the call.
The three derived facts swarmctl needs about the authelia container --
machine, unit and the host-side users path -- become readOnly options on
the authelia module rather than literals repeated at the call site.