The catch-all, the dashboard and the hive-domain vhost were built in vhosts.nix with the listen set, TLS attrs and headers applied by hand. They are declared under services.hyperhive.gateway.vhosts now, so every vhost behind the gateway goes through declared-vhosts.nix. Two gateway-side rules cover what the catch-all needed: a vhost can set securityHeaders = false (the catch-all only returns 444), and `default` is rendered at mkDefault priority so an operator's own default vhost still wins. The matrix discovery locations are unchanged. The rendered nginx vhosts are unchanged.
311 lines
13 KiB
Nix
311 lines
13 KiB
Nix
# 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 `<hive>/matrix/*`
|
|
# redirect and a redirect of everything else to the dashboard's name.
|
|
#
|
|
# 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 }`.
|
|
{
|
|
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
|
|
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
|
|
# always `https://<host>` (matrix-spec compliance) — the canonical
|
|
# 443 elides the port. See `docs/networking/gateway.md` ("Self-signed TLS").
|
|
publicScheme = "https";
|
|
publicPort = cfg.httpsPort;
|
|
publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}";
|
|
|
|
# `<hive>/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.<swarm-domain>`
|
|
# 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
|
|
# <gatewayHost>: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/<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;
|
|
'';
|
|
};
|
|
};
|
|
|
|
# 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://${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.
|
|
#
|
|
# 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
|
|
# H0M3 hub's own `/api/docs` link (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/<name>/` 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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
}
|