Commit graph

3,443 commits

Author SHA1 Message Date
damocles
b4e56b964f pr-merge: don't tell the caller to just un-draft a PR that isn't theirs 2026-08-13 15:21:12 +02:00
damocles
dd5ccb5ce7 hive-forge: don't assert 'conflicts' from a bare mergeable=false 2026-08-13 15:21:12 +02:00
damocles
145a20eae6 fix the other 3 UpsertTodo construction sites CI caught 2026-08-13 15:01:44 +02:00
damocles
1b72ed56ff todos: reopen an acked keyed row when the caller says so 2026-08-13 12:55:20 +02:00
atlas
d5782965db refactor(3202): the gateway publishes its vhost construction kit
Slice 1 of #3202. The listen set, the per-name TLS attrs and the
security headers move out of vhosts.nix into ./vhost-lib.nix and are
published as `services.hyperhive.gateway.lib` (internal, readOnly).

No behaviour change: vhosts.nix consumes the published value, so the
rendered vhost tree is identical.

The point is the next slice. Today a swarm service's vhost lives in
the gateway because only the gateway knows the port pair, the issuer
for a name, and the header block. Publishing those three is what lets
a service module declare its own vhost without the gateway having to
know that service by name.
2026-08-13 12:46:50 +02:00
atlas
272944b98f docs: delete two false cost claims about the boot sweep's drains
Both said a whole-hive graceful stop costs ONE `GRACEFUL_STOP_TIMEOUT`
in total because drains overlap. That is only true for a power op. In a
rebuild subtree the brace holds the build slot across the whole subtree,
drain included, so the boot sweep's per-agent drains serialise and the
sweep costs one timeout per wave of `buildSlots`.

Deleted rather than corrected. The right cost statement depends on an
operator knob and belongs in docs/coordinator.md if it belongs anywhere;
a comment that has to hedge about a config value is the kind that goes
stale silently. A comment saying nothing beats one that lies.
2026-08-13 12:46:06 +02:00
iris
e7f4a19939 swarm-ui: real hive-roster overview page
Fixes hyperhive#3223.

swarm-controller: GET /api/hives (utoipa-annotated same as /health),
serving the swarm's hive directory (name + domain) loaded once at
startup from a new SWARM_CONTROLLER_HIVES env var. The controller's
NixOS module sets it from services.hyperhive.swarm.hives, JSON-encoded
the same way hive-c0re already builds HYPERHIVE_PEERS for its own peer
list (environment.nix) — the full directory here rather than
peers-minus-self, since a swarm-level daemon has no 'self' hive to
exclude. Unset/malformed both fall back to an empty list with a
warning rather than failing startup, so /health stays answerable even
if this one env var is wrong.

swarm-ui: App.tsx's Home route fetches /api/hives and renders it
through the already-merged <Table>/<StatusChip>/<Panel> primitives —
name, domain (linking out to that hive's own gateway-routed
dashboard), and a static "configured" status chip until a real
online/stale/offline rollup exists server-side. Also gave swarm-ui a
base <a> color (theme's --blue) — base.css covers body/typography but
not links, and this is genuinely page-level rather than any one
component's concern.

Verified end to end, not just source-reading: ran the real
swarm-controller binary with SWARM_CONTROLLER_HIVES set, curled
/api/hives + /health over its actual unix socket; separately served
the real swarm-ui dist against a mock /api/hives and screenshotted the
rendered table. Also re-verified the nginx wiring evaluates (same
throwaway nixosSystem eval technique as #3212) — SWARM_CONTROLLER_HIVES
resolves to the expected JSON shape.

cargo test/clippy -p swarm-controller clean (2 tests, including a new
load_hives one covering missing/malformed/valid env var states). npm
run build + typecheck clean.
2026-08-13 11:21:30 +02:00
damocles
5300c242f3 skills(forge-issue-filing): soften labels section per review 2026-08-12 22:03:41 +02:00
damocles
ef40b03c87 skills: add forge-issue-filing skill 2026-08-12 21:56:00 +02:00
iris
4828c96957 swarm-controller: OpenAPI spec + gateway swagger UI wiring
Rust half mirrors hive-c0re/src/dashboard/mod.rs's utoipa pattern
exactly: an ApiDoc root, #[utoipa::path(...)] on /health (the one
existing route), and a raw JSON route at /api/openapi.json served
via OpenApiRouter::split_for_parts(). Only annotated routes appear
in the spec.

Gateway wiring extends the swarm-UI vhost (the only vhost swarm-
controller is reachable from) with:
- /api/ — proxied to the controller's unix socket untouched (no URI
  segment after the socket path), so a route swarm-controller
  registers is the path nginx forwards, no prefix-stripping to keep
  in sync by hand.
