diff --git a/nix/host-modules/hive-network.nix b/nix/host-modules/hive-network.nix index 0a8b1ffa..c37cefe4 100644 --- a/nix/host-modules/hive-network.nix +++ b/nix/host-modules/hive-network.nix @@ -276,7 +276,7 @@ in # is firewall-only by design: a host service that binds `0.0.0.0` already # serves the bridge IP, so an extra bridge-IP proxy would only collide # (EADDRINUSE) with it. Merges with the [ 80 443 ] gateway ports above. - (lib.mkIf (config.services.hyperhive.enable && cfg.exposeHostPorts != [ ]) { + (lib.mkIf (cfg.exposeHostPorts != [ ]) { networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts; }) ]; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index e045ee6d..383e768e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -698,7 +698,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-controller.enable) { + config = lib.mkIf deployCfg.swarm-controller.enable { # The daemon and the oneshot that mints its credential — the second one # failing leaves the first running and unable to authenticate anywhere. services.hyperhive.swarm.otel.journaldUnits = [ diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index dff944ea..ed7df081 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -563,7 +563,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-otel.enable) { + config = lib.mkIf deployCfg.swarm-otel.enable { # The gateway name, inside `deployCfg.swarm-otel.enable` — that guard is the load-bearing # part. Every hive in a swarm may know this collector exists, but only # the host that RUNS it may claim the name; a client hive declaring the diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index f50b3eff..44844b87 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -97,7 +97,7 @@ in ''; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-ui.enable) { + config = lib.mkIf deployCfg.swarm-ui.enable { assertions = [ { # The `_` default server already answers for the hive domain diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 5ab93a11..6db0065f 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -491,6 +491,43 @@ let machine: machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName}.allowedTCPPorts; + # Same list, on a host that may not declare the interface at all: the + # `[ 80 443 ]` block is what creates the attr, and it is off where the hive + # is. Reading it through `bridgePorts` would throw rather than report an + # empty exposure, which is precisely the state the cases below assert. + bridgePortsOrNone = + machine: + (machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName} or { + allowedTCPPorts = [ ]; + } + ).allowedTCPPorts; + + # A host that installs the modules and turns nothing on. The per-service + # deployment toggles are what decide whether the swarm-level services render + # here, and none of them is derived from the hive being on, so this renders + # the same absences `bare` does — the arms below assert both, because a + # service that had quietly become unconditional would still look right on + # only one of them. + centralToggleOff = hive { enable = false; }; + + # Ports asked for on such a host. The request is the whole condition: the + # firewall hole exists because an operator named a port, not because the + # hive is running, and an agent reaching a host service is a claim about + # the host's own listeners either way. + exposedPortsNoHive = hive { + enable = false; + network.exposeHostPorts = [ 5432 ]; + }; + + # The swarm UI where its own toggle is on — the control the absence arm + # needs, since nothing else in this suite renders this vhost and an arm + # saying "it is not there" would hold just as well if it were never there. + # Package stubbed per this file's header: the vhost roots at it. + swarmUiHere = hive { + deploy.swarm-ui.enable = true; + deploy.swarm-ui.package = pkgs.emptyDirectory; + }; + baoTwoAddresses = hive { deploy.bao.enable = true; deploy.bao.extraListenAddresses = [ "10.0.0.1" ]; @@ -3032,6 +3069,70 @@ let in !(s.listener ? metrics) && !(s ? telemetry); } + { + # The four blocks below used to be gated on the hive being enabled AND + # their own condition. The second half was always the load-bearing one — + # none of these conditions is derived from the hive toggle — so these + # arms pin what the conjunct was doing: nothing. Each is written against + # a host with the hive OFF as well as one with it on, because the way a + # dropped conjunct fails is by making something unconditional, and that + # shows up as a service appearing where nothing asked for it. + name = "no bridge port is opened for an exposeHostPorts nobody set"; + ok = + !(builtins.elem 5432 (bridgePortsOrNone bare)) + && !(builtins.elem 5432 (bridgePortsOrNone centralToggleOff)); + } + { + # Control for the arm above, and the one place this slice is not inert: + # the ports are opened because they were named, on a host that never + # turned the hive on. The bridge firewall is the host's own, so there is + # nothing here for the hive toggle to have been protecting. + name = "a named exposeHostPorts opens its bridge port on the host's own say-so"; + ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive); + } + { + # `deploy.swarm-controller.enable`, which defaults false and is + # deliberately not derived from the hive toggle — a swarm has one + # controller, so the host that runs it says so itself. + # + # Probed by what the daemon needs in order to run, not by + # `? swarm-controller`: ./host-modules/hive-tls.nix defines an + # environment key on that unit name, which leaves the attr + # present-but-inert (no `ExecStart`, empty `wantedBy`) on every hive + # that has a CA — see the comment there. The credential oneshot has no + # second definer, so its absence is the unambiguous half. + name = "the swarm controller does not run unless this host is told to run it"; + ok = + let + inert = + machine: + !(machine.systemd.services ? swarm-controller-credential) + && !( + (machine.systemd.services.swarm-controller or { serviceConfig = { }; }).serviceConfig ? ExecStart + ); + in + inert bare && inert centralToggleOff; + } + { + # `deploy.swarm-otel.enable`, same shape: the swarm's collector is one + # host's job, and the container is the whole of what it renders. + name = "the swarm collector container is absent unless this host is told to run it"; + ok = !(bare.containers ? swarm-otel) && !(centralToggleOff.containers ? swarm-otel); + } + { + # `deploy.swarm-ui.enable`, which defaults to the controller's toggle — + # derived from a sibling deployment decision, still not from the hive + # toggle. `t.local` is the fixtures' swarm domain, which is the apex the + # UI claims; the arm below is what proves this vhost renders at all. + name = "the swarm UI vhost is absent unless this host is told to serve it"; + ok = + !(bare.services.nginx.virtualHosts ? "t.local") + && !(centralToggleOff.services.nginx.virtualHosts ? "t.local"); + } + { + name = "the swarm UI claims the swarm apex where this host serves it"; + ok = swarmUiHere.services.nginx.virtualHosts ? "t.local"; + } ]; bad = builtins.filter (c: !c.ok) cases;