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.
This commit is contained in:
atlas 2026-08-13 19:34:12 +02:00 committed by mara
commit c32a9367e4
2 changed files with 74 additions and 4 deletions

View file

@ -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:<port>",
# 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.<ours>.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

View file

@ -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