Extends the theme-only alias into the full shape mara asked for on the
PR thread:
1. nix/packages/swagger-ui-dist.nix — plain vendored Swagger UI 5.17.14
dist, sourced directly from the swagger-ui-dist npm package (same
release the Rust utoipa-swagger-ui-vendored crate ships, verified
via matching gitHead commit) rather than through Cargo.lock/cargo.
2. nix/packages/swagger-ui-theme.nix — overlays our 3 override files
(index.html, hyperhive-theme.css, and now swagger-initializer.js)
onto (1).
3. vhosts.nix's swaggerUiLocations now prefix-matches the whole
/api/docs/ tree (not just 2 exact-match files) straight from (2),
plus a `= /api/docs` redirect shim since hive-c0re's own redirect
is going away too. /api/openapi.json (outside this prefix) keeps
proxying to c0re unchanged — that's the one thing that stays
dynamic.
New file swagger-initializer.js needed hand-verification: the plain
vendored copy hardcodes the swagger.io petstore demo URL.
utoipa-swagger-ui normally rewrites it per-request from a {{config}}
placeholder its own build.rs injects — since hive-c0re won't be
serving this file at all once its SwaggerUi mount is removed, that
rewrite has to be baked in statically here instead. Derived by
actually running build.rs's own two transforms (strip the default
layout: line, splice the Config JSON in place of the url/deepLinking
block) against the real vendored file, not typed from scratch —
verified byte-for-byte against what format_config() would produce for
hive-c0re's actual single-URL config, and checked with node --check.
Coordinated with damocles: he's taking the corresponding hive-c0re
side (drop the utoipa-swagger-ui dependency + SwaggerUi::new(...)
mount, keep only the plain /api/openapi.json route) once this lands.
Verified: nix fmt clean; nix build .#swagger-ui-theme succeeds, output
byte-matches the checked-in override files and node --check passes on
swagger-initializer.js; a full nixosSystem eval of nixosModules.default
resolves both new locations (/api/docs/ aliased to the right store
path, = /api/docs redirecting) with auth threaded through.
392 lines
15 KiB
Nix
392 lines
15 KiB
Nix
# nginx virtual-host tree for the gateway container: the `_` default
|
|
# server (dashboard, per-agent routing, matrix discovery), the forge
|
|
# and matrix sub-domain vhosts, and the Accept-header SPA map for the
|
|
# matrix GUI. Pure function — called from ./default.nix inside the
|
|
# container config with the outer-scope config values as arguments;
|
|
# returns `{ virtualHosts, appendHttpConfig }`.
|
|
{
|
|
lib,
|
|
cfg, # services.hyperhive.gateway
|
|
forgeCfg,
|
|
matrixCfg,
|
|
hyperhiveDomain,
|
|
dashboardDist,
|
|
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
|
|
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
|
|
tlsCert,
|
|
tlsKey,
|
|
}:
|
|
let
|
|
# The gateway always terminates TLS: self-signed is the implicit
|
|
# floor when neither `tls.certDir` nor ACME is set, so there is no
|
|
# http-only mode. Listen addresses every vhost shares — plain http
|
|
# on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md`
|
|
# ("TLS modes").
|
|
vhostListen = [
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = cfg.port;
|
|
}
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = cfg.httpsPort;
|
|
ssl = true;
|
|
}
|
|
];
|
|
# nixos `services.nginx.virtualHosts.<name>` ssl attrs merged
|
|
# into each vhost. For ACME mode: `enableACME` + `addSSL` —
|
|
# NixOS's ACME integration manages the cert lifecycle and sets
|
|
# ssl_certificate automatically. For self-signed / certDir:
|
|
# explicit cert paths.
|
|
vhostTls =
|
|
if cfg.tls.acme.enable then
|
|
{
|
|
addSSL = true;
|
|
enableACME = true;
|
|
}
|
|
else
|
|
{
|
|
addSSL = true;
|
|
sslCertificate = tlsCert;
|
|
sslCertificateKey = tlsKey;
|
|
};
|
|
|
|
# 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}";
|
|
|
|
# Security headers added at the server scope on every vhost.
|
|
# nginx's add_header inheritance rule: a location that defines its
|
|
# 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 below. HTML-serving and proxy locations that carry no
|
|
# add_header of their own pick these up from the server scope
|
|
# automatically.
|
|
hstsDirectives = lib.concatStringsSep "; " (
|
|
[ "max-age=${toString cfg.hsts.maxAge}" ]
|
|
++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains"
|
|
);
|
|
securityHeaders = ''
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
|
|
'';
|
|
|
|
# Forge sub-domain vhost. `server_name = forge.domain`, proxies
|
|
# all `/` → forgejo. Tuned for git: `client_max_body_size 1G`,
|
|
# `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on
|
|
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
|
|
# forge isn't behind the gateway.
|
|
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
|
|
"${forgeCfg.domain}" = vhostTls // {
|
|
listen = vhostListen;
|
|
extraConfig = securityHeaders;
|
|
locations."/" = {
|
|
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
client_max_body_size 1G;
|
|
proxy_read_timeout 1h;
|
|
proxy_send_timeout 1h;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
# Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`.
|
|
# `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll
|
|
# timeout). `/` serves fluffychat or 404 if GUI off. nginx
|
|
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
|
|
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
|
|
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
|
|
"${matrixCfg.gatewayHost}" = vhostTls // {
|
|
listen = vhostListen;
|
|
extraConfig = securityHeaders;
|
|
locations = {
|
|
"/_matrix/" = {
|
|
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
client_max_body_size 50M;
|
|
proxy_read_timeout 1h;
|
|
proxy_send_timeout 1h;
|
|
${securityHeaders}
|
|
add_header Access-Control-Allow-Origin *;
|
|
'';
|
|
};
|
|
}
|
|
// lib.optionalAttrs (matrixCfg.gui.enable) (
|
|
{
|
|
# fluffychat at sub-domain root, SPA-fallback via
|
|
# the Accept-header `$matrix_spa_target` map.
|
|
"/" = {
|
|
alias = "${matrixCfg.gui.package}/";
|
|
extraConfig = ''
|
|
try_files $uri $uri/ $matrix_spa_target =404;
|
|
'';
|
|
};
|
|
}
|
|
// {
|
|
# FluffyChat boot-config pre-fill so the client's
|
|
# `.well-known/matrix/client` lookup hits the
|
|
# right delegation endpoint. `domain` is required, so
|
|
# this is always present.
|
|
"= /config.json" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
|
'';
|
|
};
|
|
}
|
|
)
|
|
// lib.optionalAttrs (!matrixCfg.gui.enable) {
|
|
"/" = {
|
|
return = "404";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
# `<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 `/run/hive-state/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 /run/hive-state/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
|
|
{
|
|
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
|
|
# "SPA fallback"): text/html → index.html, else a sentinel so
|
|
# try_files falls through to 404. The dashboard doesn't use an
|
|
# Accept-header map — it routes by path (see dashboardProxyLocation).
|
|
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
|
map $http_accept $matrix_spa_target {
|
|
default "/__matrix_spa_no_html_fallback";
|
|
"~*text/html" "/index.html";
|
|
}
|
|
'';
|
|
|
|
virtualHosts = {
|
|
"_" = vhostTls // {
|
|
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/hyperhive/gateway/agents.conf
|
|
# on the host. The bind-mount at /run/hive-state/ exposes
|
|
# that file here. nginx parses `include` at config-load
|
|
# time so a reload (triggered by c0re via systemd-run
|
|
# 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 /run/hive-state/agents.conf;
|
|
'';
|
|
};
|
|
}
|
|
// forgeVhost
|
|
// matrixVhost;
|
|
}
|