- /api/docs/ (+ the bare /api/docs redirect) — the same
  swagger-ui-theme dist the per-hive dashboard already serves at its
  own /api/docs/, reused as-is since it's generic.

Both new locations reuse the same auth_request block the vhost's own
'/' already applies, factored into a shared swarmAuthRequest string —
auth_request does not inherit across sibling nginx locations, so
without this the page itself would be gated while its own API and
API docs sat open.

cargo test -p swarm-controller + cargo clippy --all-targets both
clean. Verified the new nginx wiring evaluates correctly with a
throwaway nixosSystem eval (services.hyperhive.swarm.{controller,ui}
enabled): /api/ proxies to the socket, /api/docs redirects, and both
require auth_request the same as the vhost root.

Fixes hyperhive#3212
2026-08-12 21:36:01 +02:00
iris
8642d4acf6 swarm-ui: colocate component CSS as JS-side-effect imports
Per mara's review question on PR#3219 ('shouldnt the jsx files import
their css?'): each component now does its own import ('./Shell.css',
'./Panel.css', ...) instead of swarm-ui.css centrally @import-ing
every component's stylesheet. esbuild folds every .css reachable from
main.tsx's import graph into one main.css companion output next to
main.js — no separate build step, this is bundle:true's existing
behavior, just not exercised until now.

swarm-ui.css keeps only the shared base reset (@hive/shared/base.css)
since that isn't any one component's concern. Added src/css.d.ts
(ambient '*.css' module) since tsc otherwise rejects a side-effect
import of a non-JS/TS specifier.

Side benefit: a component nothing imports (yet) no longer ships its
CSS either — StatusChip/Table aren't referenced from App.tsx today,
and main.css correctly only carries Shell.css + Panel.css. The old
central-import approach shipped all four unconditionally.

npm run build + typecheck clean. Re-screenshotted the real dist —
pixel-identical to before this change.
2026-08-12 21:33:44 +02:00
iris
55f9e6c15f swarm-ui: one subdirectory per ui/ component
Per mara's PR review request: Panel/StatusChip/Table each move into
their own subdir (ui/panel/, ui/status-chip/, ui/table/) colocating
the component with its stylesheet, matching shell/ (Shell.tsx +
Shell.css already lived this way). Import paths in App.tsx and
swarm-ui.css updated to match; no behavior change.

npm run build + typecheck both clean.
2026-08-12 21:33:44 +02:00
iris
67eb0c660d swarm-ui: page shell + layout primitives
Structural foundation split out of hyperhive#3118 per mara's steer:
structure first so the real overview page (hive roster) and later
routes (swarm-wide agent roster) land as content changes rather than
each reinventing chrome + nav + a table/panel/chip shape.

- <Shell>: header bar (branding) + nav row, wraps every route. Route
  list lives in Shell itself (one small SPA, one place to know its
  own nav).
- ui/Panel, ui/StatusChip, ui/Table: the three primitives the
  overview page's actual scope (hive roster: name/domain/status,
  linking out to each hive's own dashboard) calls for, nothing
  speculative beyond that.

Preact-native styling (plain CSS files imported via swarm-ui.css, no
shadow DOM — this package renders into light DOM) — not
@hive/shared's chrome.css, which is the old MPA dashboard's visual
language. Same base16/Catppuccin color tokens via theme.css/colors.css
so it still reads as hyperhive.

npm run build (whole frontend workspace) + npm run typecheck both
clean. Verified with a real headless-chromium screenshot against the
built dist, not just source-reading.

Fixes hyperhive#3211
2026-08-12 21:33:44 +02:00
atlas
75f99ecafb feat(3216): swarmctl shell completions
Mirrors hivectl exactly: a `completions <shell>` verb that walks the
live clap tree, and a package that pipes it into installShellCompletion
for bash/zsh/fish. Generating from the command tree rather than writing a
script by hand is what keeps completions from drifting away from the
verbs they complete — the same reason `markdown-docs` renders the docs
from that tree.

Dispatched before PathArgs::resolve() for the same reason markdown-docs
is: 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 — exactly where it runs.

swarmctl leaves mkBinPackage for its own derivation, since the extractor
installs a binary and nothing else.
2026-08-12 21:09:29 +02:00
atlas
0b1b08dfe6 fix(3149): the host asks the hive's resolver, at the bridge IP
Per mara: a general fix, not one name in one container.

Every container inherits a COPY of the host's /etc/resolv.conf at start
(nixos-containers.nix: cp --remove-destination, one shot, not a
bind-mount), so the address written there is the address every container
tries - in its own netns. That makes the value load-bearing:

  value       host  host-netns containers  bridged containers
  127.0.0.1    ok           ok             THEIR OWN loopback
  bridge IP    ok           ok                    ok

dnsmasq binds lo and the bridge, so the bridge IP works for the host
too. It is the only value correct on both sides of a netns boundary.

