hyperhive/nix/host-modules/hive-gateway/vhosts.nix
atlas 7003d14d2c deploy: split the homeserver's host decisions out of swarm.matrix
`swarm.*` is what a hive needs to be a *client* of the swarm. For the
homeserver that is what it IS from anywhere: its package, the name it
answers to, the ports and URLs it is reached on, and the client id it is
registered under. Whether it is exposed, which peers it trusts, how large
a request it accepts and where its host-local secrets sit are decisions
of the machine running it, so openFirewall, trustedServers,
maxRequestSize, registrationTokenFile, gui.enable and
sso.clientSecretFile move to `deploy.matrix.*`.

Two sub-blocks split rather than moving whole, on their own evidence.
`gui.enable` is whether THIS host serves the web client; `gui.package` is
which client, an artifact identity, and stays. `sso.clientSecretFile` is a
path on one host; `clientId` must match the id in authelia's register, so
it is swarm-wide. Each half now points at the other, because the rendered
docs put them on separate pages.

hive-gateway passed the whole `swarm.matrix` attrset into vhosts.nix, so
that file read a moving option through an argument with no option path
anywhere in it. It now takes `matrixDeployCfg` beside `matrixCfg` — the
only shape that carries a split namespace across that boundary.

While there: vhosts.nix read `matrixCfg.enable`, which has been a rename
alias for `deploy.matrix.enable` since the enable moved. Reading it made
the module system print `Obsolete option services.hyperhive.swarm.matrix.
enable is used` on EVERY evaluation of every host — a deprecation warning
no operator could silence, because the config tripping it was ours. That
shim lives in hive-matrix.nix rather than in this file's table, which is
why deploy.nix's header claim to be their single home is now qualified
in the new block's comment.

glue-matrix-bao-token.nix read the registration token through its own
`matrixCfg` alias; with that read repointed, the binding had no reader
left, so it goes, and the comment naming it is reworded.

module-eval gains a case configuring a hive through all six OLD paths and
asserting two rendered effects — the host firewall's port list and the
container's bind-mount table — because the new paths evaluate fine
without the shims. `gui.enable` is set to the opposite of its default so
the definition has to land rather than agreeing with it by accident.
2026-09-07 14:24:52 +02:00

299 lines
13 KiB
Nix

