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.
289 lines
12 KiB
Nix
289 lines
12 KiB
Nix
# nginx virtual-host tree for the gateway: the `_` default server
|
|
# (dashboard, per-agent routing, matrix discovery), the forge, matrix
|
|
# and authelia sub-domain vhosts, and the Accept-header SPA map for the
|
|
# matrix GUI. Pure function — called from ./default.nix with the
|
|
# outer-scope config values as arguments; returns
|
|
# `{ virtualHosts }`.
|
|
{
|
|
lib,
|
|
cfg, # services.hyperhive.gateway
|
|
matrixCfg,
|
|
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/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/gateway.md`.
|
|
matrixRedirectLocations =
|
|
lib.optionalAttrs (matrixCfg.enable && matrixCfg.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/gateway.md`.
|
|
wellKnownLocations = lib.optionalAttrs matrixCfg.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/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;
|
|
'';
|
|
};
|
|
};
|
|
}
|