resolveLocalQueries goes ON for its plumbing, not its address: it points
dnsmasq's own upstreams at a separate resolv-file, without which
dnsmasq reads /etc/resolv.conf and every non-hive query loops the moment
the host is pointed at dnsmasq. Its two loopback-publishing effects
(networking.nameservers and resolvconf.useLocalResolver) are overridden.

Cost: the host's DNS now depends on dnsmasq being up. Every container
already did.

The forge container keeps its hosts entry from the previous commit -
not redundancy, a fallback in a different failure domain: it works with
no DNS at all, so SSO does not ride on a host-wide resolver change.
2026-08-12 21:09:21 +02:00
atlas
25d5b4b69c fix(3149): the forge container can resolve the authelia name
The login source still failed after the argv fix, with

  dial tcp: lookup auth.constellation.darkest.space: no such host

The hive's dnsmasq is authoritative for the swarm service names, but
only containers whose resolv.conf points at the bridge ask it — agent
containers do, via an explicit unit written for that reason
(nix/agent-modules/network.nix). hive-forge resolves through the host's
resolvers instead, and the swarm domain has no public records, so
discovery fails for a name that resolves fine one container over.

Publish it in the container's own hosts file, mapped to 127.0.0.1:
sharing the host netns, loopback is the host, where nginx already serves
that vhost. TLS still validates - the CA trust bundle is bind-mounted
and the leaf covers the name. Gated on authelia being local, since a
remote provider's name belongs to another machine.

Gate (state/eval-3149-hosts.sh) asserts the RENDERED /etc/hosts rather
than the option, plus an absence probe with SSO off and a check that the
discovery URL names the same host the entry publishes.
2026-08-12 21:09:21 +02:00
iris
861a1f8f26 job_queue: filter jobq graph snapshot by per-node state
graph_snapshot previously filtered which whole roots got projected
based on the root node's own state, so a group root that was still
Running but had already-Done internal steps couldn't be filtered
down to just its live nodes, and a filtered-out root hid its entire
subtree even when a descendant still matched.

Apply the states filter after GraphWire::wire_snapshot instead, over
every node in the flattened tree, not just roots. The jobq-graph
client already handles an orphaned node (parent filtered out) by
promoting it to a rendered root, so this is safe on the client side
with no changes needed there.

Fixes hyperhive#3210
2026-08-12 20:55:25 +02:00
atlas
0b6b3b755d fix(3044): a parent's mount of a child's config is read-only
The parent's copy is for reading a child's config; a change to it is a PR
on the child's repo, made from a clone and merged after review. A
writable mount is a second path to the same file that skips that review,
which makes the boundary a convention rather than a permission.

Confirmed with ruth before flipping: it clones from the forge and opens a
PR, including for a brand-new child's first config.