# nginx virtual-host tree for the **hive's own** gateway surface: the `_`
# default server, which answers unmatched `Host` with a bare 444, and the
# vhost named for the hive domain, which carries the dashboard, per-agent
# routing, matrix discovery, the swagger UI and the legacy
# `<hive>/matrix/*` redirect.
#
# Scope: a swarm service declares its own vhost and dns name in its own
# module, built from the kit this file also consumes
# (`services.hyperhive.gateway.lib`, ./vhost-lib.nix). That keeps a
# service's gateway surface next to the service, and keeps this file to
# the surface the hive itself serves.
#
# Pure function — called from ./default.nix with the outer-scope config
# values as arguments; returns `{ virtualHosts }`.
{
lib,
cfg, # services.hyperhive.gateway
matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS
matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs
hyperhiveDomain,
dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option
}:
let
# The kit's three members, bound to the names this file already used.
# Read through `gwLib` (the published option) rather than importing
# ./vhost-lib.nix directly: a service module declaring its own vhost
# gets the same object, so "the forge vhost listens where the gateway
# listens" is true by construction and not by review.
inherit (gwLib) securityHeaders;
vhostListen = gwLib.listen;
vhostTlsFor = gwLib.tlsFor;
# Public-facing scheme + port-suffix for URLs the gateway
# mints into responses (well-known JSON, the deprecated
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
# always `https://<host>` (matrix-spec compliance) — the canonical
# 443 elides the port. See `docs/networking/gateway.md` ("Self-signed TLS").
publicScheme = "https";
publicPort = cfg.httpsPort;
publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}";
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` (legacy deep-link
# shim during the fluffychat sub-domain move). See `docs/networking/gateway.md`.
matrixRedirectLocations =
lib.optionalAttrs
(matrixDeployCfg.enable && matrixDeployCfg.gui.enable && matrixCfg.gatewayHost != null)
(
let
target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}";
in
{
"/matrix/" = {
extraConfig = ''
rewrite ^/matrix/(.*)$ ${target}/$1 permanent;
'';
};
}
);
# `.well-known/matrix/{client,server}` discovery JSON. Points
# clients at `matrixCfg.gatewayHost` when set; falls back to direct
# `<hive>:<httpPort>`. CORS `*` per matrix spec. The `m.server`
# port-8448 carve-out is documented inline. See `docs/networking/gateway.md`.
wellKnownLocations = lib.optionalAttrs matrixDeployCfg.enable (
let
clientBaseUrl =
if matrixCfg.gatewayHost != null then
"${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"
else
"${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
# `m.server` is NOT a URL: per the matrix server-server spec
# (Resolving Server Names) a delegated host with NO port resolves
# to the federation default 8448 (after the SRV check) — the
# https-implies-443 rule does NOT apply here. So the port must be
# explicit even when it's the HTTPS default; `publicPortSuffix`
# (which drops :443) is right for the client base_url above but
# wrong for federation delegation. Without this, peers federate to
# <gatewayHost>:8448 (closed) while the endpoint actually lives on
# the gateway's 443 vhost. See docs/networking/gateway.md discovery flow.
serverHostPort =
if matrixCfg.gatewayHost != null then
"${matrixCfg.gatewayHost}:${toString publicPort}"
else
"${hyperhiveDomain}:${toString matrixCfg.httpPort}";
in
{
"= /.well-known/matrix/client" = {
extraConfig = ''
default_type application/json;
${securityHeaders}
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}';
'';
};
"= /.well-known/matrix/server" = {
extraConfig = ''
default_type application/json;
return 200 '{"m.server":"${serverHostPort}"}';
'';
};
}
);
# `/agent/` catch-all 404 + the two internal error-page targets it
# points at. Per-agent `location /agent/<name>/` blocks live in the
# runtime-generated `/var/lib/hive-gateway/conf/agents.conf` (included via
# `extraConfig` on the vhost); nginx longest-prefix-match makes a
# real `/agent/<name>/` beat this catch-all. `internal` keeps the
# error pages reachable only through nginx's error handling.
agentLocations = {
"/agent/" = {
extraConfig = ''
error_page 404 = /__hive_agent_not_found;
return 404;
'';
};
"= /__hive_agent_not_found" = {
extraConfig = ''
internal;
alias ${errorPages.notFound};
default_type text/html;
'';
};
"= /__hive_agent_unreachable" = {
extraConfig = ''
internal;
alias ${errorPages.unreachable};
default_type text/html;
'';
};
};
# Shared auth block — separate locations don't inherit auth_basic, so
# each dashboard location (`/`, `/api/`) needs it or that surface is
# unauthed. `/webhook/` is intentionally excluded: Forgejo cannot
# send HTTP Basic credentials with webhook deliveries, and the HMAC
# secret (`X-Hub-Signature-256`) protects those endpoints instead.
dashboardAuth = lib.optionalString cfg.auth.enable ''
auth_basic "${cfg.auth.realm}";
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
# `=401` keeps the status 401 so the login dialog shows; the
# internal page explains `hivectl gateway create-user`.
error_page 401 =401 /__hive_auth_unauthorized;
'';
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
# is by PATH, never content-type. c0re serves exactly three prefixes —
# `/api/` (all dashboard data + actions + the SSE streams), `/webhook/`
# (knowledge push + config-PR approval triggers, HMAC-guarded), and
# `/health/` (liveness + readiness) — so those proxy to c0re and
# everything else serves the dist with an SPA fallback to index.html.
# Path routing is deterministic where an Accept-header split would make
# the SAME url behave differently by content-type (e.g. `/api/state`
# fetched with `Accept: text/html` wrongly getting index.html). A new
# top-level c0re route prefix (beyond /api + /webhook + /health) needs
# a matching location added here.
dashboardProxyLocation = {
"/" = {
root = dashboardDist;
extraConfig = ''
try_files $uri /index.html;
${dashboardAuth}
'';
};
"/api/" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
proxyWebsockets = true;
extraConfig = ''
# off + 1d keep the SSE streams (/api/dashboard/stream,
# /api/build-logs/id/{id}/stream) live.
proxy_buffering off;
proxy_read_timeout 1d;
${dashboardAuth}
'';
};
"/webhook/" = {
# No dashboardAuth here: Forgejo cannot send HTTP Basic credentials
# with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth
# for these endpoints; hive-c0re verifies it in the handler.
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
};
"/health/" = {
# No dashboardAuth here either, for a different reason than
# /webhook/: an external uptime monitor generally can't do
# interactive HTTP Basic. The endpoints themselves are scoped to
# status + warning kind/message (see hive-c0re/src/dashboard/
# health.rs) — no tokens, no agent detail — so exposing them
# unauthenticated isn't a new secret surface.
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
};
};
# Swagger UI: nginx hosts the FULL themed dist (`swaggerUiTheme` —
# vendored Swagger UI + our overlay, see nix/packages/swagger-ui-
# dist.nix + swagger-ui-theme.nix) straight from the store, with NO
# fallback to hive-c0re at all — per the operator's shape: "core
# should not need the swagger ui at all if it is hosted in gateway"
# / "core only hosts the json". Only `/api/openapi.json`
# (the live-generated spec `index.html` fetches; not under this
# prefix) still proxies to c0re via "/api/" below — that's the one
# thing that has to stay dynamic.
#
# Prefix location, not exact-match: wins over "/api/" on plain
# prefix length (no ordering/`=` needed), and now needs to cover
# every file in the tree (bundle.js, maps, favicons, …), not just
# our 2 override files — hive-c0re no longer serves any of this as
# a fallback once its own `utoipa-swagger-ui` mount is removed.
# `= /api/docs` (no trailing slash) issues the same redirect
# `utoipa-swagger-ui`'s router used to: that mount is going away
# too, so nginx has to own it now, or the H0M3 hub's own `/api/docs`
# link (no trailing slash) would 404 once hive-c0re drops the route.
swaggerUiLocations = {
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
"/api/docs/" = {
alias = "${swaggerUiTheme}/";
extraConfig = ''
index index.html;
${dashboardAuth}
'';
};
};
in
{
virtualHosts = {
# 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
// wellKnownLocations
// agentLocations
// dashboardProxyLocation
// swaggerUiLocations
// lib.optionalAttrs cfg.auth.enable {
# Internal-only target for the 401 error_page above.
# `internal` prevents direct client access; `alias` serves
# the pre-built HTML from the Nix store.
"= /__hive_auth_unauthorized" = {
extraConfig = ''
internal;
alias ${errorPages.unauthorized};
default_type text/html;
'';
};
};
# Per-agent location blocks, generated at runtime by
# hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf
# on the host — the same machine nginx runs on. nginx parses
# `include` at config-load time so a reload (triggered by c0re
# after each agents.conf write) picks up new or removed
# agents without a nixos-rebuild. nginx's longest-prefix-
# match rule ensures `/agent/<name>/` from this file beats
# the `/agent/` catch-all above.
extraConfig = securityHeaders + ''
include /var/lib/hive-gateway/conf/agents.conf;
'';
};
};
}