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.
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.
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.
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'.
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.
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.
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.
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.
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
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.
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.
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.
`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.
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.
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.
`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.
`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.
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.
localHostsEntry's 127.0.0.1 rows leak into the address= answers dnsmasq
gives agent containers now that it runs on the host, sending every
agent back to itself instead of the bridge IP.
Follow-up to my own #3171. That PR gave the deploy path its own cache and
left the paragraph above it saying "package builds and checks reuse this"
— which stopped being true in the same commit that made it false.
Merged into one accurate lead-in. The surviving facts (built once, every
consumer passes the same `cleanSrc`) are still worth stating; the claim
about who consumes it belongs to the two blocks below, which say it
correctly.
crane's `buildDepsOnly` defaults `doCheck = true`, which adds
`--all-targets` and a `cargo test --no-run` — so the dep cache compiles
every dev-dependency and test harness in the tree. The checks need
exactly that. `workspaceBuild` never runs a test and never links one, and
was sitting on the same cache.
Split into two, one per audience. Not a duplication of something shared:
CI evaluates against hyperhive's own nixpkgs pin while a host overrides
it — security patches cannot wait on a lock bump upstream — so the two
closures already differ and neither substitutes for the other. Giving
each its own cache therefore costs nobody a second build; it stops the
deploy compiling artifacts only CI consumes.
Both still build from crane's dummy source, so both hashes key on
Cargo.toml/Cargo.lock: a code edit rebuilds neither, a dependency or
toolchain bump rebuilds both. That bump is the recurring cost this
targets — on a host overriding nixpkgs for security patches, it is every
patch.
⚠️ The first build after this lands is cold: the derivation hashes move.
Review catch (argus): most of the pre-existing script body picked up ~14
leading spaces it never had, because replacing an inline `optionalString`
with a named binding changed what the formatter treated as the block's
base indent. Functionally invisible — nix strips the minimum common
indentation and bash ignores the rest — but it made the diff read as
"reindented everything, incidentally added a block" instead of "added a
block".
Net diff on this file is now 231 insertions and 2 deletions.
The question this answers is "what do I have to configure, and where" —
so the table of secrets is the deliverable and the prose is scaffolding
around it.
The organising idea worth keeping: a secret belongs in-container when
nothing outside that container reads it. Every one of authelia's own
secrets passes that test; the client secret's plaintext fails it, which
is what makes delivery a problem at all rather than a detail.
The all-local case from the delivery ruling: when one host runs both the
forge and the swarm's authelia, nothing should need an operator.
Two containers, one secret, and the awkward part is that they share this
host's network namespace but not its filesystem. They reach each other on
127.0.0.1, which makes them feel co-located — the forge still cannot open
a path inside authelia's tree. The host is the only place both are
addressable, so the copy runs there, and `hostClientSecretDir` publishes
the outside view of the inside path exactly as `hostUsersFile` already
does for the users database.
Deliberately a copy rather than a `bindMounts` entry. nixos-container
refuses to start when a bind source is missing, and this secret does not
exist until authelia's first boot has minted it — binding it would make
the forge wait on a file that waits on a container that starts after it.
On a fresh hive that is a permanent stall presenting as "the forge is
broken", several layers from its cause.
The owning uid is discovered from the forge container's own state dir
rather than assumed. Whatever uid maps to forgejo inside that container
already owns the directory it was created with; writing a number here
would be a second place for it to be wrong.
The client entry is contributed to authelia's list by the forge module
itself, from the same source-name constant the registration uses, so the
redirect URI authelia allows and the one forgejo sends cannot drift.
A mismatch there is a rejected login with no error text worth reading.
Additive, never exclusive: forgejo keeps its local password database
and gains a second way in. An identity provider that can take the forge
offline when it hiccups is a worse forge than one with two doors.
A login source in forgejo is a database ROW, not an `app.ini` key, so
this is a unit rather than config. It is ordered AFTER forgejo — unlike
its neighbour `forgejo-gpg-init`, which runs before — because on a fresh
hive that database does not exist until forgejo has started and
migrated; running first would either fail or initialise a schema behind
the server's back.
Idempotency is by query (`admin auth list`), not by a stamp file: the
same reasoning already written down for the GPG key next to it, that a
stamp outlives a state wipe and then suppresses the repair.
Two assertions rather than defaults, both firing at eval: SSO needs a
secret path, and it needs somewhere to discover the provider. Either
one missing produces a login button that always fails — a runtime
symptom several layers from its cause, which is exactly the trade an
eval error is worth making.
The secret is read from a path and passed on argv for one exec, because
`--secret` is the only input forgejo offers — no `--secret-file`, no env
var, though its sibling `forgejo-cli actions register` has both. Inside
this container the value is already at rest in the login-source row and
the only principals are root and forgejo, so argv widens its readership
to nobody new. Accepted deliberately, not overlooked.
A client secret has two holders in two containers: the relying party
authenticates with the plaintext, authelia compares a digest. Neither
side can generate it alone, and `settings` is rendered into the
world-readable nix store, so the value cannot be declared.
So it is minted here, once, as two files — `<id>.secret` and
`<id>.digest`. That split is what lets `oidc-clients.yml` be re-rendered
on *every* boot from the nix-declared metadata plus the stored digest: a
new redirect URI or a renamed client takes effect on rebuild without
rotating a credential another container is already holding.
`authelia crypto hash generate pbkdf2 --random` generates the password
itself and prints it beside its digest, so no plaintext is ever handed to
a second process on a command line. The clients file reaches authelia
through `settingsFiles`, which upstream merges at runtime — the same
mechanism it already uses for the issuer JWK.
Minting fails closed: an empty secret or digest aborts the unit, and the
unit is `requiredBy` authelia, so the provider refuses to start rather
than serving a client that can never authenticate. That failure would
otherwise surface three layers away as an opaque 401 from the token
endpoint.
The swarm's authelia has been a session / `auth_request` provider only.
SSO for the forge (and matrix behind it) needs the second role: an OIDC
provider that issues tokens to relying parties.
The provider is derived from `oidc.clients` rather than carrying its own
`enable`, because authelia refuses to start with a provider that has no
clients — a separate flag would be a second fact free to disagree with
the first. The list defaults to empty, so a hive that has not opted in
renders exactly what it rendered before.
Its two secrets are generated in-container by the existing first-boot
unit, which is the same test that unit already applies to the jwt,
session and storage keys: nothing outside this container reads them. The
hmac key is 64 random bytes and joins that loop; the issuer key is an RSA
pair, because it *signs* id tokens and relying parties verify them
against the public half at `/jwks.json` — a symmetric secret cannot serve
that.
No client secret appears here, and that is the point: a client secret has
two holders in two containers, and `settings` is rendered into the world-
readable nix store. Minting it is the next commit's problem.
Adds swarmctl markdown-docs (a hidden Verb, same clap-markdown +
hide=true shape as hivectl markdown-docs) and generates
docs/tools/swarmctl-cli.md from it. Wires a swarmctl-docs freshness
check into nix/checks.nix, same shape as hivectl-docs, diffing against
packages.swarmctl.
One real gotcha: PathArgs::resolve() reads required
SWARMCTL_AUTHELIA_* deployment env vars and errors if unset -
swarmctl markdown-docs must not go through that path (it needs none of
those vars, and the docs build runs it outside any real deployment).
Restructured main() so resolve() only runs for the User arm, not
unconditionally before the match.
Also links the new doc from docs/tools/README.md (new 'for the swarm
operator' section), CLAUDE.md's swarmctl bullet, and
docs/conventions.md's flake-check list.
Verified: cargo check/clippy -D warnings/test/fmt -p swarmctl all
clean; swarmctl markdown-docs diffs clean against the committed doc
(checked against both a plain cargo build and the actual nix build.
#swarmctl output); scripts/check-issue-refs.sh clean.
Argus flagged on this PR's review that esbuild only strips TS types
without checking them, so a real type error would build clean and
pass every other check. Mara asked for it to be wired in on the same
PR.
Adds checks.swarm-ui-typecheck: a separate buildNpmPackage derivation
(not folded into swarm-ui.nix itself, which doesn't need typescript in
node_modules for its actual esbuild-only build) running
'npm run typecheck --workspace=packages/swarm-ui', same shared
npmDepsHash as frontend/swarm-ui.
Verified: nix build .#checks.x86_64-linux.swarm-ui-typecheck succeeds;
nix fmt clean; scripts/check-issue-refs.sh clean.
Project-bootstrap scope per the issue: static build as a nix pkg,
empty start page for now, functionality deferred until auth against
authelia is figured out. Stack (Preact + wouter + TypeScript + JSX)
matches the shell decision from the earlier framework-paths thread —
a real SPA shell with a router and deep links, distinct from the
per-hive dashboard's vanilla-JS + custom-element MPA.
- New npm workspace frontend/packages/swarm-ui: one route (/), a
wouter Switch/Route shell, a 404 fallback. Reuses @hive/shared's
colors.css/theme.css/base.css for visual consistency; no other
shared JS (the vanilla-JS el()/dom.js helpers are superseded by
Preact in this shell).
- nix/packages/swarm-ui.nix: its own buildNpmPackage derivation
(scoped to just this workspace via an explicit buildPhase), not
folded into nix/packages/frontend.nix's packages.default closure —
same reasoning swarm-controller/swarmctl already use for staying out
of daemonBins: a hive that doesn't run the swarm controller
shouldn't carry swarm-ui bytes.
- npmDepsHash recomputed in both frontend.nix and swarm-ui.nix (same
shared lockfile, new deps: preact, wouter-preact, typescript).
- Added swarm-ui to nix/checks.nix alongside frontend, for the same
FOD-staleness reason plus being the only thing that actually builds
it in CI (not in packages.default's closure like frontend is, so
nix flake check wouldn't otherwise touch it).
- npm run typecheck (tsc --noEmit) is available locally; not yet wired
into CI — esbuild transpiles TS without type-checking, so that's a
real gap, left as a follow-up rather than growing this bootstrap PR
with a new CI workflow step.
Verified: nix build .#swarm-ui and .#frontend both succeed; npm run
build (root, all workspaces) succeeds; tsc --noEmit clean;
scripts/check-issue-refs.sh clean.
The swarm's service names default to siblings of the hive domain
(forge.<swarm.domain>, not forge.<hive domain>), and the hive CA's leaf
is a single-label wildcard over its own domain, so it cannot cover them.
The swarm-services leaf can — but only on a host that holds the swarm
root key, i.e. swarm.ca.autoConfigure. Everywhere else the gateway
quietly serves the hive leaf on those names and every client sees a
mismatch, on a config that evaluates and deploys cleanly.
A warning rather than an assertion, per the operator's call. This module
knows what it can issue; it cannot see an operator-installed services
sub-CA or an external ACME setup, so "no certificate path" is a thing it
observes, not a thing it can conclude. A rebuild must not be blocked by
a verdict this host isn't in a position to reach — the message says what
was observed and names both ways out.
Each workspace crate's own README.md now also shows up as
docs/components/<crate>.md via nix/packages/reference-docs.nix, so it
renders on the docs site alongside the rest of docs/ without a second
hand-copied file to keep in sync. Relative links that were correct
from the crate's own position in the tree are rewritten for their new
virtual position (../docs/x.md -> ../x.md, ../<sibling-crate> ->
./<sibling-crate>.md). Landing page at docs/components/README.md, nav
bullet added to docs/README.md. No changes needed in the website
repo's docs.nix - it already walks every subdirectory generically.
Per review: docs represent current state. Every "used to" / "no longer"
clause this branch introduced is gone — including the History section in
network.md, which was a whole subsection about a sync mechanism that
doesn't exist.
Where the removed clause was carrying a real constraint, the constraint
stays and is stated in the present tense instead of as a delta: nothing
narrows what the gateway's nginx can reach except the directory
permissions in front of a socket, and nothing bounds `ReloadGatewayNginx`
except the hard-coded unit name. Those read as rules now rather than as
the story of how they came to be rules.
The gateway's nginx + dnsmasq no longer run in their own nspawn container.
`nix/host-modules/hive-gateway/default.nix` loses the
`containers.hive-gateway` wrapper and everything that existed only to punch
holes in it: `privateNetwork = false`, `CAP_NET_ADMIN`, five bind mounts,
its own `stateVersion`, `networking.firewall.enable = false`,
`networking.resolvconf.enable = false`, and the `hive-gateway-resolv`
path+service pair. 465 -> 303 lines.
The container never bought isolation here. It shared the host netns by
necessity — nginx binds the host's :80/:443, dnsmasq answers on the bridge —
so each of those settings was undoing a boundary the gateway could not
afford in the first place.
Four things made it more than a deletion, none of them visible in the nix
diff:
- The self-signed cert service also imports the hive CA leaf, so removing it
with the container would have left nginx naming a missing cert file, which
it refuses to load at all.
- The nginx reload is a hive-priv verb. It still needs root, but no longer
for the reason its doc gave, and `--machine=` was both transport and
scope — so the unit name is now hard-coded in the helper as the
containment.
- The lifecycle verb named a container that stops existing.
- `journalctl -M hive-gateway` had no machine to enter.
Per the operator's ruling, the operator verb keeps working and agents lose
it. `InfraContainer` answered three questions that used to share an answer;
it now splits into `name()` (identity), `target()` (Container vs HostUnit),
`service_unit()` (the systemd unit), and `agent_restartable()`, which the
MCP restart path checks before the capability so the refusal cannot read as
"ask for infra_admin". `SIBLING_CONTAINERS` drops the gateway — it gates the
requests that name a container as a string — while `FromStr` still accepts
it, because that answers what a name is, not who may act on it. The
dashboard's gateway journal reads host journald filtered to `nginx.service`.
Prose was corrected where it only named a location, and re-argued where the
container was doing security work: a `0666` per-agent socket was safe
because only the gateway container had the directory bind-mounted. There is
no mount now, so the directory permissions are the whole of the access
control — the constraint holds, its mechanism doesn't.
Gate: nix fmt / clippy --all-targets -D warnings / cargo test all clean (710
tests); hivectl-cli.md regenerated from the clap tree. The nix eval was run
in both TLS shapes at this commit: every delta in the rendered
virtualHosts is one of the three intended path moves, dnsmasq settings are
byte-identical, and the absence probe flips true -> false with bindMounts
emptied.
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.
hive_sh4re::assets::branding_svg() resolved a server-side default
icon at runtime from HIVE_ASSETS_DIR — the only consumer was
serve_icon(), which fell back to it whenever the agent had no
`hyperhive.icon` override. Removed both the fallback and the
function: serve_icon() now 404s when /etc/hyperhive/icon.svg is
absent, and the per-agent web UI (app.js) picks up the existing
dashboard swarm.js pattern — swap the <img> src to the
frontend-bundled /favicon.svg on load failure, guarded against
looping if the fallback itself 404s.
Updated the doc/comment claims that said the server always returns
an image (docs/web-ui/agent.md, nix/agent-modules/default.nix, the
hive-c0re/forge/users.rs comment referencing the old shared-asset
set). forge-avatar-sync and the matrix avatar sync are unaffected —
both are gated on hyperhive.icon != null and never depended on the
removed fallback.
core_avatar_png() and config_org_avatar_png() were hive-sh4re
functions used only by hive-c0re::forge::users. Move them there as
private path-resolution helpers (core_avatar_png_path,
config_org_avatar_png_path), following the existing
std::env::var(...).expect(...) required-env-var style used elsewhere
in hive-c0re.
The org avatar is now independently configurable:
services.hyperhive.c0re.orgAvatarPng (nullable path, default null)
lets an operator override just the agent-configs org avatar PNG
without replacing the whole assets package. Wired via a new
HIVE_ORG_AVATAR_PNG env var that falls back to the bundled PNG when
the option is unset. The core avatar stays under HIVE_ASSETS_DIR,
unchanged.
`security.pki.certificateFiles` is read when the system is BUILT and the
swarm root is deliberately a runtime file (its key must never enter the
world-readable store), so there was nothing build-time to name and the
container trusted no swarm-internal CA. Federation with a peer whose
cert chains to the swarm root did not validate.
The bridge already existed: `lib/hive-ca-trust.nix`, which `hive-ci` and
`hive-forge` both cross. This wires the matrix container to it -- read-only
bind of the trust bundle, container ordered after `hive-tls-ca.service` --
rather than inventing a second mechanism.
Consumption is the per-runtime half, and it is not what it looks like.
tuwunel links no openssl, which makes `SSL_CERT_FILE` appear inapplicable.
It isn't: the outbound client is `reqwest` with the `rustls` feature,
which builds a `rustls_platform_verifier::Verifier`, and because tuwunel
calls `tls_certs_merge` (additive) rather than `tls_certs_only`, the
platform roots stay alongside its compiled-in webpki set. On Linux that
verifier resolves through `rustls-native-certs` to `openssl-probe`, which
reads `SSL_CERT_FILE`. Established by reading tuwunel 1.8.0 and reqwest
0.13.4 source, not inferred from the feature list -- an earlier reading of
mine concluded the opposite from the absence of a `native-roots` feature
name and was wrong.
The variable therefore names a CONCATENATION (system CAs + the bundle),
never the anchor alone: `openssl-probe` uses it *instead of* the default
store, so pointing it at the bundle would drop every public CA and break
federation with the wider matrix network -- trading a small outage for a
much larger one.
`container@hive-matrix` needed `mkMerge`: it already assigned `after` for
the gateway ordering and the helper contributes its own `after`/`requires`,
so two bare assignments would have conflicted rather than combined.
⚠️ Gate honesty: `state/eval-3093.sh` proves the mount, the ordering, the
bundle service and the env var are RENDERED -- 6 probes, including two
separate absence probes (mount and consumption are wired at different
sites, and the gateway outage happened precisely because two of three
sites tolerated a missing cert and the third did not). It CANNOT prove
tuwunel loads them: a build that ignores the variable evaluates
identically to one that honours it, and the chain above is
version-specific. That half wants an executed check on a real deploy.
A swarm runs one homeserver, so its identity belongs to the swarm and
not to whichever hive happens to host it -- otherwise relocating the
container between hives reads as a different homeserver rather than a
move. `gatewayHost` moved for that reason a commit ago; `serverName`
was left behind, which made the identity hive-shaped and the routing
swarm-shaped.
⚠️ This is the one value on the page that a rebuild cannot undo.
`gatewayHost` is a routing detail clients rediscover through
`.well-known`; `server_name` is embedded in every user and room id, so
adopting a new one strands the existing accounts and rooms rather than
renaming them -- their ids still name a homeserver that stops
answering. Moving the DEFAULT is safe; moving a RUNNING deployment is
not, and existing hives must pin the value they already minted ids
under before rebuilding.
The fallback stays total on a null swarm domain for the same reason as
its neighbours: the required-domain assertion in hive-network.nix is
what should fire, not a coercion error from an unrelated option
interpolating null.
The legacy-pin eval probe now covers `serverName` as well. It existed
to answer "what do I set so old deployments don't change", and was
proving that only for the two values that are cheap to change -- the
irreversible one was the one it did not assert.
Third of the three service moves, and the one that fails closed rather
than loudly. The vhost name and the session cookie's domain move in the
SAME commit because authelia validates `authelia_url` is a sub-domain of
the cookie `domain` at STARTUP -- move only the vhost and it does not
misbehave at first login, it refuses to boot.
No migration pin, unlike the forge and matrix: nothing depends on the
old name yet, so it moves outright.
Also gives the container the authelia binary, so an operator with a
shell can run `authelia crypto hash generate` for the users file. That
fix was written for a PR that was closed and is tracked nowhere else.
The accepted scope limit is recorded where the cookie is configured: a
hive keeping a domain outside the swarm's tree does not receive this
cookie, so SSO covers the swarm's services and not that hive's own
dashboard until its domain moves too.
⚠️ Nothing resolves `auth.<swarm.domain>` yet. The hive gateway is
deliberately NOT taught about it: swarm services get their vhost and
their name on the HOST, not inside a per-hive proxy container. Both
halves are the swarm-nginx work, and authelia has no vhost there either
way today.
Both halves change: the parent, because a swarm runs one homeserver and
every hive reaches it; and the label, because `chat` names the service
people use rather than the protocol it speaks.
⚠️ `serverName` is deliberately NOT touched, and the two are now
documented as the different things they are. `gatewayHost` is a routing
detail -- the API listener nginx proxies `/_matrix/*` to, which clients
rediscover through `.well-known`. `serverName` is the matrix identifier
baked into every user and room id: changing it is a different
homeserver, not a rename, so it still falls back to the bare hive
domain. A note at the fallback says so, since that binding is where a
future edit would most plausibly "fix" the inconsistency.
Old deployments pin `matrix.<hive domain>` -- exactly what the old
default rendered -- and dnsmasq already lists `gatewayHost` explicitly,
so a pinned or moved name stays routed either way.
A swarm runs one forge and every hive in it reaches the same host, so
the name belongs to the swarm rather than to whichever hive happens to
run it: `forge.<swarm.domain>` instead of `forge.<hive domain>`.
A deployment that was running before this keeps its current name with
one line -- `swarm.forge.domain = "forge.<hive domain>"` -- which is
exactly what the old default rendered, so pinning it is a no-op today
and freezes it against the new default.
Certificates follow either way: the swarm-services sub-CA is
name-constrained to the CONFIGURED names rather than to a fixed tree, so
a pinned legacy name is as issuable as the new default. DNS follows too
-- dnsmasq already lists `forge.domain` explicitly, precisely so a
cross-domain override stays routed.
The default stays total on a null swarm domain (`forge.invalid`) so the
required-domain assertion in hive-network.nix is what an operator sees,
rather than a coercion error naming this option.
main is already unborked -- the constraint-syntax fix landed directly in
31e47632 and the hive-tls-ca ordering race in b5066ad0. What is left is
the reason the syntax bug became an OUTAGE rather than a warning, and it
is independent of both.
nginx refuses to load a config naming a cert file that does not exist:
`cannot load certificate ... no such file` fails the pre-start test, so
the vhost does not degrade -- the ENTIRE proxy dies and restart-loops
into start-limit-hit, taking the forge, the dashboard and matrix with
it. Signing tolerated the leaf's absence and the container import
tolerated it; the vhost reference did not, and that is the one that
decides whether anything serves.
Absence stays a normal state here: the leaf only exists where the swarm
CA is autoconfigured. So the import now installs the HIVE leaf at the
services path instead of removing the file. That is a name mismatch on
those vhosts -- browsers warn, strict clients refuse -- and every other
name keeps working. A bad certificate is a bad afternoon; a dead gateway
is a dead hive.
Also keeps the comment explaining why every nameConstraints entry needs
its own `permitted;` prefix, so the next reader doesn't re-derive it
from an outage.
`services.hyperhive.domain` and `swarm.hives.<hiveName>.domain` were two
homes for one value with nothing asserting they agreed. The failure that
buys is the worst shape a config defect has: it evaluates cleanly, and
the symptom ("the other hives can't reach me") appears on a machine
other than the misconfigured one.
The directory is now the single source. `hives.<name>.domain` gains the
`<name>.<swarm.domain>` default -- a derivation from two values an
operator had to state explicitly, not a guess -- so a conventional swarm
is a list of names and a hive addressed by something else says so in the
one place every other hive reads. `services.hyperhive.domain` reads its
own entry; the direct formula is deleted rather than kept as a fallback,
which would have restored the second path (and, reading `swarm.domain`
itself, a second path that can disagree).
Setting it directly still wins, with a deprecation warning: nothing
breaks today, but a value written only there is invisible to the swarm.
The self-entry assertion now fires on an EMPTY directory too. Since
`swarm.domain` became required, every hive is in a swarm -- a swarm of
one is still a swarm -- and this host's address is read out of the
directory, so the entry is missing either way and the precise message
should be the one that fires.
Upgrading costs one line on hives that never listed themselves:
`services.hyperhive.swarm.hives.<hiveName> = { };`, no value.
Measured against a real config, not reasoned about: pinning
`swarm.forge.domain = "forge.<hive domain>"` while `swarm.domain` is a
different apex -- the documented way an existing deployment keeps its
names while joining a swarm -- FAILED eval under this assertion.
And the reason it gave was false. It said those names are certified by
the services sub-CA, "which is constrained to the swarm's own tree, so a
name outside it has no issuer here". The sub-CA is constrained to the
**configured names** (swarm-ca.nix builds its nameConstraints from the
derived list), and the swarm root carries no name constraints at all --
only basicConstraints and keyUsage. So any configured name is issuable,
and a legacy name under the hive domain is additionally signable by the
hive CA.
The assertion therefore encoded the shape I had in mind rather than a
constraint the code implements, and it rejected a configuration that
works. Removed rather than weakened: a narrower version would still
carry a claim nothing backs.
`serviceDomains` stays -- it feeds the sub-CA's name constraints, the
leaf's SANs and the per-vhost cert choice, and those three agreeing is
the thing that mattered. The (option, value) pairs collapse back to a
plain list, since the provenance existed only for the assertion message.