diff --git a/docs/network.md b/docs/network.md index 21629e93..36aecaf2 100644 --- a/docs/network.md +++ b/docs/network.md @@ -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 `` and its sub-domains. +is authoritative for `` 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 diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 574ae569..77c87247 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -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 .. + 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 `.`, 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 +`.`. 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. = { };`, 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`). diff --git a/nix/host-modules/hive-network.nix b/nix/host-modules/hive-network.nix index fa836ad0..4a1702e8 100644 --- a/nix/host-modules/hive-network.nix +++ b/nix/host-modules/hive-network.nix @@ -171,11 +171,16 @@ in hyperhive requires services.hyperhive.domain to be set — the hive resolver is authoritative for `` 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 - `.`. + 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."" = { }; + + whose `domain` defaults to `.`. The + assertion in ./swarm.nix names it precisely; this one is the + backstop. ''; } { diff --git a/nix/host-modules/hyperhive.nix b/nix/host-modules/hyperhive.nix index c080e780..4cec58ce 100644 --- a/nix/host-modules/hyperhive.nix +++ b/nix/host-modules/hyperhive.nix @@ -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 `.` formula did NOT move here from + # there and back: it lives once, on `hives..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 `@` qualified labels. - Defaults to `.` 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 `.` — 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 + `.` — 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 - `.` — 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 + `.` — 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 diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 8798f2d3..4bf07940 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -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; + # `.` 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 - `.`, 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 `.`, 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 `.`, 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 + `.`. Set it in the entry if this hive is + addressed by something else. Declared hives: ${lib.concatStringsSep ", " (lib.attrNames swarmCfg.hives)} '';