From c32a9367e468c8ab462b3a075e1d931d117b5df1 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 19:34:12 +0200 Subject: [PATCH] gateway: reject unmatched Host instead of serving the dashboard The `_` vhost was serving the hive's own surface, so every dashboard and agent-UI request matched the default server rather than a named vhost -- and so did a request for any name at all, including a raw IP. Split it: `_` keeps only `return 444`, and the hive surface moves to a vhost named for the hive domain. `_` is `mkDefault` so an operator can claim default_server themselves, plus an assertion for the case where they add one without turning ours off -- nginx refuses to start on a duplicate default_server and nixpkgs asserts nothing, so that would otherwise surface as a gateway outage at rebuild time. --- nix/host-modules/hive-gateway/default.nix | 40 +++++++++++++++++++++++ nix/host-modules/hive-gateway/vhosts.nix | 38 ++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index b6fe8e03..7e64ff24 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -24,6 +24,13 @@ let matrixCfg = config.services.hyperhive.swarm.matrix; networkCfg = config.services.hyperhive.network; + # Every vhost claiming `default_server`, ours and the operator's + # alike. Computed once so the assertion below and the message it + # prints cannot disagree about what they found. + defaultVhosts = lib.filter (n: config.services.nginx.virtualHosts.${n}.default or false) ( + lib.attrNames config.services.nginx.virtualHosts + ); + # Dashboard SPA dist, static-served by nginx. dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; @@ -143,6 +150,39 @@ in rather than letting dnsmasq pick. ''; } + { + # nginx refuses to start with two `default_server`s on one + # address ("a duplicate default server for 0.0.0.0:", + # exit 1) and nixpkgs asserts nothing — `vhost.default` is a + # plain bool rendered straight into the listen line. So without + # this, an operator adding their own default vhost gets a + # gateway that fails its config test at rebuild time, which + # takes the forge, dashboard, matrix and swarm UI with it, and + # reports a port rather than a cause. + # + # Not covered by our `mkDefault`: an operator's own vhost is a + # different option path, so nothing merges and nothing + # conflicts — priority only helps someone who already knows + # ours exists. Fail at eval and name both, so the fix + # (`services.nginx.virtualHosts..default = false`) is + # readable from the error. + assertion = lib.length defaultVhosts <= 1; + message = '' + More than one nginx virtual host is marked `default = true`: + ${lib.concatStringsSep ", " defaultVhosts} + + nginx allows exactly one default server per listen address + and refuses to start otherwise, so this would fail at + service start rather than here — taking every site behind + the gateway down with it. + + The gateway's own catch-all (`_`, which returns 444) is set + with `mkDefault`, so to make yours the default server turn + ours off explicitly: + + services.nginx.virtualHosts."_".default = false; + ''; + } ]; # Ensure the gateway state dirs exist at host boot, before anything diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index b8166520..ee855739 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -219,11 +219,41 @@ let in { virtualHosts = { - # `tlsFor "_"`, not a separate binding: the default server is a - # vhost named `_`, and a name that is not a swarm service domain - # (`_` never is) resolves to the hive's own leaf — which is what - # this vhost has always served. + # The catch-all, and now *only* a catch-all: anything whose `Host` + # matches no vhost gets an immediate 444 (close without a response) + # rather than being served the hive's dashboard. + # + # `_` is the idiomatic spelling because it is not a legal hostname, + # so it can never match a request by name — it serves traffic solely + # by being `default_server`. + # + # ⚠️ It still needs TLS attrs. It listens on the https port, so a + # client connecting by IP completes a TLS handshake *before* nginx + # can look at `Host` and reject it; with no cert the vhost fails to + # load. The certificate will not match what such a client asked for + # — that is unavoidable and correct: nothing can present a valid + # cert for a name the operator never issued one for. + # + # `mkDefault` per the operator: an operator with their own + # `default = true` vhost must be able to win without fighting + # priorities. The assertion in ./default.nix catches the case where + # they add one *without* turning this off, which nginx would + # otherwise only report at runtime as a failed config test. "_" = (vhostTlsFor "_") // { + listen = vhostListen; + default = lib.mkDefault true; + extraConfig = '' + return 444; + ''; + }; + + # The hive's own surface, now reachable by NAME. This used to be + # served by the `_` vhost above: no vhost was named for the hive + # domain, so every dashboard and agent-UI request matched the + # default server instead (confirmed against 24h of nginx's access + # log — `server: _` on requests whose Host *was* the hive domain). + # Naming it is what lets the catch-all start rejecting. + ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { listen = vhostListen; locations = matrixRedirectLocations