Moves the matrix sub-domain vhost out of the gateway's vhosts.nix, its `address=` rule out of dnsmasq.nix, and the Accept-header `$matrix_spa_target` map out of the gateway's appendHttpConfig — all three into hive-matrix.nix. The map is the one that had no business being where it was: it exists solely for the SPA fallback in the vhost's `/` location, and `appendHttpConfig` is a `lines` option, so a module can contribute to it without the gateway assembling it. The `.well-known/matrix/*` delegation deliberately stays on the hive's own vhost. The spec requires it at the SERVER NAME, which is the hive domain: that is the hive answering "where is my homeserver", not the homeserver answering for itself. Moving it would have been the obvious symmetric thing and it would have been wrong.
373 lines
16 KiB
Nix
373 lines
16 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,
|
|
autheliaCfg, # services.hyperhive.swarm.authelia
|
|
uiCfg, # services.hyperhive.swarm.ui
|
|
controllerCfg, # services.hyperhive.swarm.controller
|
|
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}";
|
|
|
|
# Swarm UI vhost — the swarm's front page, on the swarm apex, and the
|
|
# FIRST `auth_request` anywhere in this gateway (everything else is
|
|
# `auth_basic` + htpasswd).
|
|
#
|
|
# ⚠️ `auth_request` answers "is there a session", not "is this an
|
|
# operator". The operator-only part is authelia's `access_control`
|
|
# rule (../swarm-authelia.nix) requiring `group:operators` — agents
|
|
# are getting authelia accounts of their own, and without that rule a
|
|
# session alone would open this page.
|
|
#
|
|
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
|
|
# that wrongly denies takes the whole UI away. That is the reason the
|
|
# redirect target and the header set below are copied from a measured
|
|
# source rather than from an example.
|
|
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a hardening
|
|
# preference, the only way this page works at all. authelia answers the
|
|
# auth subrequest for an `http://` target with **400**, and nginx's
|
|
# `auth_request` only understands 2xx/401/403, so a plain-http visit
|
|
# dies as "auth request unexpected status: 400" with no hint a login
|
|
# exists. `vhostListen` binds :80, so without this the door is open on
|
|
# a port the lock cannot work on. Serving forge or matrix over http is
|
|
# merely insecure rather than broken, so they keep `addSSL` and the
|
|
# asymmetry stays local to the vhost whose correctness depends on the
|
|
# scheme. `removeAttrs` because nixos asserts on a vhost declaring both.
|
|
# Shared with every swarm-UI-vhost location below (`/`, `/api/`,
|
|
# `/api/docs/`) — auth_request does not inherit across sibling
|
|
# locations, so each one that should be operator-gated repeats this
|
|
# verbatim rather than only the page itself being protected while its
|
|
# own API and API docs are reachable unauthenticated.
|
|
swarmAuthRequest = ''
|
|
auth_request /__hive_authelia;
|
|
# Captured BEFORE the error_page jump: inside the 401 handler
|
|
# `$request_uri` is the internal one, so building the return
|
|
# link there sends the operator back to the auth subrequest
|
|
# instead of the page they asked for.
|
|
auth_request_set $target_url $scheme://$http_host$request_uri;
|
|
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
|
|
'';
|
|
|
|
swarmUiVhost = lib.optionalAttrs uiCfg.enable {
|
|
"${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // {
|
|
forceSSL = true;
|
|
listen = vhostListen;
|
|
extraConfig = securityHeaders;
|
|
locations = {
|
|
"/" = {
|
|
root = "${uiCfg.package}";
|
|
extraConfig = ''
|
|
${swarmAuthRequest}
|
|
# SPA: any path the bundle routes client-side is served the
|
|
# entry document rather than a 404 from the filesystem.
|
|
try_files $uri /index.html;
|
|
'';
|
|
};
|
|
# swarm-controller's whole HTTP surface, including the live
|
|
# `/api/openapi.json` spec — proxied untouched (no URI segment
|
|
# after the socket path, same "pass the request through as-is"
|
|
# shape as the per-hive dashboard's own `/api/` proxy) so the
|
|
# path swarm-controller registered a route at is the path
|
|
# nginx forwards, no prefix-stripping to keep in sync by hand.
|
|
"/api/" = {
|
|
proxyPass = "http://unix:${controllerCfg.socketPath}:";
|
|
extraConfig = swarmAuthRequest;
|
|
};
|
|
# 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` — see that block's
|
|
# comment for why core-equivalent (here, swarm-controller)
|
|
# does not also mount its own copy.
|
|
"= /api/docs" = {
|
|
extraConfig = ''
|
|
return 301 /api/docs/;
|
|
'';
|
|
};
|
|
"/api/docs/" = {
|
|
alias = "${swaggerUiTheme}/";
|
|
extraConfig = ''
|
|
index index.html;
|
|
${swarmAuthRequest}
|
|
'';
|
|
};
|
|
# The subrequest itself. `auth-request` is the implementation
|
|
# name authelia exposes under `/api/authz/`; `/api/verify` is the
|
|
# LEGACY path every older example shows.
|
|
#
|
|
# Header set measured against the pinned binary (4.39.20), not
|
|
# copied: `X-Original-URL` and `X-Original-Method` are present as
|
|
# literals and are what this implementation reads —
|
|
# `X-Forwarded-Uri` does not appear in it at all, so sending it
|
|
# would look like configuration and be dead weight.
|
|
"= /__hive_authelia" = {
|
|
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
|
|
extraConfig = ''
|
|
internal;
|
|
# A subrequest carries no body, and forwarding one here makes
|
|
# authelia read a payload it will never use.
|
|
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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
# `<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 = {
|
|
# `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.
|
|
"_" = (vhostTlsFor "_") // {
|
|
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;
|
|
'';
|
|
};
|
|
}
|
|
|
|
// swarmUiVhost;
|
|
}
|