The prose was the larger half. docs/approvals.md did not merely describe
the old mount, it *instructed* agents to use it ("can therefore edit,
commit, and submit changes for any of its direct children directly inside
its container"), and the doc comment in host_config.rs asserted a
dependency that never existed: the InitConfig seed runs as hive-c0re
against the host path, and read_only on a bind constrains writers inside
the container only. That comment is what produced issue #3206, now closed
as invalid.
2026-08-12 20:33:47 +02:00
atlas
d03549c40f fix(3213): the swarm UI vhost forces https
authelia refuses to authorize a non-https target: the auth subrequest for
`X-Original-URL: http://…` answers 400, and nginx's auth_request only
understands 2xx/401/403, so a plain-http visit died as "auth request
unexpected status: 400" — a bare 400 in the browser with no hint that a
login exists.

The shared vhostListen binds :80 as well as :443 and every vhost used
addSSL, so the door was open on a port the lock cannot work on. That is
harmless for forge and matrix, where http is merely insecure rather than
broken, so the asymmetry stays local to the one vhost whose correctness
depends on the scheme.

removeAttrs because nixos asserts on a vhost declaring both addSSL and
forceSSL.

Measured against the pinned binary rather than reasoned about:
  X-Original-URL: http://constellation…/   -> 400 Bad Request
  X-Original-URL: https://constellation…/  -> 401 + Location: auth…?rd=…
2026-08-12 20:12:45 +02:00
iris
4b71a76b12 agent: drop dead .header-pill-loose CSS
Styled variant with zero HTML/JS reference anywhere in the frontend
tree, found while surveying pill/chip/badge sites for the "extract
pill component" issue. The extraction it was originally paired with
(PR#3197) got closed as not worth the churn for 2 instances, but this
one finding stands on its own — no reason to carry dead CSS forward.

npm run build clean.
2026-08-12 19:33:07 +02:00
iris
8dd6d05d6c frontend: unify pill/chip/badge shape into shared CSS
Per mara's steer on #3053 ("chip/pill/badge is the same if you squint
... theme unification is part of the goal" then "make it common css
instead of component, thats fine. but make them look unified (not as
much per usage css)"): shared CSS, not a JS component.

New @hive/shared/pill.css defines two classes, `.hive-pill` (primary
state chips) and `.hive-pill-sm` (secondary meta chips) — border/
border-radius/padding/font-size/letter-spacing (colour stays per-site,
the meaningful semantic part). Every render call site across dashboard
(swarm.js/core.js/builds.js) and agent (index.html/app.js) now carries
one of the two shared classes directly, alongside its own existing
semantic-colour modifier class.

Second cut of this PR, per argus's approve + mara's follow-up review
comment on the first cut: the first version instead enumerated every
legacy classname (`.badge`, `.status-badge`, `.header-pill`, etc.)
straight into pill.css's own selector groups so no call sites needed
touching. Mara's correction: that just relocates the duplication
rather than removing it, and the shared CSS shouldn't have to keep
naming every consumer. This version does the real rename instead.

Most visible consequence, unchanged from the first cut: dashboard's
`.badge` family moves off its own shape (2px square corners, uppercase,
tighter padding) onto the shared rounded-pill shape + agent's "sm" tier
sizing. `npm run build` clean across all three packages; verified the
compiled bundles carry the new classnames at every call site (dashboard
JS, agent index.html + app.js), not just the source tree.

Fixes #3053
2026-08-12 19:25:45 +02:00
iris
92b9e67327 frontend: unify pill/chip/badge shape into shared CSS
Per mara's steer on #3053 ("chip/pill/badge is the same if you squint
... theme unification is part of the goal ... make it common css
instead of component, thats fine. but make them look unified"):
shared CSS, not a JS component.

New @hive/shared/pill.css defines the shape (border/border-radius/
padding/font-size/letter-spacing — color stays per-site, that's the
meaningful semantic part) in two tiers matching what was already
organically in use: `.hive-pill` (primary state chips) and
`.hive-pill-sm` (secondary meta chips). Every pre-existing classname
that drew its own copy of this shape (dashboard's `.badge` family,
agent's `.status-badge`/`.state-badge`/`.header-pill`/`.ctx-badge`/
`.model-chip`/`.effort-chip`) is folded straight into the same
selector groups, so no markup or JS changes were needed anywhere —
every render call site keeps constructing the exact same classnames
it always did.

Most visible consequence: dashboard's `.badge` family moves from its
own shape (2px square corners, uppercase, tighter padding) onto the
shared rounded-pill shape + agent's "sm" tier sizing, matching the
"look unified" ask directly. `npm run build` clean across all three
packages.
2026-08-12 19:25:45 +02:00
atlas
ae05289e8b docs(3201): setup guide — the group flag is not one-shot any more
The step-3 warning said to keep `--group admins` and left the impression
that a later re-run would fix a missing one. It would not: `user add`
refuses on an existing name, so until `user update` existed the only
repair was hand-editing two files as root. mara asked the question the
warning should have answered — "will the cmd just add the group or do i
get a whole new user?".

Deliberately in this PR rather than #3199's: the sentence only becomes
true once the verb it names exists.
2026-08-12 19:23:31 +02:00
atlas
24ee0990a2 feat(3201): swarmctl user update — change an existing subject's attributes
`user add` refuses on an existing name, so the `--group` flag it takes at
creation time could not be added afterwards at all: repairing an account
meant hand-editing both users.json and the rendered users.yml as root.
mara, on #3167: "i will not edit those files by hand, we will have the
same issues elsewhere".

The merge rules live in users.rs as a pure function over a UserUpdate, so
they are testable without a command line, a container or a running
authelia — main.rs's arm only loads, applies, publishes and prints.

Removals are strict and everything else is idempotent, which is the one
asymmetry here and is deliberate: a --remove-group naming a group the
user does not have fails, because a revocation that reports success
without revoking is the outcome nobody re-checks; while refusing an
already-satisfied set would make the multi-attribute call this verb
exists for break whenever one of the values was already right.

A command that changes nothing at all still fails — it would otherwise
rewrite both files and restart the SSO provider to no effect.

Passwords are out of scope: regenerating a credential is a different
intent from editing an attribute, and folded together an attribute edit
can invalidate a login by accident.

Extracts publish() from user_add so both verbs share the
render -> store -> users.yml -> restart ordering and the comment that
explains why that order, rather than the second verb copying it.
2026-08-12 19:23:31 +02:00
iris
086f3f43d6 claude-plugins: add headless-screenshot skill to base
Captures a lesson from this session: "no browser in this container"
usually means no browser pre-installed, not unreachable. nixpkgs#chromium
is a `nix shell` away and one headless invocation renders a page +
writes a PNG, no puppeteer/playwright needed for a static screenshot.

Every agent hits "I have no browser" sooner or later when reviewing a
frontend change; this has been rediscovered independently across
several sessions rather than remembered. Belongs in base (every agent
needs it, not role-specific) alongside the sibling skills already
there.
2026-08-12 18:31:55 +02:00
atlas
4dd5093c6f docs(3167): the setup guide gains a swarm UI step, and one group name
Per mara on the PR: the guide someone follows on a fresh deploy is a
different page from the one they read while debugging, and only the
second existed.

Also fixes a contradiction the request surfaced: setup.md and
swarm/sso.md have told every operator to create '--group admins' since
the bootstrap step existed, while the new rule required 'operators'.
This is the first rule that CONSUMES a group name, so it takes the one
the guide already creates - inventing a second would have meant every
account made by following the guide silently failing the check it was
supposed to pass, and for mara specifically a migration step that now
may not be needed at all.

setup.md's step 3 says why --group is not decoration; the new step 4
says what decides whether the page opens (the group, and the name
resolving) rather than listing a command, because there is nothing to
run. Steps renumbered, including the matrix block's internal 4a-4e.
2026-08-12 17:57:52 +02:00
atlas
40e1ed2967 fix(3167): wire the swarm-ui package from the flake, not a pkgs attr
The option defaulted to pkgs.swarm-ui, which does not exist: this
project has no overlay - flake.nix's nixosModules.default wires package
options with mkDefault from its own package set, and swarm.controller
does exactly that. The default would have failed to evaluate on any real
deployment, not just in a test harness.

Found by the gate forcing .package: the earlier probes passed because
they only read option values that never touched it.
2026-08-12 17:49:21 +02:00
atlas
470d2ad845 docs(3167): the swarm UI page, and the group step that gates it
New docs/swarm/ui.md (split-page shape, per the docs rule), linked from
the swarm README and added to the gateway's vhost map.

Leads with the step that separates 'protected' from 'locked out':
swarmctl user add <you> --group operators. auth_request asks whether
there is a session; the access_control rule is what makes it mean
operator, and an account created before the rule existed has no groups.

Also records the four wiring sites a swarm service name needs, with the
certificate one called out - serviceDomains is both the sub-CA's
nameConstraints set and the leaf's SANs, and the apex is a sibling of
the other three rather than a parent, so nothing issues for it
implicitly.
2026-08-12 17:46:15 +02:00
atlas
600fa15afe feat(3167): publish the swarm UI's name to agents and to local dev
Four wiring sites, same as forge/matrix/authelia: vhost, cert name, DNS,
local-dev hosts. This commit is the last two.

The apex is a SIBLING of forge./chat./auth., not a child of anything
dnsmasq already answers for, so the /<hive domain>/ rule does not cover
it - without an explicit record the name is unresolvable from every
agent netns while working fine from the host, which is the shape that
nearly shipped when authelia's name moved.

Published to agents deliberately (mara: publishing it is fine).
Reachability is not the access control: the vhost's auth_request plus
authelia's group:operators rule are, and an agent that resolves the name
still cannot open the page.
2026-08-12 17:43:35 +02:00
atlas
0dc2e6b64f feat(3167): the swarm UI vhost, behind an authelia subrequest
Serves the static bundle on the swarm apex and gates it with
auth_request - the first one in this gateway, everything else being
auth_basic + htpasswd.

Header set measured against the pinned authelia (4.39.20) rather than
copied from an example: X-Original-URL and X-Original-Method are present
as literals and are what the auth-request implementation reads, while
X-Forwarded-Uri does not appear in that binary at all - sending it would
look like configuration and be dead weight. The endpoint is
/api/authz/auth-request; /api/verify is the legacy path older examples
show.

auth_request_set captures the return URL BEFORE the error_page jump: in
the 401 handler $request_uri is the internal one, so building the link
there sends the operator back to the auth subrequest rather than the
page they asked for.

Authorisation is the access_control rule from the previous commit, not
this subrequest: auth_request answers 'is there a session'.
2026-08-12 17:40:24 +02:00
atlas
d337fec565 feat(3167): authelia gates the swarm UI on an operators group
default_policy = one_factor means 'any authenticated user', which is
authentication and not authorisation. The swarm UI is operator-only and
agents are getting authelia accounts of their own, so a session alone
would be enough to open it the day that lands - the vhost's auth_request
would be a check nobody fails.

Adds an access_control rule for the UI's domain requiring
subject = group:operators, present only when the UI is enabled. The
group name is a constant beside the rule: it is also what an operator
types into 'swarmctl user add --group', and a configurable name is one
more way for the rule and the account to disagree silently.
2026-08-12 17:31:30 +02:00
atlas
f2840612c0 feat(3167): options + certificate name for the swarm UI
New swarm-ui module: enable (derived from swarm.controller.enable - the
UI reads that daemon's state over its socket, so the host that runs the
controller is the host that can serve the UI), domain (defaults to the
swarm apex; an option so a hive can pin it like forge/matrix can), and
package.

Adds the name to swarm.serviceDomains, which is both the services
sub-CA's nameConstraints set and the leaf's SAN set. The apex is a
SIBLING of forge./chat./auth., not a parent, so nothing issues for it
implicitly - left out, the vhost falls back to the hive leaf and the
swarm's front page opens with a name mismatch.

Asserts the UI domain differs from the hive domain: the gateway's
default server already answers for the latter, and two vhosts claiming
one server_name resolve to whichever nginx picks rather than erroring.
2026-08-12 17:29:13 +02:00
müde
657dba762b fix(3191): let hive-c0re write the gateway conf dir under strict sandboxing
Moving agents.conf/gateway.htpasswd out of StateDirectory (0e1a975f)
left hive-c0re unable to write them at all: ProtectSystem=strict only
keeps StateDirectory and RuntimeDirectory writable, so every write to
/var/lib/hive-gateway/conf failed with EROFS. agents.conf never got
past the tmpfiles pre-seed, so nginx had no location block for any
agent.
2026-08-12 16:39:58 +02:00
atlas
71b0f1c5b8 fix(3149): pass the oauth flags as an array, and verify the source exists
The login source was never registered on any boot since it landed:

  forgejo-sso-source[422]: Command error: unexpected arguments: profile, email, groups'

The flags were built as a shell STRING and word-split at the call site.
Splitting happens after quote removal, so the quotes inside the value are
just characters: --scopes 'openid profile email groups' reached forgejo
as four words, three of them unexpected and one carrying an apostrophe.
escapeShellArg interpolated into a string that is later word-split is a
no-op that looks exactly like protection.

An array carries the argument boundaries instead of re-deriving them from
whitespace, and the shellcheck disable goes with it.

Also assert the effect: the unit now fails if the source is absent from
'admin auth list' afterwards. The old failure exited non-zero and was
still invisible to every check that read the rendered script rather than
its result.
2026-08-12 15:04:18 +02:00
iris
3c262b6c1b docs: rename docs/components to docs/crates
The generated landing page's own H1 already said "Crate reference" —
the directory name should match. Rename docs/components/ -> docs/crates/
and update the generating derivation (nix/packages/reference-docs.nix),
its default.nix caller comment, and docs/README.md's link.

Fixes #3193
2026-08-12 13:32:55 +02:00
atlas
b12be26f20 docs(3191): drop the migration history from the gateway comments
Per mara on the PR: how a thing used to be deployed stops being
relevant the moment this is merged and the hives are rebuilt, so the
comments state the current constraint and nothing about the container
that used to hold it.

Removes the two ⚠️ blocks this PR added (hive-tls-ca's ordering, the
resign propagation), the matrix ordering's, dnsmasq's resolv.conf
paragraph, and the pre-existing 'REMOVED WITH THE CONTAINER' inventory
in the gateway module. The 🚨 do-not-simplify warning on the cert-copy
unit keeps both its reasons and loses the container framing.

Kept deliberately: the two operator-facing 'this used to default to
true' option notes (migration information for someone upgrading), and
the SupplementaryGroups block, which documents why an absence is
load-bearing rather than how a deployment used to look.
2026-08-12 13:26:58 +02:00
atlas
f85724a638 docs(3191): the gateway's comments describe a host service, not a container
Prose-only sweep of every remaining claim that nginx or dnsmasq lives
in a container: the port comment (root in a container -> root on the
host), upstreamHost's netns rationale, the ACME state dir, the store
path reachability note, the vhost tree header, dnsmasq's resolv.conf
paragraph (there is no copy and no path unit watching it any more),
the two hive-network bridge comments, and swarm-controller's socket
access-control note, which described a bind-mount that no longer
exists.

No behaviour change; all of it was describing a mechanism that was
deleted.
2026-08-12 12:20:28 +02:00
atlas
d70b1e0a97 fix(3191): order gateway TLS against the units that read it, not a dead container
Removing the gateway container retired two things that were still
depended on: an ordering edge and a delivery path.

- hive-tls-ca ordered itself before/requiredBy
  container@hive-gateway.service. That unit no longer exists, so the CA
  was sequenced against nothing while its real consumer,
  hive-gateway-self-signed-cert (which nginx Requires=), could win the
  race and fail its copy under set -eu, blocking nginx.

- hive-tls-resign propagated a rotated leaf with
  `systemctl -M hive-gateway … || true`. The machine is gone, so both
  calls failed and both failures were swallowed: the unit logged
  "propagating" and exited 0 while nginx served the stale copy until it
  expired. Host units now, so no -M and no || true — a failed
  propagation fails the timer.

- container@hive-matrix ordered after the gateway container to get the
  resolver up first. dnsmasq is a host service now, so it orders after
  dnsmasq.service, which is what the edge always meant.
2026-08-12 12:18:13 +02:00
atlas
0e1a975f9f fix(3179): the gateway's config files get their own state dir
`agents.conf` and `gateway.htpasswd` move from /var/lib/hyperhive/gateway
to /var/lib/hive-gateway/conf, alongside the `tls/` the gateway already
kept there.

nginx reads both as an unprivileged user. Under c0re's state dir it could
only reach them by traversing a directory systemd re-declares `0750
hive-core` on every c0re start — so nginx was given `SupplementaryGroups
= [ "hive-core" ]`, which also handed it read access to everything else
group-readable in that tree. The tokens are individually 0600, but the
broker sqlite carries no explicit mode: every message between every agent
was readable by the process whose job is parsing untrusted network input.

Moving the files removes the need and the exposure together. The group is
gone, and its absence is now commented as load-bearing so it doesn't come
back as a fix for a symptom it would recreate.

Also drops this module's `/var/lib/hyperhive` tmpfiles rule. It declared
`0755 root root` and could never win against `StateDirectoryMode`, and a
losing declaration still reads as a guarantee — that is what sent the
first diagnosis of the outage looking for who had changed the mode.

Ordering is unchanged and still the thing that makes a fresh boot work:
tmpfiles runs before services and seeds both files empty-but-valid, nginx
names them (an `include` of a missing file is fatal, not empty), and
content arrives when c0re writes and reloads — which it does on every
topology change, so a boot against the empty seed resolves itself.

Folds in the mode fix: `write` now sets 0644 on the tmp file before the
rename, because a rename carries the source's mode and discards the
destination's, and the tmpfiles rule that declares 0644 is
create-if-absent so it never re-applies.
2026-08-12 10:29:27 +02:00
atlas
ac15c68cd2 docs(3189): the error-page scope text describes the new shape
Two claims this branch falsified and left standing, both caught in
review:

`vhosts.nix`'s `errorPages` param comment enumerated the set
(`{ notFound, unreachable, unauthorized }`) and adding a fourth member
made the enumeration wrong at the point a reader consults it.

`gateway.md` said extending custom error pages beyond the per-agent
routes was "a separate follow-up" — while this branch is that follow-up,
so the doc contradicted the code sitting next to it.

Rewrites the scope rule as the criterion rather than a list, since a
list is what went stale: a route earns a page when the default status
would point at the wrong component. That covers the per-agent routes and
the sso vhost, and explains why forge/matrix/fluffychat still don't
qualify — their upstreams being down means what the code says.
2026-08-12 10:29:16 +02:00
atlas
275d502639 feat(3189): the sso vhost serves a themed page instead of a bare 502
A dead authelia upstream almost always means "no users yet" — authelia
treats an empty user store as a fatal startup error, so an enabled but
unbootstrapped swarm crash-loops behind a vhost that is working
perfectly. nginx's default 502 says the opposite: it points at the
proxy, which is the one component that is fine.

Adds `ssoUnavailable` to the shared error-page set and wires it on the
authelia vhost the same way the per-agent blocks wire
`__hive_agent_unreachable`: `proxy_intercept_errors on` plus an internal
location serving the static page.

The page leads with the bootstrap command rather than burying it under
an explanation, and names the container journal as the fallback for the
cases where users are not the problem. Same Catppuccin template as its
siblings, so this costs no new styling.
2026-08-12 10:29:16 +02:00
atlas
a2ea91afe2 docs(3186): the setup walkthrough gains the swarm SSO bootstrap
`setup.md` is the page someone follows on a fresh deploy, and it had no
step for authelia at all — so the operator finished the walkthrough with
a crash-looping container and a 502, having done everything the doc
asked. The warnings added to sso.md and gateway.md only help someone who
is already debugging; this is the page that decides whether they end up
debugging.

New step 3, gated on `swarm.authelia.enable`, placed before matrix
because it is part of getting the front door open. Says plainly that it
is required to finish the install, names the symptom it prevents, and
links sso.md for the detail rather than restating it.

Renumbers matrix (4), spawn (5) and host commands (6), including the
matrix block's own 3a–3e labels, which would otherwise disagree with
their heading.
2026-08-12 10:06:37 +02:00
atlas
014ae405e0 docs(3186): the first user is required to start authelia, not to log in
`swarm/sso.md` described the empty user store as a resting state — a
provider that is reachable but has nobody in it yet. It isn't. Authelia
validates the store at startup and treats zero users as fatal:

    error reading the authentication database: could not validate the
    schema: users: non zero value required

so it exits 1, systemd restarts it, and an enabled-but-unbootstrapped
swarm presents as a crash-looping container behind a vhost that is
working correctly. The observed symptom is `502 Bad Gateway`, which
reads as a proxy fault and is not one.

Says so, gives the error text to grep for, and marks the `swarmctl user
add` step as required to finish the install rather than as a first-login
convenience. `gateway.md` gains the same warning next to the vhost,
because that is where someone lands when the 502 is what they can see.

The reason the store ships empty is unchanged and still right: seeding
an account means a credential in a config file. What was wrong was
calling the resulting state harmless.
2026-08-12 00:31:07 +02:00
atlas
21ceb75850 feat(3089): all-local asserts the swarm controller
`enableAllLocalDefaults` already asserts the swarm's shared services and
its CA; the controller was the one swarm-level thing it left off, so the
default deployment ran authelia, matrix and the forge with nothing
controlling them — and, until the previous commit in this area, without
`swarmctl` either.

The controller's own option stays `default = false`. Running it is a
statement about swarm topology rather than about hyperhive being
installed, and no single host can infer that on its own. But "this box
is the whole deployment" IS that statement, which is why the mode may
assert what `services.hyperhive.enable` never could.

Derived from the mode, not from `enableRequiredServices`: a hive in a
larger swarm can legitimately want the shared services without being the
host that controls them.

`mkDefault`, so `enableAllLocalDefaults = true` with an explicit
`controller.enable = false` still yields a controller-less box — the
mode fills in for an operator who hasn't spoken and never argues with
one who has.
2026-08-11 23:50:19 +02:00
damocles
04ff7a6f05 gateway: reword bare tracker tag in dnsmasq.nix comment (tracker-tag lint) 2026-08-11 23:47:09 +02:00
damocles
2bf0d73c8b hive-c0re: drop needless async from pause_many (clippy pedantic) 2026-08-11 23:47:09 +02:00
damocles
20a7a21053 hive-c0re/hivectl/hive-agent: pause as a job-queue DAG node (closes #3056) 2026-08-11 23:47:09 +02:00
atlas
a92f7351d9 fix(3086): the hive leaf stops naming two services it already covered
`DNS:forge.<domain>` and `DNS:matrix.<domain>` sat beside
`DNS:*.<domain>`, which already covers them — a wildcard covers exactly
one label and those are one label. Naming them read as policy, and that
is why they outlived the thing they described: a configured service name
no longer has to be under this hive's domain, and when it isn't, this is
the one list it cannot join.

Proved by execution rather than eval, because the SAN list is a printf
argument filled in at runtime and eval can only show the template — the
lesson from the gateway outage. openssl-verified against a CA carrying
the real name constraint: old and new leaves agree on every name the hive
serves, the wildcard genuinely stops at one label, and a sibling name
added to this leaf fails.

That last case came back stronger than expected: a nameConstraints
violation invalidates the certificate, not the offending SAN, so one
foreign name here would break the leaf for the dashboard and every other
vhost sharing it. The comment says so now, and gateway.md's cert-shape
paragraph no longer describes SANs that are gone.
2026-08-11 23:35:06 +02:00
atlas
660629a7c6 docs(3083): getting into the SSO provider the first time
The vhost half of this change is only useful with an account behind it,
and the provider is generated with an empty user set on purpose. Document
the `swarmctl user add` step rather than automating it: bootstrapping an
IdP non-interactively means a secret arriving from a file, an env var or a
nix expression, all worse than one command typed once.

The gateway and network pages gain the rows they would otherwise be
missing — vhost map, local-dev hosts entry, and the resolver's
authoritative-name list.
2026-08-11 23:30:44 +02:00
atlas
67a20d387f feat(3083): the gateway serves authelia
authelia has listened on 127.0.0.1:9091 since it was stood up, with
nothing proxying to it — so `auth.<swarm.domain>` resolved and then
refused the connection. This is the vhost that was never written.

Follows forge and matrix exactly: one `optionalAttrs` attrset merged into
`virtualHosts`, TLS chosen by `vhostTlsFor` (the swarm-services leaf
already names it, since `swarm.serviceDomains` includes
`authelia.domain`), and the same four wiring sites those two occupy —
vhost, dnsmasq address, local-dev `/etc/hosts`, and the arg lists that
feed both files.

Gated on this host running the container, not on authelia being
configured: every hive knows the swarm's `authelia.url`, but only the one
serving it may claim the name. A client hive declaring this vhost would
answer for a service it does not run.

Two things that are deliberate rather than incidental:

`X-Forwarded-{Proto,Host,Uri,For}` are set because authelia decides by
the *original* request — the login redirect and the session cookie's
domain both derive from them. Without them every request looks like it
arrived at 127.0.0.1 over plain http.

And no `auth_basic`. Applying the gateway's basic-auth block to the SSO
provider would put the login page behind the login mechanism it exists to
replace.
2026-08-11 23:30:44 +02:00