From b4c5954491f60e3e4189bc56b1dd316e2728152e Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 17 Oct 2026 13:44:20 +0200 Subject: [PATCH] move the hive's vhosts and matrix discovery out of the gateway The dashboard vhost, the hive domain vhost and the dashboard's local name move to hive-c0re/vhosts.nix; the matrix .well-known discovery and the legacy /matrix/ redirect move to glue-matrix-hive-discovery.nix, which adds them to the hive domain vhost. Both declare through gateway.vhosts. hive-gateway/vhosts.nix keeps the catch-all only, and the gateway no longer reads deploy.matrix or c0re.servedFrontend, nor deploy.hive-controller.enable for its vhosts. Part of #3742. --- docs/networking/gateway.md | 4 +- hive-c0re/src/container_view.rs | 2 +- hive-c0re/src/dashboard/mod.rs | 4 +- hive-c0re/src/paths.rs | 4 +- nix/host-modules/default.nix | 1 + .../glue-matrix-hive-discovery.nix | 81 +++++ nix/host-modules/hive-c0re/default.nix | 7 +- nix/host-modules/hive-c0re/vhosts.nix | 213 +++++++++++ nix/host-modules/hive-gateway/default.nix | 55 +-- nix/host-modules/hive-gateway/error-pages.nix | 2 +- nix/host-modules/hive-gateway/vhost-lib.nix | 11 +- nix/host-modules/hive-gateway/vhosts.nix | 339 ++---------------- nix/host-modules/swarm-ui.nix | 8 +- nix/module-eval/core-toggle.nix | 7 +- nix/module-eval/matrix-core.nix | 25 ++ nix/packages/swagger-ui-theme.nix | 2 +- swarm-controller/src/main.rs | 4 +- 17 files changed, 391 insertions(+), 378 deletions(-) create mode 100644 nix/host-modules/glue-matrix-hive-discovery.nix create mode 100644 nix/host-modules/hive-c0re/vhosts.nix diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index ee186dc5..9b9106be 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -22,7 +22,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | `/metrics` | the queue exporter's `/metrics` on loopback, behind an authelia `auth_request`; every other path, `/` included, returns `403`, so `/varz` and `/connz` are never reachable | `swarm-nats.nix`, the queue's host | | `grafana.`, `metrics.`, `logs.`, `otel.`, `bao.` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | -A service module declares its vhost as `services.hyperhive.gateway.vhosts.`: the locations, `forceSSL` and any `extraConfig` it needs. The gateway renders it into `services.nginx.virtualHosts.` and adds the `listen` set, the certificate for that name and the [security headers](#security-headers) (ahead of the vhost's own `extraConfig`), and drops `addSSL` from a vhost that sets `forceSSL`. The eval fails for a vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself, and for any vhost declared while `gateway.enable` is false. A name with a certificate from outside the gateway's leaves (the queue's) sets `certificate = { cert; key; }` instead. `hive-gateway/vhosts.nix` declares the gateway's own vhosts (`_`, the dashboard and the hive domain) the same way; the catch-all sets `securityHeaders = false` because it sends no response, and the gateway renders a vhost's `default` at `mkDefault` priority. +A service module declares its vhost as `services.hyperhive.gateway.vhosts.`: the locations, `forceSSL` and any `extraConfig` it needs. The gateway renders it into `services.nginx.virtualHosts.` and adds the `listen` set, the certificate for that name and the [security headers](#security-headers) (ahead of the vhost's own `extraConfig`), and drops `addSSL` from a vhost that sets `forceSSL`. The eval fails for a vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself, and for any vhost declared while `gateway.enable` is false. A name with a certificate from outside the gateway's leaves (the queue's) sets `certificate = { cert; key; }` instead. `hive-gateway/vhosts.nix` declares the gateway's own catch-all (`_`) the same way, `hive-c0re/vhosts.nix` the dashboard and the hive domain, and `glue-matrix-hive-discovery.nix` adds matrix discovery to the hive domain where the homeserver runs. The catch-all sets `securityHeaders = false` because it sends no response, and the gateway renders a vhost's `default` at `mkDefault` priority. **The hive's own vhosts**: the dashboard's, named `.hives.` (`swarm.dashboardDomain`), and one named for the hive domain. They exist only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows: @@ -258,7 +258,7 @@ hive-c0re serves exactly two prefixes, so the dashboard routes by **path** — d - `location /health/` → hive-c0re: liveness and readiness. - `location /` → the dashboard dist (from the `servedFrontend` nix-store path) with `try_files $uri /index.html`. -The gateway static-serves the dist while hive-c0re stays API-only, so a frontend-only change doesn't restart the core daemon. A new top-level c0re route prefix needs a matching `location` on the hive vhost (`hive-gateway/vhosts.nix`). +The gateway static-serves the dist while hive-c0re stays API-only, so a frontend-only change doesn't restart the core daemon. A new top-level c0re route prefix needs a matching `location` on the dashboard vhost (`hive-c0re/vhosts.nix`). ### Per-agent unix-socket upstream diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index be230f7f..a14451d0 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -126,7 +126,7 @@ fn agent_url(name: &str) -> Option { /// The pure half of [`agent_url`], split out so the path-scheme formatting /// is testable without an env var in the loop. `/agent//` is the /// gateway's real path scheme -/// (`nix/host-modules/hive-gateway/vhosts.nix`'s per-agent `agentLocations` +/// (`nix/host-modules/hive-c0re/vhosts.nix`'s per-agent `agentLocations` /// block) — computed once here so a client (trollshell's sidebar, the /// dashboard) never has to derive it and silently drift if that scheme /// ever changes. diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index fa11d40c..6cd46850 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -22,7 +22,7 @@ use crate::lifecycle; /// Root of the auto-generated `OpenAPI` spec, served raw at /// `/api/openapi.json` — see [`utoipa`]. Swagger UI itself (browsable at /// `/api/docs`) is nginx-hosted from the nix store (see -/// `nix/host-modules/hive-gateway/vhosts.nix`'s `swaggerUiLocations`). +/// `nix/host-modules/hive-c0re/vhosts.nix`'s `swaggerUiLocations`). /// Only routes carrying a `#[utoipa::path(...)]` annotation show up in /// the spec — an unannotated route just doesn't appear, nothing breaks. #[derive(OpenApi)] @@ -91,7 +91,7 @@ struct AppState { )] fn app(coord: Arc) -> axum::Router { // API-only: the gateway static-serves the dashboard dist and proxies - // non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`). + // non-static requests here (see `nix/host-modules/hive-c0re/vhosts.nix`). // Unmatched paths 404. // No static fallback needed here either. Every route (SSE streams // included — `utoipa::path` can document an SSE body as an opaque diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index d4aea385..e35e8efb 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -223,8 +223,8 @@ pub const KNOWLEDGE_DIR: &str = "/var/lib/hyperhive/knowledge"; /// confinement; nothing else is doing that job. /// /// Sibling of the gateway's `tls/`, which already lived here. -// nix: named by the gateway's nginx config (hive-gateway/vhosts.nix) and created by -// its tmpfiles rules (hive-gateway/default.nix) — must match. +// nix: named by the dashboard vhost (hive-c0re/vhosts.nix) and created by the gateway's +// tmpfiles rules (hive-gateway/default.nix) — must match. pub const GATEWAY_CONF_DIR: &str = "/var/lib/hive-gateway/conf"; #[must_use] diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index ef1a1760..3314d1a0 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -40,6 +40,7 @@ ./glue-logs-auth-bao-identity.nix ./glue-metrics-auth-bao-identity.nix ./glue-matrix-ctl-bao-identity.nix + ./glue-matrix-hive-discovery.nix ./glue-matrix-oidc-client.nix ./glue-nats-auth-bao-identity.nix ./glue-nats-bao-identity.nix diff --git a/nix/host-modules/glue-matrix-hive-discovery.nix b/nix/host-modules/glue-matrix-hive-discovery.nix new file mode 100644 index 00000000..8885e947 --- /dev/null +++ b/nix/host-modules/glue-matrix-hive-discovery.nix @@ -0,0 +1,81 @@ +# Glue: serve the swarm homeserver's discovery on the hive domain's vhost +# wherever both run. +# +# ONE PAIRING PER FILE — matrix → hive domain vhost, and nothing else. +# Deleting this leaves federation and clients with no `.well-known/matrix/*` +# answer at the server name, and the legacy `/matrix/*` links 404. +# +# The locations sit on the hive domain because the matrix spec reads +# delegation off the *server name*: it is the hive answering "where is my +# homeserver", not the homeserver answering for itself (./hive-matrix.nix's +# vhost on `gatewayHost` carries the rest). +# +# ⚠️ The condition repeats the one ./hive-c0re/vhosts.nix declares the hive +# domain vhost under. With it looser, this file alone would declare that +# vhost, with discovery and no `/` redirect. +{ + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + gatewayCfg = hyperhiveCfg.gateway; + matrixCfg = hyperhiveCfg.swarm.matrix; + + publicPort = gatewayCfg.httpsPort; + # The canonical 443 elides the port; see `docs/networking/gateway.md` + # ("Self-signed TLS"). + homeserverUrl = "https://${matrixCfg.gatewayHost}${ + lib.optionalString (publicPort != 443) ":${toString publicPort}" + }"; +in +{ + config = + lib.mkIf + ( + hyperhiveCfg.deploy.matrix.enable + && matrixCfg.gatewayHost != null + && hyperhiveCfg.deploy.hive-controller.enable + && gatewayCfg.enable + && hyperhiveCfg.swarm.dashboardDomain != null + ) + { + services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations = { + # `/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.` + # by default; legacy deep-link shim for the fluffychat sub-domain). + # See `docs/networking/gateway.md`. + "/matrix/" = { + extraConfig = '' + rewrite ^/matrix/(.*)$ ${homeserverUrl}/$1 permanent; + ''; + }; + + # `.well-known/matrix/{client,server}` discovery JSON, pointing + # clients and peers at `gatewayHost`: the homeserver has no other + # address. CORS `*` per matrix spec. See `docs/networking/gateway.md`. + "= /.well-known/matrix/client" = { + extraConfig = '' + default_type application/json; + ${gatewayCfg.lib.securityHeaders} + add_header Access-Control-Allow-Origin *; + return 200 '{"m.homeserver":{"base_url":"${homeserverUrl}"}}'; + ''; + }; + # `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, unlike the client + # base_url above. Without it, peers federate to :8448 + # (closed) while the endpoint actually lives on the gateway's 443 + # vhost. See docs/networking/gateway.md discovery flow. + "= /.well-known/matrix/server" = { + extraConfig = '' + default_type application/json; + return 200 '{"m.server":"${matrixCfg.gatewayHost}:${toString publicPort}"}'; + ''; + }; + }; + }; +} diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index e919af48..62471e8f 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -1,9 +1,9 @@ # The hive-c0re coordinator daemon (runs as the unprivileged # `hive-core` user), socket-activated at /run/hyperhive/host.sock. # Layout: ./options.nix (option declarations), ./theme.nix (stylix -# frontend theming → `servedFrontend`), ./environment.nix (the daemon -# unit's env attrset). The root privileged helper it delegates to is -# its own module (../hive-priv.nix). +# frontend theming → `servedFrontend`), ./vhosts.nix (its gateway vhosts), +# ./environment.nix (the daemon unit's env attrset). The root privileged +# helper it delegates to is its own module (../hive-priv.nix). { pkgs, lib, @@ -125,6 +125,7 @@ in imports = [ ./options.nix ./theme.nix + ./vhosts.nix # Hive-CA trust for this daemon's outbound TLS. The matrix provisioning # URL (`swarm.matrix.apiUrl`) defaults to https on the homeserver's # gateway vhost, which serves a leaf chaining to the store's pki root — diff --git a/nix/host-modules/hive-c0re/vhosts.nix b/nix/host-modules/hive-c0re/vhosts.nix new file mode 100644 index 00000000..336e6822 --- /dev/null +++ b/nix/host-modules/hive-c0re/vhosts.nix @@ -0,0 +1,213 @@ +# The hive's gateway surface, declared under `services.hyperhive.gateway.vhosts` +# like every service's: the dashboard's vhost (`swarm.dashboardDomain`), which +# carries the dashboard behind authelia, per-agent routing and the swagger UI; +# and the vhost named for the hive domain, which redirects everything to the +# dashboard's name. Matrix discovery on the hive domain is added by +# ../glue-matrix-hive-discovery.nix. +{ + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + gatewayCfg = hyperhiveCfg.gateway; + inherit (hyperhiveCfg.swarm) dashboardDomain; + autheliaDomain = hyperhiveCfg.swarm.authelia.domain; + inherit (gatewayCfg.lib) errorPages; + + # Dashboard SPA dist, static-served by nginx. + dashboardDist = "${hyperhiveCfg.c0re.servedFrontend}/dashboard"; + + # The canonical 443 elides the port; see `docs/networking/gateway.md` + # ("Self-signed TLS"). + publicPortSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; + + # `/agent/` catch-all 404 + the two internal error-page targets it + # points at. Per-agent `location /agent//` 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//` 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; + ''; + }; + }; + + # Repeated verbatim by every operator-gated location (`/`, `/api/`, + # `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling + # locations, so a location without it is reachable with no session. + # + # ⚠️ `auth_request` answers "is there a session", not "is this an + # operator". authelia's `operatorsOnly` rule for this name + # (../swarm-authelia.nix) is what refuses an agent's own account. + dashboardAuth = '' + auth_request /__hive_authelia; + # Captured BEFORE the error_page jump: inside the 401 handler + # `$request_uri` is the internal one. + auth_request_set $target_url $scheme://$http_host$request_uri; + error_page 401 =302 https://${autheliaDomain}/?rd=$target_url; + ''; + + # Dashboard: nginx static-serves the dist, c0re is API-only. Routing + # is by PATH, never content-type. c0re serves exactly two prefixes — + # `/api/` (all dashboard data + actions + the SSE streams) 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 + /health) needs + # a matching location added here. + dashboardProxyLocation = { + "/" = { + root = dashboardDist; + extraConfig = '' + try_files $uri /index.html; + ${dashboardAuth} + ''; + }; + "/api/" = { + proxyPass = "http://${gatewayCfg.upstreamHost}:${toString gatewayCfg.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} + ''; + }; + "/health/" = { + # No dashboardAuth here: an external uptime monitor can't do an + # interactive login. 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://${gatewayCfg.upstreamHost}:${toString gatewayCfg.upstreamPort}"; + }; + }; + + # Swagger UI: nginx hosts the FULL themed dist (`gateway.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: core hosts only the JSON. Only + # `/api/openapi.json` (the live-generated spec `index.html` fetches; + # not under this prefix) proxies to c0re via "/api/" above — 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 covers every file in + # the tree (bundle.js, maps, favicons, …), not just our 2 override + # files — hive-c0re serves none of this as a fallback. + # `= /api/docs` (no trailing slash) redirects to `/api/docs/`: nginx + # owns that redirect because hive-c0re has no route for it, and the + # dashboard's own `/api/docs` links (no trailing slash) would 404 + # without it. + swaggerUiLocations = { + "= /api/docs" = { + extraConfig = '' + return 301 /api/docs/; + ''; + }; + "/api/docs/" = { + alias = "${gatewayCfg.swaggerUiTheme}/"; + extraConfig = '' + index index.html; + ${dashboardAuth} + ''; + }; + }; + + # The subrequest `dashboardAuth` makes, the same as the swarm UI's + # (../swarm-ui.nix), which documents the header set. + autheliaLocation = { + "= /__hive_authelia" = { + proxyPass = "https://${autheliaDomain}/api/authz/auth-request"; + # nixpkgs appends its OWN `Host $host` after extraConfig, which would + # override verifiedProxyTo's — see the comment on verifiedProxyTo in + # ../hive-gateway/vhost-lib.nix. + recommendedProxySettings = false; + extraConfig = '' + internal; + ${gatewayCfg.lib.verifiedProxyTo autheliaDomain} + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + }; +in +{ + # Gated on the gateway too: a hive may run with the gateway off, and a + # vhost declared then fails the eval (../hive-gateway/declared-vhosts.nix). + config = + lib.mkIf + (hyperhiveCfg.deploy.hive-controller.enable && gatewayCfg.enable && dashboardDomain != null) + { + # The dashboard's name, for agents on the bridge and (with + # `localHostsEntry`) the host's own browser. + services.hyperhive.gateway.localNames = [ dashboardDomain ]; + + services.hyperhive.gateway.vhosts = { + # The dashboard, reachable by its own NAME under the swarm domain, + # where authelia's session cookie reaches. The hive leaf carrying + # this name exists only where a hive runs. + # + # The agent routes live here and not on the hive domain because the + # agent page calls the dashboard's `/api/` on its own origin + # (frontend/packages/agent/src/lib/dashboardBase.ts), and the + # dashboard links to them by path. They carry no `auth_request`. + # + # `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers + # the subrequest for an `http://` target with 400, which + # `auth_request` cannot read. + ${dashboardDomain} = { + forceSSL = true; + locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation; + # 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//` from this file beats + # the `/agent/` catch-all above. + extraConfig = '' + include /var/lib/hive-gateway/conf/agents.conf; + ''; + }; + + # The hive domain's own vhost: a redirect of every path to the + # dashboard's name, so a bookmarked dashboard or agent link still + # lands. + ${hyperhiveCfg.domain} = { + locations."/".extraConfig = '' + return 301 https://${dashboardDomain}${publicPortSuffix}$request_uri; + ''; + }; + }; + }; +} diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 012e4e84..4cfe0839 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -6,7 +6,7 @@ # namespace of its own. # Full vhost map + discovery flow + design rationale in # `docs/networking/gateway.md`. Layout: ./options.nix (option declarations), -# ./vhosts.nix (the gateway's own nginx virtual hosts), ./declared-vhosts.nix +# ./vhosts.nix (the catch-all `_` vhost), ./declared-vhosts.nix # (renders the vhosts service modules declare under `gateway.vhosts`), ./error-pages.nix # (styled static pages), ./dns.nix + ./dnsmasq.nix (the resolver, which # has an enable of its own because a host can need hive names to resolve @@ -24,8 +24,6 @@ let # cert are exactly the names that cert is issued for, so both read the # same list rather than each deciding what "a swarm service" means. swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; - matrixCfg = config.services.hyperhive.swarm.matrix; - matrixDeployCfg = config.services.hyperhive.deploy.matrix; tlsStateDir = config.services.hyperhive.deploy.hive-controller.tls.stateDir; # The hive leaf exists only where a hive runs; ../hive-tls.nix issues it # under that same condition. @@ -48,16 +46,6 @@ let lib.attrNames config.services.nginx.virtualHosts ); - # Dashboard SPA dist, static-served by nginx. - dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; - - # Full hyperhive-themed Swagger UI dist — nginx serves this whole - # tree straight from the store at /api/docs/, no hive-c0re fallback - # (see vhosts.nix's `swaggerUiLocations` and - # nix/packages/swagger-ui-{dist,theme}.nix). Own option under this - # module (not c0re's) — hive-c0re has no relationship to it. - swaggerUiTheme = cfg.swaggerUiTheme; - # Self-signed TLS is the implicit floor: when neither an operator cert # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway # serves a leaf the secret store issues (see hive-tls.nix). There is no @@ -85,17 +73,15 @@ let svcCert = "${tlsDir}/swarm-services.pem"; svcKey = "${tlsDir}/swarm-services-key.pem"; - # Styled static error pages. Built once here and reached two ways: - # directly by ./vhosts.nix, and via the published kit by any service - # module that aims an `error_page` at one. + # Styled static error pages, reached through the published kit by any + # service module that aims an `error_page` at one. errorPages = import ./error-pages.nix { inherit pkgs; }; # The vhost construction kit (listen set / per-name TLS attrs / - # security headers / error pages). Computed here, published as - # `cfg.lib` below, and handed to ./vhosts.nix **as the published - # value** — so the tree the gateway renders and the kit a service - # module gets are the same object by construction, not by two call - # sites agreeing. + # security headers / error pages). Computed here and published as + # `cfg.lib` below, which ./declared-vhosts.nix and every service module + # read, so the tree the gateway renders and the kit a service module + # gets are the same object by construction. vhostLib = import ./vhost-lib.nix { inherit lib @@ -113,23 +99,6 @@ let # per-container copy. caBundle = "${config.services.hyperhive.deploy.hive-controller.tls.stateDir}/trust-bundle.pem"; }; - - ownVhosts = import ./vhosts.nix { - gwLib = cfg.lib; - inherit (config.services.hyperhive.swarm) dashboardDomain; - autheliaDomain = config.services.hyperhive.swarm.authelia.domain; - inherit - lib - cfg - errorPages - matrixCfg - matrixDeployCfg - hyperhiveDomain - runsHive - dashboardDist - swaggerUiTheme - ; - }; in { imports = [ @@ -145,8 +114,8 @@ in # file's `let` and are not option surface. services.hyperhive.gateway.lib = vhostLib; - # The gateway's own vhosts, rendered the way every service's are. - services.hyperhive.gateway.vhosts = ownVhosts.vhosts; + # The gateway's own vhost, rendered the way every service's are. + services.hyperhive.gateway.vhosts = import ./vhosts.nix; assertions = [ { @@ -256,12 +225,6 @@ in "r /var/lib/hive-gateway/conf/gateway.htpasswd" ]; - # The dashboard's name, for agents on the bridge and (with - # `localHostsEntry`) the host's own browser. - services.hyperhive.gateway.localNames = lib.optional ( - runsHive && config.services.hyperhive.swarm.dashboardDomain != null - ) config.services.hyperhive.swarm.dashboardDomain; - # ACME (Let's Encrypt) integration. nginx vhosts set # `enableACME = true` via the vhost builder; this provides the # shared ACME config (acceptTerms + email). diff --git a/nix/host-modules/hive-gateway/error-pages.nix b/nix/host-modules/hive-gateway/error-pages.nix index 9c791ff7..adf7dfa5 100644 --- a/nix/host-modules/hive-gateway/error-pages.nix +++ b/nix/host-modules/hive-gateway/error-pages.nix @@ -2,7 +2,7 @@ # special-cased, all rendered from one Catppuccin-styled template. # Useful pages instead of nginx's default 404/502 — see # `docs/networking/gateway.md::Per-agent error pages` for the design rationale + -# page-vs-status semantics. Consumed by ./vhosts.nix. +# page-vs-status semantics. Published as `gateway.lib.errorPages`. { pkgs }: let mkPage = diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index 54e20a06..33bb6f7f 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -2,9 +2,8 @@ # attrs, and the security headers every vhost in front of this gateway # needs. # -# Split out of ./vhosts.nix so it has one home and several consumers. -# ./vhosts.nix and ./declared-vhosts.nix (which renders the vhosts service -# modules declare under `gateway.vhosts`) read it through +# One home, several consumers: ./declared-vhosts.nix (which renders the +# vhosts service modules declare under `gateway.vhosts`) reads it through # `services.hyperhive.gateway.lib` (see ./options.nix) rather than calling # this file directly — one source, not a copy that agrees by inspection. A # service module reads `verifiedProxyTo`, `errorPages` and `securityHeaders` @@ -111,9 +110,9 @@ in # own add_header does NOT inherit the server-level ones. Any # location with its own add_header (e.g. CORS on /.well-known or # /_matrix/) must repeat the security headers explicitly — see those - # locations in ./vhosts.nix. HTML-serving and proxy locations that - # carry no add_header of their own pick these up from the server - # scope automatically. + # locations in ../glue-matrix-hive-discovery.nix and ../hive-matrix.nix. + # HTML-serving and proxy locations that carry no add_header of their own + # pick these up from the server scope automatically. securityHeaders = '' add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 510ec80d..5a9305c6 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -1,311 +1,40 @@ -# The gateway's own vhosts, as `services.hyperhive.gateway.vhosts` -# declarations (rendered by ./declared-vhosts.nix): the `_` default server, -# which answers unmatched `Host` with a bare 444; the dashboard's vhost -# (`swarm.dashboardDomain`), which carries the dashboard behind authelia, -# per-agent routing and the swagger UI; and the vhost named for the hive -# domain, which carries matrix discovery, the legacy `/matrix/*` -# redirect and a redirect of everything else to the dashboard's name. +# The gateway's own vhost, as a `services.hyperhive.gateway.vhosts` +# declaration (rendered by ./declared-vhosts.nix): the `_` default server, +# which answers unmatched `Host` with a bare 444. # -# Scope: a swarm service declares its own vhost and dns name in its own -# module, the same way. 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 `{ vhosts }`. +# Scope: a service declares its own vhost and dns name in its own module, +# the same way — the hive's dashboard and hive domain in +# ../hive-c0re/vhosts.nix, matrix discovery in +# ../glue-matrix-hive-discovery.nix. That keeps a service's gateway surface +# next to the service, and keeps this file to what the gateway itself +# serves. { - lib, - cfg, # services.hyperhive.gateway - matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS - matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs - hyperhiveDomain, - dashboardDomain, # services.hyperhive.swarm.dashboardDomain - autheliaDomain, # services.hyperhive.swarm.authelia.domain - runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here - dashboardDist, - swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css - errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable } - gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option -}: -let - # Read through `gwLib` (the published option) rather than importing - # ./vhost-lib.nix directly, so the headers on the matrix discovery - # locations are the ones every other vhost gets. - inherit (gwLib) securityHeaders; - - # Public-facing scheme + port-suffix for URLs the gateway - # mints into responses (well-known JSON, the deprecated - # `/matrix/*` 301 redirect, future absolute-URL needs): - # always `https://` (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}"; - - # `/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.` - # by default; legacy deep-link shim for the fluffychat sub-domain). - # See `docs/networking/gateway.md`. - matrixRedirectLocations = - lib.optionalAttrs (matrixDeployCfg.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, pointing clients - # and peers at `matrixCfg.gatewayHost`: the homeserver has no other - # address. CORS `*` per matrix spec. The `m.server` port-8448 carve-out - # is documented inline. See `docs/networking/gateway.md`. - wellKnownLocations = lib.optionalAttrs (matrixDeployCfg.enable && matrixCfg.gatewayHost != null) ( - let - clientBaseUrl = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; - # `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 - # :8448 (closed) while the endpoint actually lives on - # the gateway's 443 vhost. See docs/networking/gateway.md discovery flow. - serverHostPort = "${matrixCfg.gatewayHost}:${toString publicPort}"; - 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//` 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//` 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; - ''; - }; - }; - - # Repeated verbatim by every operator-gated location (`/`, `/api/`, - # `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling - # locations, so a location without it is reachable with no session. + # 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. # - # ⚠️ `auth_request` answers "is there a session", not "is this an - # operator". authelia's `operatorsOnly` rule for this name - # (../swarm-authelia.nix) is what refuses an agent's own account. - dashboardAuth = '' - auth_request /__hive_authelia; - # Captured BEFORE the error_page jump: inside the 401 handler - # `$request_uri` is the internal one. - auth_request_set $target_url $scheme://$http_host$request_uri; - error_page 401 =302 https://${autheliaDomain}/?rd=$target_url; - ''; - - # Dashboard: nginx static-serves the dist, c0re is API-only. Routing - # is by PATH, never content-type. c0re serves exactly two prefixes — - # `/api/` (all dashboard data + actions + the SSE streams) 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 + /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} - ''; - }; - "/health/" = { - # No dashboardAuth here: an external uptime monitor can't do an - # interactive login. 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: core hosts only the JSON. Only - # `/api/openapi.json` (the live-generated spec `index.html` fetches; - # not under this prefix) proxies to c0re via "/api/" below — that's - # the one thing that has to stay dynamic. + # `_` 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`. # - # Prefix location, not exact-match: wins over "/api/" on plain - # prefix length (no ordering/`=` needed), and covers every file in - # the tree (bundle.js, maps, favicons, …), not just our 2 override - # files — hive-c0re serves none of this as a fallback. - # `= /api/docs` (no trailing slash) redirects to `/api/docs/`: nginx - # owns that redirect because hive-c0re has no route for it, and the - # dashboard's own `/api/docs` links (no trailing slash) would 404 - # without it. - swaggerUiLocations = { - "= /api/docs" = { - extraConfig = '' - return 301 /api/docs/; - ''; - }; - "/api/docs/" = { - alias = "${swaggerUiTheme}/"; - extraConfig = '' - index index.html; - ${dashboardAuth} - ''; - }; - }; - - # The subrequest `dashboardAuth` makes, the same as the swarm UI's - # (../swarm-ui.nix), which documents the header set. - autheliaLocation = { - "= /__hive_authelia" = { - proxyPass = "https://${autheliaDomain}/api/authz/auth-request"; - # nixpkgs appends its OWN `Host $host` after extraConfig, which would - # override verifiedProxyTo's — see the comment on verifiedProxyTo in - # ./vhost-lib.nix. - recommendedProxySettings = false; - extraConfig = '' - internal; - ${gwLib.verifiedProxyTo autheliaDomain} - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-Method $request_method; - proxy_set_header X-Original-URL $scheme://$http_host$request_uri; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - ''; - }; - }; -in -{ - vhosts = { - # 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 gets TLS attrs from the gateway. 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. - # - # The gateway renders `default` at `mkDefault` priority: 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. - "_" = { - default = true; - # A response to attach headers to is exactly what this vhost refuses. - securityHeaders = false; - extraConfig = '' - return 444; - ''; - }; - } - // lib.optionalAttrs (runsHive && dashboardDomain != null) { - # The dashboard, reachable by its own NAME under the swarm domain, where - # authelia's session cookie reaches. Only where a hive runs: the - # dashboard, agent routes and swagger UI proxy to hive-c0re, which runs - # only with the hive controller, and the hive leaf carrying this name - # exists only there. - # - # The agent routes live here and not on the hive domain because the - # agent page calls the dashboard's `/api/` on its own origin - # (frontend/packages/agent/src/lib/dashboardBase.ts), and the dashboard - # links to them by path. They carry no `auth_request`. - # - # `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers the - # subrequest for an `http://` target with 400, which `auth_request` - # cannot read. - ${dashboardDomain} = { - forceSSL = true; - locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation; - # 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//` from this file beats - # the `/agent/` catch-all above. - extraConfig = '' - include /var/lib/hive-gateway/conf/agents.conf; - ''; - }; - - # The hive domain's own vhost: matrix discovery, which federation - # reads off the server name, and a redirect of every other path to the - # dashboard's name, so a bookmarked dashboard or agent link still lands. - ${hyperhiveDomain} = { - locations = - matrixRedirectLocations - // wellKnownLocations - // { - "/".extraConfig = '' - return 301 ${publicScheme}://${dashboardDomain}${publicPortSuffix}$request_uri; - ''; - }; - }; + # ⚠️ It still gets TLS attrs from the gateway. 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. + # + # The gateway renders `default` at `mkDefault` priority: 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. + "_" = { + default = true; + # A response to attach headers to is exactly what this vhost refuses. + securityHeaders = false; + extraConfig = '' + return 444; + ''; }; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 2e25b2b9..7d74cd37 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -3,9 +3,9 @@ # `swarm.dashboardDomain`) — this one answers for the swarm apex and is the # operator's view across hives. # -# Options only. The vhost itself is declared in hive-gateway/vhosts.nix -# alongside forge/matrix/authelia: a service module says *how* it is -# reached, the gateway says *whether this host serves it*. +# Options, and the swarm apex's vhost, declared below through +# `gateway.vhosts`: a service module says *how* it is reached, the gateway +# says *whether this host serves it*. { lib, config, @@ -252,7 +252,7 @@ in }; # Swagger UI: same "nginx hosts the themed dist straight from # the store, only /api/openapi.json is dynamic" shape as the - # per-hive gateway's `swaggerUiLocations`. + # hive dashboard's `swaggerUiLocations` (./hive-c0re/vhosts.nix). "= /api/docs" = { extraConfig = '' return 301 /api/docs/; diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 5e9b8ffc..b92bd6bd 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -484,12 +484,13 @@ let && allLocal.systemd.services.hive-c0re.environment.HYPERHIVE_DASHBOARD_DOMAIN == "h1.hives.t.local"; } { - # The gateway's own three vhosts are declared under `gateway.vhosts` like - # a service's, so the gateway's listen set and issuer choice reach them + # The gateway's catch-all and the hive's two vhosts (../host-modules/ + # hive-c0re/vhosts.nix) are declared under `gateway.vhosts` like a + # service's, so the gateway's listen set and issuer choice reach them # by the same path. The catch-all keeps its bare `return 444;` (no # headers: it sends no response) and the dashboard keeps its exact # location set. - name = "the gateway's own vhosts render with its listen set, issuer choice, headers and locations"; + name = "the catch-all and the hive's vhosts render with the gateway's listen set, issuer choice, headers and locations"; ok = let gw = allLocal.services.hyperhive.gateway; diff --git a/nix/module-eval/matrix-core.nix b/nix/module-eval/matrix-core.nix index 9be157d9..ed6625d9 100644 --- a/nix/module-eval/matrix-core.nix +++ b/nix/module-eval/matrix-core.nix @@ -135,6 +135,31 @@ let matrixElsewhere.systemd.services.hive-c0re.environment.HIVE_MATRIX_API_URL == "https://chat.t.local"; } + { + # The discovery locations reach the hive domain's vhost from a glue + # module, merged into the vhost hive-c0re declares. The matrix-elsewhere + # arm reads the same vhost, which still exists there with its redirect, + # so its absence of discovery cannot pass on a misspelled name. + name = "matrix discovery sits on the hive domain's vhost only where the homeserver runs"; + ok = + let + here = matrixNoBaoIdentity.services.nginx.virtualHosts."h1.t.local".locations; + elsewhere = matrixElsewhere.services.nginx.virtualHosts."h1.t.local".locations; + in + lib.hasInfix ''{"m.homeserver":{"base_url":"https://chat.t.local"}}'' ( + here."= /.well-known/matrix/client".extraConfig or "" + ) + && lib.hasInfix ''{"m.server":"chat.t.local:443"}'' ( + here."= /.well-known/matrix/server".extraConfig or "" + ) + && lib.hasInfix "rewrite ^/matrix/(.*)$ https://chat.t.local/$1 permanent;" ( + here."/matrix/".extraConfig or "" + ) + && lib.hasInfix "return 301 https://h1.hives.t.local$request_uri;" here."/".extraConfig + && elsewhere ? "/" + && !(elsewhere ? "= /.well-known/matrix/client") + && !(elsewhere ? "/matrix/"); + } ]; in runGroup "matrix-core" cases diff --git a/nix/packages/swagger-ui-theme.nix b/nix/packages/swagger-ui-theme.nix index c37ce3ba..91413c66 100644 --- a/nix/packages/swagger-ui-theme.nix +++ b/nix/packages/swagger-ui-theme.nix @@ -9,7 +9,7 @@ # the plain vendored dist (./swagger-ui-dist.nix). This is the FULL # tree hive-gateway's nginx serves at `/api/docs/` — hive-c0re hosts # none of it, only the dynamic `/api/openapi.json` (see -# nix/host-modules/hive-gateway/vhosts.nix). +# nix/host-modules/hive-c0re/vhosts.nix). # # Output layout: every file from `swagger-ui-dist`, with 3 replaced: # index.html adds colors.css (live stylix theme) + diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index cdc6c1da..39a5c800 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -20,8 +20,8 @@ //! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a //! raw JSON route at `/api/openapi.json`. Swagger UI itself is //! nginx-hosted from the nix store, same shape as the per-hive -//! dashboard's (`nix/host-modules/hive-gateway/vhosts.nix`'s -//! `swarmUiVhost` — a swagger-ui-theme dist under `/api/docs/`, no +//! dashboard's (`nix/host-modules/hive-c0re/vhosts.nix`'s +//! `swaggerUiLocations` — a swagger-ui-theme dist under `/api/docs/`, no //! fallback to this daemon). Only annotated routes appear in the spec; //! an unannotated one just doesn't show up, nothing breaks.