Commit graph

3,702 commits

Author SHA1 Message Date
atlas
af1203c78b fix(#3422): swarmctl reads and writes users.yml directly
The second half of making one file canonical. swarmctl kept its own
private JSON store and rendered users.yml from it, against the same
physical file the bridge wrote -- the seam that made `swarm agent create`
refuse to run.

- users.yml is read before it is written, so users another writer added
  are loaded rather than treated as a file to refuse or clobber. The
  overwrite guard and the seed check go with the second store: they
  existed to police two stores that could disagree.
- serde_norway replaces the hand-rolled emitter. Unknown top-level and
  per-user keys round-trip through `extra`, so two writers cannot delete
  each other's fields.
- The synthetic email moves from render time to the write path and is
  stored. With the file as the store, "rendered but not persisted" has
  nowhere left to live, and mara ruled the stored address correct.
- `--store` is removed rather than deprecated: a flag whose only
  remaining effect is nothing reads as accepted and does nothing.

Three tests asserted the emitter's exact bytes, and one asserted the
guard. Rewritten rather than deleted -- as round-trips for the former,
and inverted for the latter, since "a populated file is READ" is the
behaviour this change is for and deleting its test would leave it
unpinned.
2026-08-18 10:31:48 +02:00
atlas
4125d967ef fix(#3422): stop pointing the bridge at a second user store
The bridge no longer has a private canonical store, so the env var
naming one is gone rather than repointed. What is left is the single
users file it now reads and writes directly.

That line is what made `swarm agent create` fail on this hive: the
bridge found no store at its own JSON path, saw users already in
`users.yml`, and refused to overwrite a file it had not written --
correctly, given two things claimed to be canonical for one file.
2026-08-18 10:31:48 +02:00
atlas
24f4cd42a9 fix(#3422): the bridge reads and writes users.yml directly
`swarm agent create` failed with "no user store at
swarm-authelia-bridge-users.json but users.yml already holds users" on
any hive that had users. The guard was correct; what was wrong is that
two files both claimed to be canonical for one physical file.

The bridge kept a private users.json and rendered users.yml from it,
while swarmctl kept its own pair against the same users.yml. A writer
whose own JSON was absent could not tell "nothing here yet" from
"someone else's users", so it refused to write at all.

users.yml becomes the store: read before write, through serde_norway
rather than a hand-rolled emitter. Unknown top-level and per-user keys
round-trip through `extra`, or whichever process writes second would
silently delete what the first added. Validation stays on the write path
-- a bare to_string(&store) serialises perfectly and drops the "no
control character ever reaches this file" guarantee silently.

The seed constant goes with the guard: nothing writes a seed now, an
absent file is an empty store, and left as a pub constant it read as if
the seed dance were still load-bearing.
2026-08-18 10:31:48 +02:00
atlas
266e8ac96b fix(#3394): distinguish the caller's bad token from the bridge failing to validate
swarm-authelia-bridge answered one 401 for four causes, including its own
client credentials being rejected by authelia — telling a caller its token
was invalid when the fault was ours. That cost a real diagnostic round-trip,
because the only thing separating the two was a log line inside a container.

Introspection now returns a three-way Verdict, and one classify() maps it to
either admission, a 401 (the caller's credential) or a 503 (ours). Admission
is unchanged: Ok is reachable from exactly one variant.
2026-08-18 00:22:45 +02:00
damocles
07d3d3060e revive: reseed applied from forge main, no proposed fallback 2026-08-18 00:13:23 +02:00
damocles
5d396b143e lifecycle: reseed applied repo from proposed on revive after purge 2026-08-18 00:13:23 +02:00
iris
e71e4ef432 swarm-ui: escape the hyphen in create-agent's name pattern
Closes #3423.

Modern browsers validate an <input pattern> attribute's regex in
Unicode-set ('v') mode, which is stricter about hyphen placement than
classic mode: `[a-z0-9-]` throws "Invalid character class" under 'v'
mode even though a trailing hyphen is unambiguous (and valid) in classic
regex. Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
`new RegExp('[a-z0-9\\-]', 'v')` doesn't. Escaping the hyphen fixes it
without changing what the pattern matches.
2026-08-17 23:56:53 +02:00
iris
62d1c6224b frontend: fix api-error.ts's stale in-transition comment
atlas, on the issue: swarm-controller's problem+json conversion merged
(58588a68, closing hyperhive#3412 which this comment referenced as an
in-flight fix). Every first-party API is RFC 9457 now, so the non-JSON
fallback path is not backend-transition compatibility anymore — it's for
bodies this repo didn't author (a caller pointed elsewhere, or an
nginx-synthesised 502/504 that never reaches our handlers). Comment
updated to say that instead of pointing at a transition that's already
finished.
2026-08-17 23:11:11 +02:00
iris
ca84079a21 frontend: add WarnBanner Preact component, compose ApiErrorPanel from it
mara, on review: "i want each component to import its own css file
itself[;] if you need a bunch of extra css externally, its not a proper
component ... you may need to migrate other components that you would
want to use first".

WarnBanner is the Preact-component successor to the shadow-DOM
<hive-warn> custom element (same three-tier info/warning/error visual
language, colours copied faithfully from hive-warn.css). ApiErrorPanel
now composes it instead of owning a copy of the border/colour/pulse
rules itself — its own CSS is back down to just the layout that's
actually specific to it (heading, copy button, detail text).

Re-verified with a fresh mock-server screenshot: same rendered output as
before, now via composition instead of a duplicated banner shape.
2026-08-17 23:11:11 +02:00
iris
f60aab4717 frontend: shared ApiErrorPanel component, promote readApiError from credentials.js
Closes #3410.

`ApiErrorPanel` renders a ProblemDetails (RFC 9457) error nicely, with a
copy button so the full text can be pasted straight into a bug report.
No truncation of `detail` — on the #3363 incident that motivated this
issue, that string was the entire diagnosis.

`readApiError`/`problemMessage`/`ProblemDetails` are promoted out of
credentials.js's original `readErrorBody` into `@hive/shared/api-error.js`
(comment rewritten: the RFC 9457 rework has already landed everywhere
except swarm-controller's status route, #3412 in flight, so the raw-text
fallback is a compat shim for that one gap, not a general transition).
credentials.js's 4 call sites switch to the shared reader (kept as
one-line messages there, its result slots are single-line aria-live
regions, not a panel context).

Wired ApiErrorPanel into OverviewPage.tsx (the issue's own worked
example) and CreateAgentPage.tsx (second real call site).

Not built on <hive-warn> despite matching its visual language — that
custom element's CSS-as-text import only works under a build with
loader: 'text' for .css (dashboard's), and silently renders unstyled
under swarm-ui's default css loader (filed separately as #3415).
ApiErrorPanel is a self-contained light-DOM component instead, per
mara's own suggestion to keep it independent of the old UI's shapes.
2026-08-17 23:11:11 +02:00
atlas
bfe921c254 feat(#3405): declarative grafana plugins for the swarm dashboard
Plugin management is server-admin scoped, and on an SSO hive nobody holds that
role: auto_assign_org_role grants an org role, and the built-in local admin
that does hold server admin cannot log in because the login form is disabled
whenever SSO is configured. Two individually-correct decisions leaving no path
to the plugin UI at all.

Declarative is the way through rather than a workaround for it -- plugins land
in the store and in git, survive a rebuild and a state reset, and the container
needs no runtime egress to grafana.com.

mkIf rather than passing the list through: upstream's default is null, while an
empty list is a real value pointing the plugin path at an empty store dir, so a
hive that sets nothing must keep seeing null.
2026-08-17 22:24:07 +02:00
atlas
346a6b1b4c fix(#3393): render an email for every swarm user, synthesised when none was given
Authelia serves no `email` claim for a user without one, and a relying party
that wants that claim does not degrade -- grafana falls through to
`<api_url>/emails`, a GitHub-ism authelia does not implement, and the login
dies with InternalError naming nothing useful. So an absent address is a
broken login rather than a sparse profile.

Uses `<username>@hyperhive.local`, the domain hive-c0re already gives every
agent's forge account and every hyperhive-authored commit. Not deployment-
derived: that would have to be plumbed in from config, and an operator already
supplying a domain may as well supply the whole address.

Synthesised in the renderer, never in the store: users.json stays honest that
none was supplied, an operator who later sets a real one is not fighting an
invented value, and existing users are fixed by the next render with no
migration step. A supplied address always wins.

The old test asserting an absent email is omitted pinned exactly the behaviour
that broke the login; it is split so the group half keeps its meaning and the
email half states the new contract.
2026-08-17 22:04:17 +02:00
atlas
58588a6866 fix(#3412): swarm-controller answers errors as RFC 9457 problem+json
Its three error paths returned a bare string with a status code, which forces
a caller to treat the whole body as prose. hive-c0re converted some time ago,
so swarm-controller was the last backend on the old shape -- and it is the one
behind the hive status page's 503, where the body is frequently the entire
diagnosis rather than a summary.

Adds the commitment to docs/conventions.md, since it was implied by the code
in one daemon and written down nowhere: an endpoint of ours answering with a
bare string is a bug to file, not something callers work around.

The test asserts the rendered response -- media type plus an addressable
detail -- rather than the problem_details value, because a handler that built
the value correctly and then returned it as a string would satisfy a test
written against the type alone.
2026-08-17 21:30:39 +02:00
damocles
1d9a06482a docs(setup): point operator forge/matrix account creation at swarm SSO 2026-08-17 21:12:24 +02:00
atlas
e4a98d40ba fix(#3363): grant the bare consumer-create subject the reader actually uses
`store.keys()` creates an ephemeral ordered consumer, whose create subject
ends at the stream name. `>` matches one or more tokens and never zero, so
the `.>` form alone never covered it: the server refused
`$JS.API.CONSUMER.CREATE.KV_hive-status`, the refusal reached the client as a
timeout, and the operator saw a 503 on the hive status page.

The test asserted only the `.>` form, which reads as covering the bare one, so
the suite stayed green while every list timed out in production. It now names
the bare subject separately and first.
2026-08-17 20:48:52 +02:00
atlas
3fdc3bb15b fix(#3396): name the matrix bundle from a literal, not a missing option
hive-matrix.nix declares no `machine` option -- the idiom was copied from
swarm-grafana, which does. Any hive with matrix enabled failed to evaluate.
2026-08-17 20:37:52 +02:00
atlas
2eb74f3a74 fix(#3396): move the forge onto the shared trust-bundle helper
Last of the three modules that hand-rolled the same concat with wantedBy +
before and no requires, so a failed assembly left the consumer running against
a missing file and trusting nothing -- every outbound TLS call fails while the
unit looks healthy.

The forge is the one with two consumers: forgejo-sso-source fetches the
issuer's discovery document over the swarm CA and once shipped without the
trust its sibling had. It only exists when SSO is on, so the consumer list is
conditional -- naming an absent unit would define a serviceless one and order
nothing.

Removes the now-dead useSelfSigned and caContainerPath bindings (nix does not
warn) and retargets three comments the deletion orphaned, including the helper
header that still named this module as the per-call-site concat.
2026-08-17 20:35:56 +02:00
atlas
9073c9b3f7 fix(#3396): move grafana and matrix onto the shared trust-bundle helper
Both hand-rolled the same concat with wantedBy + before and no requires,
so a failed assembly let the consumer start against a missing file and
trust nothing at all -- which fails every outbound TLS call while the unit
looks healthy. The helper puts requires on the consumer and verifies the
assembled bundle before moving it into place.

Removes each module's now-dead useSelfSigned binding: its only use was the
mkIf on the deleted block, and nix does not warn about an unused let
binding. Also corrects two comments the deletion orphaned -- one described
the removed path binding, the other pointed at a bundle service that no
longer exists.
2026-08-17 20:29:22 +02:00
atlas
d212125b48 fix(#3391): introspect authelia by name instead of loopback
A loopback literal encodes 'authelia is in my netns' at the call site,
and authelia's OIDC endpoints are https-only in effect: reached directly
they answer 400, because the forwarded headers nginx injects for every
other consumer are what let it determine its own issuer. Going by name
deletes the need for those headers rather than reproducing them, and
converges on the URL swarm-nats-auth already uses.

Depends on the CA trust added for the same container in #3407 -- without
it this swaps a 400 for an UnknownIssuer.

The null-url assertion joins the module's existing list: interpolating a
null would surface as a nix coercion error several files from its cause.
2026-08-17 20:02:15 +02:00
atlas
e7a5e93bbc docs: trim the trustBundle comment under the 30-line lint
Keeps the three constraints a reader cannot derive; the reasoning behind
them lives in the PR.
2026-08-17 19:57:44 +02:00
atlas
cb650f2dbb fix(#3363): give swarm-authelia the hive CA too
Same class-B defect as the queue responder: swarm-authelia-bridge
introspects authelia by name over https and the container trusted no swarm
CA, so the call could only ever fail UnknownIssuer.

This is also the prerequisite for the by-domain introspection change on
#3391 -- flipping that URL without the trust half would swap one failure
for another.
2026-08-17 19:57:44 +02:00
atlas
850cc2c1d3 fix(#3363): give the queue's auth responder the hive CA
The responder introspects authelia over https by name. It had no CA trust
at all, so the handshake failed UnknownIssuer, introspection failed, and it
denied every client -- surfacing at the controller as a 60s
authorization-violation loop, two layers from the cause.

Adds a shared trustBundle helper to lib/hive-ca-trust.nix rather than a
fifth hand-rolled concat. Four containers were each assembling this
themselves, which is how they came to share one defect: wantedBy + before
express ordering but not success, so a failed assembly let the consumer
start against a missing file and trust nothing at all.

The helper fixes both halves of that. requires goes on the consumer, so a
failed bundle stops it and the dependency is visible in systemctl status
where someone debugging a TLS failure looks. And the script assembles to a
temp path, checks the result actually contains a certificate, and only then
moves it into place -- cat of an empty bind exits 0, so set -e does not
catch it and a partial bundle must never appear under the final name.

Returns a module rather than bare services: a caller that already writes
systemd.services.<consumer> cannot also write systemd.services in the same
attrset.
2026-08-17 19:57:44 +02:00
damocles
bfc0d86684 swarm-required-services: trim otel.enable comment per review 2026-08-17 19:36:49 +02:00
damocles
89e8600ba8 swarm-required-services: default otel.enable from enableRequiredServices 2026-08-17 19:36:49 +02:00
atlas
4c7865cd8b docs: trim the reload rationale to the live constraint
Keeps why the setting exists (the bridge writes this file and cannot
restart authelia) and drops the incident narrative and the answered
objections -- that history belongs in the PR discussion, not in three
comment blocks a future reader has to scroll past.
2026-08-17 19:25:27 +02:00
atlas
63fc54edc5 refactor(#3393): stop restarting authelia from swarmctl
authelia now watches the users file, so the restart is redundant -- and it
was the wrong shape twice over. It could fail: a login was refused for a
user whose record was already correct on disk, with nothing in either log
implicating the reload. And it only ever worked for this writer --
swarm-authelia-bridge writes the same file and cannot restart anything,
since running unprivileged inside the container is the whole reason it may
write it at all. A reload that depends on which process did the writing is
not a reload.

--machine/--unit and their two env vars existed solely to name a
systemctl -M target, so they go with it. That drops two required settings
from the operator surface.

The three objections previously recorded against watch are all answered
now, and are kept next to the decision rather than deleted: the key is
verified against the pinned build (validate-config accepts it and rejects
a misspelling), the watch is on the directory so a rename is observed, and
partial reads are structurally impossible because every writer of this
file goes through write_atomic.
2026-08-17 19:22:27 +02:00
atlas
154ab6c4ae fix(#3401): let authelia re-read its user file on change
Authelia reads authentication_backend.file.path once at startup. Without
watch, an identity the bridge creates is real on disk and invisible to the
running authelia until something unrelated bounces the unit.

swarm-authelia-bridge cannot restart authelia -- running unprivileged
inside the container is the whole reason it may write that file, so
restarting the unit is exactly the privilege it was designed not to hold.
swarmctl does restart it, but that is a systemctl -M shellout that can
fail, and when it did the symptom was a login refused for a user whose
record was already correct on disk. This makes correctness stop depending
on that restart succeeding.
2026-08-17 19:15:54 +02:00
damocles
0138b0e0ac swarmctl: add user list 2026-08-17 18:50:27 +02:00
atlas
e0e5823080 fix(#3384): source the queue policy's principals from the modules that mint them
The auth-callout responder decides what an admitted client may publish
from two strings: the prefix marking a hive client, and the client id
allowed to read every hive's key. Both were literals in three places --
swarm-authelia.nix mints "hive-${name}", swarm-controller.nix defines
"swarm-controller", and the responder carried its own copies as clap
defaults because swarm-nats.nix passed neither.

Each producer now publishes its value as a readOnly option and the
responder's ExecStart reads them, so the agreement is one evaluation
rather than three strings that happen to be equal. Same pattern the
module already uses for `--account`, and the same argument
swarm-authelia.nix gives for publishing `machine` and `unit`.

Worth the change because the failure is silent and misattributed:
rename either principal and the responder starts denying the one that
stopped matching, a denial reaches a NATS client as a timeout rather
than an error, and a hive that is refused looks exactly like a hive
that has not reported yet.
2026-08-17 17:34:51 +02:00
atlas
2e9ce53a32 docs(#3297): move each subject set's rationale next to the list it constrains
The module doc had grown a 37-line preamble carrying three separate
arguments, which the comment-block lint refuses. Splitting it is the
better fix than raising the limit: the reader who is about to widen a
subject list meets the reason not to at the list, not seven screens up.

Also corrects one claim I had no measurement for. The CREATE note said
creating an existing stream with a different config "is an error rather
than a rewrite" — asserted, not observed. What is observed is narrower
and enough: a hive holding this grant leaves the stream config untouched
and never publishes $JS.API.STREAM.UPDATE at all.
2026-08-17 17:34:27 +02:00
atlas
61a13a63d4 fix(#3297): grant the JetStream subjects every client needs first
A grant carrying every bucket-specific subject and neither of these
cannot create the bucket at all: the client times out on `$JS.API.INFO`
long before it reaches a subject that was granted, and a NATS denial
reaches the client as a hang rather than an error.

Both were named by the server's own log, not reasoned about.
`$JS.API.INFO` is the account-level JetStream info every client requests
on connect; `$JS.API.STREAM.NAMES` is how a client finds the stream
backing a bucket. The latter lets a client enumerate stream names in the
account, which in an account holding one bucket discloses a name both
ends already share.

Every earlier measurement missed them, because each either granted
`$JS.API.>` wholesale or ran against a bucket the setup had already
created while unscoped. A minimum established against an existing bucket
is not the minimum for making one, so the leave-one-out that trimmed the
reader's set could not have found this — every candidate it tried was
tried in a world where the bucket existed.

Found by running the shipping gate against the real binary. No unit test
could have: the failure is a timeout inside a real server's permission
check.
2026-08-17 17:34:27 +02:00
atlas
7b5f383b05 fix(#3297): reject a publish template that names no hive
`--hive-publish-subject` exists to put a second stream inside one hive's
namespace. A template with no `{hive}` in it expands to the same subject
for every hive, so the option whose only purpose is scoping becomes the
way to remove it — silently, and only in the deployment that set it.

`Policy::new` returns a `Result` rather than checking at the call site:
that makes an unscoped policy unconstructible instead of merely
unlikely, the same reason `grant` takes its permissions by value. The
error names the offending template and says what goes wrong with it,
because an operator meets it at boot with no other context.

Also documents what the prefix match does not do. A client id is a hive
here because it starts with the configured prefix, not because it
appears in the roster — the responder runs in a container and cannot see
`swarm.hives`. Passing the roster in would close that and would also be
a second place deciding who may connect as what, which `introspect`'s
docs argue against for the same reason admission lives in one place.

The two intra-doc links to `open_or_create` become plain backticks.
Un-gating the `status` module means its module doc now renders in builds
without the `kv` feature, where the item it linked does not exist.
2026-08-17 17:34:27 +02:00
atlas
c8a3159297 feat(#3297): scope a hive's queue grant to its own subjects
Every admitted client got the same unrestricted grant, so any hive could
write any other hive's status key. The responder now derives a
permission set from the caller's identity and mints it into the user
JWT.

A hive may publish to its own KV key and the two JetStream subjects
needed to reach it; the controller may list and fetch every key and
write none; anything else is denied outright. Deny is the default
because every other shape fails open, and silently: a client that
matched no rule and kept the old grant would make the policy advisory.

The subject sets are measured rather than reasoned about, and two of
them are counter-intuitive. `$KV.<bucket>.<key>` alone does not let a
client write that key, because the client resolves the bucket first. And
`$JS.API.>` is not "the JetStream permission": it also covers
`$JS.API.STREAM.DELETE`, with which a hive correctly refused on a
neighbour's key can delete the whole bucket and every hive's data with
it. Granting it would have made per-key scoping decorative, so the
subjects are named individually and a test asserts the wildcard does not
come back as a convenience.

Minimality is by removal: each subject was dropped in turn to confirm
the client breaks without it. That is not pedantry — an additive search
had called a set minimal while two of its five subjects were never
needed, which ships an unnecessary grant with a measurement attached
making it look earned.

Both grants include `STREAM.CREATE` on the one named stream, because
`status::open_or_create` is called by both ends: either may arrive first
on a fresh swarm, and without it a new swarm never gets a bucket at all.
`CREATE` is not `UPDATE`, so a second arrival cannot reshape the bucket
the first one made.

`status::BUCKET` moves out from behind the `kv` feature so this
responder can share it. The name is a `&str` with no dependencies and
only `open_or_create` needs JetStream; gating the name forced a third
consumer to choose between a stack it does not use and a copied literal,
and the copied literal is exactly the disagreement that module exists to
prevent.

Only publish is scoped. Subscription permissions are unrestricted and
unmeasured, and the module docs say so rather than implying a property
nothing established.
2026-08-17 17:34:27 +02:00
atlas
5539819330 fix(#3363): swarm containers write their own resolver file
A swarm service container shares the host netns and force-disables
resolvconf, so it inherits the /etc/resolv.conf nixos-containers copies in
at start (cp --remove-destination, host-side preStart, once per start) and
nothing ever refreshes it. That makes the container's resolver a snapshot of
the host's file at its boot instant.

When that snapshot is wrong the container can never recover, and the symptom
appears arbitrarily far from the cause: swarm-nats-auth cannot resolve
authelia's name, so it denies every auth-callout request and the queue
refuses every client with an authorization violation.

Give each of the four swarm containers a oneshot that writes the resolver
file itself, from the bridge IP, ordered before that container's first DNS
consumer. The shape is the one every agent container already uses.

networking.nameservers cannot do this: resolvconf is its only consumer and
these containers disable it, so setting it renders no file while still
evaluating cleanly. A static environment.etc entry cannot either -- it would
have to survive etc activation landing on the regular file the host already
copied there, which no eval can show.
2026-08-17 17:30:15 +02:00
atlas
bb21ae6d7e docs(gotchas): record how a too-narrow RestrictAddressFamilies presents
The error names the protocol and never the sandbox, so it reads like a
network fault. Records the two easy-to-miss families (AF_INET6 alongside
AF_INET, and AF_NETLINK for getaddrinfo) and the reason the directive rots:
it is a claim about what the program does, and nothing re-checks it when a
client is added.
2026-08-17 17:05:18 +02:00
atlas
2b21bedaa3 fix(swarm-controller): permit the socket families the daemon opens
The unit restricted RestrictAddressFamilies to AF_UNIX, which was correct
while the daemon only served its unix socket. It has since grown three
outbound clients -- authelia token minting and forge calls over HTTPS, and
the queue over NATS -- and every socket(AF_INET, ...) was refused by seccomp.

systemd surfaces that refusal as EAFNOSUPPORT, "Address family not supported
by protocol", so the failure names the protocol and never the sandbox. The
visible symptom was swarm agent creation failing while minting a bearer
token, with a connect error that reads like a network fault.

Permit AF_INET/AF_INET6 for those clients and AF_NETLINK, which glibc's
getaddrinfo needs to enumerate local addresses before returning one. The
rest of the unit's hardening is unchanged.
2026-08-17 16:33:22 +02:00
atlas
c290a3b209 fix(#3363): build the test's client without a system trust store
The new regression test constructed `reqwest::Client::new()`, which
panics in the nix build sandbox: with no system CA store,
`ClientBuilder::build()` reaches `rustls_platform_verifier::Verifier::new()`
and fails, and `new()` is `build().expect(..)`. The test passed locally
because a devshell has `/etc/ssl/certs`, and failed in CI.

Disabling certificate verification takes the branch that installs a
no-op verifier and never consults the platform store, so the client
builds anywhere. That is sound in this test and nowhere else: nothing is
sent, the request is built and its bytes are inspected. The helper says
so at the point someone would otherwise object to it.

Found by reading reqwest's `ClientBuilder::build()` rather than trying
builder flags: the first repro attempt — pointing `SSL_CERT_FILE` and
`SSL_CERT_DIR` at nothing — did not reproduce, so any fix verified
against it would have been verified against nothing.
2026-08-17 01:32:50 +02:00
atlas
3d6a97c61d fix(#3363): authenticate the token request with HTTP Basic
Every access-token request the swarm queue client has ever made was
refused. It sent the client id and secret as form fields
(`client_secret_post`); authelia's client registration allows only
`client_secret_basic`, so the identity provider rejected the request
before looking at the credentials at all.

What made it survive so long is the shape of the failure. The refusals
tripped authelia's rate limiter, whose penalty grows faster than this
client's retry interval — 56s, then 296s, then 535s, against a retry
every 60s — so the limiter never drained and a 429 came back before the
credentials were evaluated. The line naming the real cause appeared
roughly once an hour, inside a continuous storm of a different error,
and the storm read as the problem.

Both other callers in this workspace that present client credentials
already use Basic. RFC 6749 says clients SHOULD, authelia's registration
default says so, and a secret in a header is one fewer place for a proxy
to log it.

The test asserts the shape of the request rather than a server's reply:
Authorization is Basic, the body carries the grant type, and neither the
secret nor the client id appears in the body. It fails on the previous
code with no identity provider, no deployment and no network — which is
what this needed and did not have.
2026-08-17 01:32:50 +02:00
iris
22068bd2b1 swarm-ui: use absolute asset paths in index.html
Relative hrefs (static/main.js) only resolve correctly for a route
one path segment deep - the browser resolves them against the
current URL's directory, so a route two-plus segments deep would
404 the JS/CSS silently once this is wired to a real server. Made
absolute (/static/main.js), matching what build.mjs's own comment
already documents these paths as being served at.
2026-08-17 00:24:44 +02:00
iris
47e89c1c93 swarm-ui: move create-agent page off /agents, rename to CreateAgentPage
mara: the route should reflect creating an agent, and stay separate
from a future agent list page. Renamed AgentsPage -> CreateAgentPage
(file, component, css classes) and moved the route from /agents to
/create-agent - flat, not /agents/new, since index.html's relative
asset links only resolve correctly one path segment deep (filed
separately as a real bug, not fixed here). Leaves the bare /agents
path free for a future roster page.
2026-08-17 00:20:10 +02:00
iris
d044281040 swarm-ui: minimal agent creation page
Adds /agents: a name field that POSTs to swarm-controller's
POST /api/agents (from the CreateIdentity work), shows the queued
job node id, and links to /jobs to watch it settle. Scope matches
the issue exactly - no forge/deploy options, those aren't wired
server-side yet.
2026-08-17 00:20:10 +02:00
damocles
abf3a9e530 fix(swarm-controller): repair broken rustdoc intra-link in forge.rs 2026-08-17 00:05:12 +02:00
damocles
7577d149cf swarm-controller: validate create_agent's name via hive_types::Ident 2026-08-17 00:05:12 +02:00
damocles
1d31bb6e80 swarm-controller: CreateRepo/AddRepoMember/InitAgentConfigRepo forge nodes 2026-08-17 00:05:12 +02:00
atlas
f287ff1ee8 feat(#3297): read the caller's identity out of introspection
The auth-callout responder learned whether a token was valid, never whose
it was, so every admitted client got the same unscoped grant. Scoping a
grant to one hive's subjects needs an identity to scope it to.

`is_active` becomes `identify_caller` and returns the identity rather
than a boolean: `Ok(Some(client_id))` admits as that client, `Ok(None)`
denies. `active: true` with no `client_id` is a denial, and returning an
identity is what makes that unrepresentable instead of remembered --
there is no admitted-but-unscoped value to construct, so there is no
branch a later edit can forget to handle.

The module deliberately ignores `sub`/`scope`/`exp` on the grounds that
modelling a field implies checking it. That still holds: `active` remains
the whole admission decision, made in one place. `client_id` answers a
different question -- as whom -- and is used downstream of an admission
that has already happened, not as a second gate. The module docs say so,
next to the paragraph that would otherwise argue for deleting the field.

An inactive token has no identity even when the body names one, and an
empty `client_id` counts as absent: it would become a blank component in
a subject the grant is scoped to, which is not a narrower permission but
a different one. The two credential parameters are renamed `own_*` --
this responder's introspection credential is not the caller's identity,
and the function now takes one and returns the other.

The grant itself is still unscoped; narrowing it is the next slice.
2026-08-16 23:48:32 +02:00
damocles
4db3d1e4ae fix(hive-forge-notify): stop embedding the issue/PR description in todos 2026-08-16 23:39:54 +02:00
atlas
bb53032897 fix(#3372): grafana serves a unix socket instead of taking a port
Per the operator's call on #3372: rather than moving Grafana off the
forge's 3000 to another number, take it off TCP entirely.

The collision was possible because every swarm service container shares
the host's network namespace, which makes a port a swarm-wide resource
two modules can each claim believing it free — Grafana took upstream's
3000, so does the forge, and `grafana.<swarm-domain>` served the forge
with no bind error and nothing in any log. A socket has a path, and a
path collision is a build-time conflict rather than a runtime coin toss.

Three parts, none of which works alone:

- `protocol = "socket"` with `socket_gid` = nginx's static gid 60.
- Grafana joins that gid inside the container. A non-root process may
  only chgrp to a group it belongs to, and a container has its own user
  database — without the membership Grafana starts, the chown fails, and
  the socket is simply unreachable.
- The socket dir is created host-side by tmpfiles and bind-mounted in. A
  container's /run is an nspawn tmpfs, so it is not visible from the host
  at /var/lib/nixos-containers/<name>/run; the same shape the per-agent
  web.sock already uses.

The `port` option is gone rather than deprecated — nothing can set it to
a colliding value if it does not exist.
2026-08-16 23:21:00 +02:00
atlas
fbb20a56e8 fix: SetCredential with an empty value is dropped by systemd
`swarm-controller.service` carried `SetCredential=queue-client.secret:` — an
empty value, which systemd's parser refuses:

    /etc/systemd/system/swarm-controller.service:38:
    Invalid syntax, ignoring: queue-client.secret:

So the line was dropped on every daemon-reload, and `LoadCredential=` was
fatal again — precisely the failure that default was added to prevent. On a
hive where authelia has not yet minted the secret, the controller refuses to
start rather than coming up with the queue unconfigured.

It looked correct for days because the credential file happened to exist, so
the fail-soft was never exercised.

Measured with `systemd-analyze verify`: an empty value is rejected, any
non-empty one is accepted. The placeholder is a real word rather than filler —
it reaches the token request as the client secret, so authelia refuses it and
the journal names something an operator can act on.

Gate: state/eval-setcredential.sh, with the parent commit as its mutation.
2026-08-16 22:53:50 +02:00
atlas
859833b186 fix(#3370): give Grafana a secret_key, generated in-container on first boot
nixpkgs dropped the default for `services.grafana.settings.security.secret_key`
and asserts on null, so the module I merged an hour ago fails the build on any
host that enables it. That is a broken deploy, not a warning.

Generated in-container like authelia's own keys and for the same reason:
nothing outside the container ever reads it. Generated ONCE and kept — the
key signs Grafana's stored datasource secrets, and rotating it does not
re-encrypt what it already encrypted, so a fresh key per boot would leave
Grafana unable to read its own database. Delivered as `$__file{}`, so it
never enters the store.

The gate missed this because a container is a separate NixOS evaluation with
its own assertions: forcing the host's `config.assertions` never touched the
container's. `state/eval-3265.sh` now forces
`containers.swarm-grafana.config.assertions` and reports any that fail.
2026-08-16 22:46:21 +02:00
damocles
405ed85550 fix(swarm-authelia-bridge): lock the load-insert-publish sequence 2026-08-16 22:38:40 +02:00