refactor(nix): a hive's domain comes out of the swarm directory

`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.
This commit is contained in:
atlas 2026-08-05 21:48:20 +02:00 committed by mara
commit 3b6576faee
5 changed files with 221 additions and 122 deletions

View file

@ -87,14 +87,18 @@ listener on `bridgeIp` is on the host's bridge interface.
{
services.hyperhive = {
enable = true;
domain = "darkest.space";
# network.bridgeIp = "10.42.0.1"; # default
hiveName = "pr1ma";
swarm.domain = "darkest.space";
swarm.hives.pr1ma = { }; # -> domain = pr1ma.darkest.space
# network.bridgeIp = "10.42.0.1"; # default
};
}
```
Requires `services.hyperhive.domain` to be set — the dnsmasq resolver
is authoritative for `<hive-domain>` and its sub-domains.
is authoritative for `<hive-domain>` and its sub-domains. You do not
write it: it is read from this hive's entry in the swarm directory
(`docs/swarm/README.md` § Hive identity config).
## Bridge addressing

View file

@ -22,8 +22,14 @@ the additional config needed when the swarm spans multiple hosts.
services.hyperhive = {
swarm.domain = "example.com"; # required — the swarm's DNS domain
hiveName = "pr1ma"; # required — this hive's label in it
# domain = "pr1ma.example.com"; # derived from the two above
swarm.name = "constellat1on"; # shared swarm display name (optional)
# required — the directory, identical on every host in the swarm.
# Names only: each entry's `domain` defaults to <name>.<swarm.domain>.
swarm.hives = {
pr1ma = { };
edge = { };
};
};
```
@ -34,12 +40,22 @@ and deploys — an eval failure asking the operator to write the address
down is the cheaper outcome. **Upgrading past this release means setting
both once.**
`domain` is required too, but you no longer have to *write* it: it
defaults to `<hiveName>.<swarm.domain>`, since every hive in a swarm
occupies its own sub-domain of it. A hive that pins `domain` explicitly
keeps exactly the value it has today — that's why this is a default and
not a rename: re-rooting where a value *comes from* must not reinterpret
the values already deployed.
`domain` is required too, but you no longer *write* it: it is read from
this hive's own entry in the directory, whose `domain` defaults to
`<name>.<swarm.domain>`. So a conventional swarm states no addresses at
all, and a hive addressed by something else states it in the one place
the other hives read — `swarm.hives.edge.domain = "edge.elsewhere.example";`.
Setting `services.hyperhive.domain` directly still works and still wins,
with a **deprecation warning**. The reason it's deprecated is not tidiness:
that option is local to one host, the directory is copied to every host,
so a value written only there leaves every peer pointing somewhere else
with nothing detecting the disagreement.
⚠️ **Upgrading:** a hive that has been running on `swarm.domain` +
`hiveName` alone now needs its own directory entry —
`services.hyperhive.swarm.hives.<hiveName> = { };`, one line, no value.
Eval fails naming it if you forget.
`domain` drives `HYPERHIVE_HIVE_DOMAIN` in every container so agents can
form qualified labels (`iris@pr1ma.example.com`).

View file

@ -171,11 +171,16 @@ in
hyperhive requires services.hyperhive.domain to be set the
hive resolver is authoritative for `<hive-domain>` and its
sub-domains, and agents reach the forge/matrix through the
gateway by that domain. Pin a hostname
(`services.hyperhive.domain = "example.com";`), or set
`services.hyperhive.swarm.domain` and
`services.hyperhive.hiveName` and it is derived for you as
`<hiveName>.<swarm.domain>`.
gateway by that domain.
It is read from this hive's entry in the swarm directory, so
what is actually missing is that entry:
services.hyperhive.swarm.hives."<hiveName>" = { };
whose `domain` defaults to `<hiveName>.<swarm.domain>`. The
assertion in ./swarm.nix names it precisely; this one is the
backstop.
'';
}
{

View file

@ -5,6 +5,7 @@
{
lib,
config,
options,
...
}:
let
@ -36,19 +37,24 @@ in
# Hive identity (label + domain + display names).
options.services.hyperhive.domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
# Every hive in a swarm lives at its own sub-domain of the swarm's,
# so this is derivable rather than something each hive repeats. It
# stays a DEFAULT and not a rename: an alias would reinterpret the
# domains hives have already deployed, whereas a default only fills
# in the ones that never set it. Null (both parts unset) keeps the
# existing "required" assertion in hive-network.nix as the single
# place this can fail.
# Read out of the swarm directory rather than derived here. The
# directory is what every OTHER hive reads this hive's address from,
# so deriving it separately gave two homes for one value with
# nothing asserting they agreed — and a disagreement surfaces as
# "the other hives can't reach me", on a machine other than the
# misconfigured one.
#
# ⚠️ The `<hiveName>.<swarm.domain>` formula did NOT move here from
# there and back: it lives once, on `hives.<name>.domain`, which
# this reads. Restoring a direct fallback here would recreate the
# second path (and, since that default reads `swarm.domain` too, a
# value that can differ from the directory's).
default =
if hiveCfg.swarm.domain != null && hiveCfg.hiveName != null then
"${hiveCfg.hiveName}.${hiveCfg.swarm.domain}"
if hiveCfg.hiveName != null && hiveCfg.swarm.hives ? ${hiveCfg.hiveName} then
hiveCfg.swarm.hives.${hiveCfg.hiveName}.domain
else
null;
defaultText = lib.literalExpression ''"''${hiveName}.''${swarm.domain}", or null when either is unset'';
defaultText = lib.literalExpression "services.hyperhive.swarm.hives.\${hiveName}.domain, or null when there is no entry for this hive";
example = "darkest.space";
description = ''
Canonical host domain for hyperhive subsystems that need a
@ -63,12 +69,44 @@ in
`hive-agent::identity::hive_domain()` for `<name>@<domain>`
qualified labels.
Defaults to `<hiveName>.<swarm.domain>` when both of those are
set, so a hive in a swarm does not restate its own address.
Setting this explicitly always wins.
**Deprecated as a place to write.** It is read from this hive's
own entry in `services.hyperhive.swarm.hives`, whose `domain`
defaults to `<hiveName>.<swarm.domain>` so a conventional hive
states nothing at all, and a non-conventional one states its
address in the directory every other hive reads. Setting it here
still wins and still works, with a warning: the directory is
shared, this option is not, so a value written only here is
invisible to the rest of the swarm.
'';
};
# Deprecation warning for the shorthand above, fired on PRIORITY.
#
# ⚠️ Not `isDefined`, and not `files`: the module system injects an
# option's own `default` as a definition attributed to the declaring
# file, so both say "defined, in hyperhive.nix" for a config that set
# nothing — measured, after this warning fired on the conventional
# case. `mkOptionDefault` is priority 1500, so anything lower is a
# definition someone actually wrote (100 plain, 1000 mkDefault).
config.warnings =
lib.optional (hiveCfg.enable && options.services.hyperhive.domain.highestPrio < 1500)
''
services.hyperhive.domain is set explicitly and is deprecated. This
hive's address belongs in the swarm directory, which every hive in
the swarm shares a copy of:
services.hyperhive.swarm.hives."${toString hiveCfg.hiveName}".domain = "${toString hiveCfg.domain}";
(Or drop the value entirely if it is the conventional
`<hiveName>.<swarm.domain>` that is the directory entry's own
default.)
Setting it here still wins, so nothing is broken right now. What it
does not do is tell the other hives: they read this hive's address
out of their copy of the directory, so a value written only here
leaves them pointing somewhere else with nothing detecting it.
'';
# Where the swarm lives. Declared beside the hive's own identity
# because it is what that identity is derived FROM — every hive in a
# swarm is a sub-domain of it. Unlike the renamed options nearby, this
@ -79,11 +117,11 @@ in
example = "darkest.space";
description = ''
DNS domain of the wider swarm this hive belongs to. Each hive
occupies its own sub-domain of it, which is why
`services.hyperhive.domain` defaults to
`<hiveName>.<swarm.domain>` set this plus
`services.hyperhive.hiveName` and a hive needs no domain of its
own.
occupies its own sub-domain of it, which is why every entry in
`services.hyperhive.swarm.hives` defaults its `domain` to
`<name>.<swarm.domain>` set this plus
`services.hyperhive.hiveName`, list the hives by name, and no
hive in the swarm states an address at all.
**Required** when `services.hyperhive.enable`, and deliberately
not defaulted: there is no fallback worth having. A guessed

View file

@ -44,103 +44,121 @@ in
{
options.services.hyperhive.swarm.hives = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
domain = lib.mkOption {
type = lib.types.str;
example = "lab.example.com";
description = ''
Public DNS domain this hive occupies used for dashboard
links, peer HTTPS checks and Matrix federation discovery.
lib.types.submodule (
{ name, ... }:
{
options = {
domain = lib.mkOption {
type = lib.types.str;
# `<name>.<swarm.domain>` is a derivation from two values an
# operator had to state explicitly (both are required), not a
# guess — and it is what makes this directory worth copying:
# a conventional swarm is `{ pr1ma = { }; umbra = { }; }`,
# names only, with a non-conventional hive saying so and only
# that. A shared file is read far more often than written.
#
# Total rather than a throw when `swarm.domain` is unset, for
# the reason ./hive-network.nix:155 gives in full: defaults
# that interpolate the domain are forced *while the assertion
# list evaluates*, so a throw here would replace the message
# naming the missing option with a coercion error naming this
# one. `.invalid` is reserved (RFC 2606) and fails loudly at
# resolution if it ever escaped — which the required-domain
# assertion is there to stop.
default = if swarmCfg.domain == null then "${name}.invalid" else "${name}.${swarmCfg.domain}";
defaultText = lib.literalExpression ''"''${name}.''${services.hyperhive.swarm.domain}"'';
example = "lab.example.com";
description = ''
Public DNS domain this hive occupies used for dashboard
links, peer HTTPS checks and Matrix federation discovery.
Deliberately has no default. It is conventionally
`<name>.<swarm.domain>`, but defaulting to that would let
a typo'd swarm domain produce a name that resolves
somewhere real; an eval failure naming the hive is the
better outcome.
'';
Defaults to `<name>.<swarm.domain>`, the convention every
hive in a swarm follows, so a conventional directory is
names only. Set it for a hive that is addressed by
something else.
'';
};
certFingerprint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
description = ''
Expected TLS certificate fingerprint for this hive's HTTPS
endpoint. Null = trust the CA bundle which for a hive
inside the swarm CA hierarchy is the normal case, since
every hive under the swarm root already chains to it.
Set it to pin a leaf that no CA in the bundle vouches for.
Format: the literal `sha256:` followed by exactly 64
hex digits (case-insensitive, no colon separators) the
SHA-256 digest of the DER-encoded leaf certificate.
Generate with `openssl x509 -noout -fingerprint -sha256`,
then strip the colons and prepend `sha256:`. A malformed
value is ignored with a warning rather than weakening
trust. See docs/swarm/README.md for the full recipe.
Scopes only to hive-c0re's own peer HTTPS checks it does
NOT help Matrix federation, which validates against the
container's trust bundle. There is no per-hive CA field to
cover that case any more: the swarm root is the trust path
(see ./swarm-ca.nix).
'';
};
wireguardPublicKey = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "base64pubkey=";
description = ''
WireGuard public key for this hive's host. Required when
`services.hyperhive.swarm.wireguard.enable = true` and
you want this hive reachable over the mesh. Null = TLS-
only peering (public internet, no mesh tunnel).
'';
};
wireguardEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "203.0.113.1:51820";
description = ''
WireGuard endpoint for this hive in `host:port` form.
Null = this hive has no reachable endpoint, so the tunnel
is initiated from the other side.
Reads like a fact about the relationship and is not: it
says whether *this* hive can be dialled, which every other
hive in the swarm needs the same answer to.
'';
};
wireguardAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.100.0.2/32";
description = ''
IP address (with prefix) of this hive's host on the
WireGuard mesh. Used as the `allowedIPs` for its
WireGuard config entry and injected into `HYPERHIVE_PEERS`
so hive-c0re can route intra-swarm traffic to the mesh
address rather than the public domain. Required to include
a hive in the mesh (entries missing this field are
silently excluded from `wg-hive`).
'';
};
};
certFingerprint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
description = ''
Expected TLS certificate fingerprint for this hive's HTTPS
endpoint. Null = trust the CA bundle which for a hive
inside the swarm CA hierarchy is the normal case, since
every hive under the swarm root already chains to it.
Set it to pin a leaf that no CA in the bundle vouches for.
Format: the literal `sha256:` followed by exactly 64
hex digits (case-insensitive, no colon separators) the
SHA-256 digest of the DER-encoded leaf certificate.
Generate with `openssl x509 -noout -fingerprint -sha256`,
then strip the colons and prepend `sha256:`. A malformed
value is ignored with a warning rather than weakening
trust. See docs/swarm/README.md for the full recipe.
Scopes only to hive-c0re's own peer HTTPS checks it does
NOT help Matrix federation, which validates against the
container's trust bundle. There is no per-hive CA field to
cover that case any more: the swarm root is the trust path
(see ./swarm-ca.nix).
'';
};
wireguardPublicKey = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "base64pubkey=";
description = ''
WireGuard public key for this hive's host. Required when
`services.hyperhive.swarm.wireguard.enable = true` and
you want this hive reachable over the mesh. Null = TLS-
only peering (public internet, no mesh tunnel).
'';
};
wireguardEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "203.0.113.1:51820";
description = ''
WireGuard endpoint for this hive in `host:port` form.
Null = this hive has no reachable endpoint, so the tunnel
is initiated from the other side.
Reads like a fact about the relationship and is not: it
says whether *this* hive can be dialled, which every other
hive in the swarm needs the same answer to.
'';
};
wireguardAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.100.0.2/32";
description = ''
IP address (with prefix) of this hive's host on the
WireGuard mesh. Used as the `allowedIPs` for its
WireGuard config entry and injected into `HYPERHIVE_PEERS`
so hive-c0re can route intra-swarm traffic to the mesh
address rather than the public domain. Required to include
a hive in the mesh (entries missing this field are
silently excluded from `wg-hive`).
'';
};
};
}
}
)
);
default = { };
example = {
pr1ma = {
domain = "pr1ma.example.com";
wireguardAddress = "10.100.0.1/32";
wireguardEndpoint = "203.0.113.1:51820";
};
edge = {
domain = "edge.example.com";
domain = "edge.elsewhere.example";
wireguardAddress = "10.100.0.2/32";
};
};
@ -151,10 +169,15 @@ in
`services.hyperhive.hiveName` is what makes a given host read it
as "me and four others" rather than "five peers".
Empty (the default) means this host is not part of a swarm. Once
non-empty it must contain an entry for `hiveName`, which is
asserted a hive that lists everyone but itself would otherwise
derive its own peer set as *everything* and peer with itself.
Each entry needs no fields at all in the conventional case: a
hive's `domain` defaults to `<name>.<swarm.domain>`, so the whole
directory is usually a list of names.
It must contain an entry for `hiveName`, which is asserted this
host's own address is read out of it (it is where
`services.hyperhive.domain` derives from), and a hive that lists
everyone but itself would otherwise derive its own peer set as
*everything* and peer with itself.
'';
};
@ -197,20 +220,33 @@ in
assertions = [
{
# An EMPTY `hives` fires this too, deliberately: since
# `swarm.domain` became required, every hive is in a swarm — a
# swarm of one is still a swarm — so a directory with no entry
# for this host is missing one either way. It also has to fire
# here, because this host's own domain is now read out of the
# directory: without the entry `services.hyperhive.domain` is
# null and the generic required-domain assertion in
# ./hive-network.nix would fire instead, naming an option the
# operator should no longer be setting.
#
# Guarded on `hiveName != null` so the required-hiveName
# assertion in ./hyperhive.nix is what fires for that case —
# two assertions naming the same missing value is noise.
assertion = swarmCfg.hives == { } || cfg.hiveName == null || swarmCfg.hives ? ${cfg.hiveName};
assertion = cfg.hiveName == null || swarmCfg.hives ? ${cfg.hiveName};
message = ''
services.hyperhive.swarm.hives has no entry for this hive
(services.hyperhive.hiveName = "${toString cfg.hiveName}").
`hives` describes every hive in the swarm including this one,
so that every host can share one identical attrset. Add:
so that every host can share one identical attrset, and this
hive's own domain is derived from its entry. Add:
services.hyperhive.swarm.hives."${toString cfg.hiveName}" = {
domain = "${toString cfg.domain}";
};
services.hyperhive.swarm.hives."${toString cfg.hiveName}" = { };
No fields are needed: `domain` defaults to
`<name>.<swarm.domain>`. Set it in the entry if this hive is
addressed by something else.
Declared hives: ${lib.concatStringsSep ", " (lib.attrNames swarmCfg.hives)}
'';