mara on PR #754: "would it be better to specify full forge domain in options instead?" Drops the awkward `cfg.subdomain` label option. Now `cfg.domain` is the single source of truth for both the forgejo `DOMAIN` setting (existing semantics) AND the gateway vhost server-name (new). ## Before / after ```nix # before: separate label + cfg.domain juggling services.hyperhive.forge.subdomain = "forge"; # → forge.<hive> services.hyperhive.forge.domain = "localhost"; # unused for vhost # after: full domain, single option services.hyperhive.forge.domain = "forge.darkest.space"; # ← used for ROOT_URL + vhost ``` ## Default `cfg.domain` default auto-derives: - `forge.<services.hyperhive.domain>` when hive-domain is set - `"localhost"` otherwise (pre-#749 direct-on-port shape) So the common case (hive-domain set) gets `forge.<hive>` for free, operators with a bespoke shape (`git.example.com`) set the full hostname directly. ## Assertions - `cfg.domain != ""` — empty would render `.<hive>` shaped garbage in both server_name + /etc/hosts. - `cfg.behindGateway → gateway.enable` — can't route through a gateway that isn't running. (The previous "subdomain = empty" assertion is dropped — that edge case is gone with the rename.) ## Verified - default with `hyperhive.domain = "test.local"` → `forge.test.local`, `ROOT_URL = http://forge.test.local/`, vhost present - `forge.domain = "git.example.com"` → `git.example.com`, `ROOT_URL = http://git.example.com/`, vhost = `["_", "git.example.com"]` - `gateway.enable = false` → `forge.domain` falls back to `localhost`, `ROOT_URL = http://localhost:3000/`, no gateway vhost (`behindGateway = false`) - `/etc/hosts` (when `localHostsEntry = true`) → unique entries for hive-domain + forge.domain (de-duped via `lib.unique` for the edge case where forge.domain = hive-domain) - full container toplevel builds clean ## PR title (Will fix the PR title separately — still says "/forge/" which is wrong since the rewrite to sub-domain shape.)
475 lines
22 KiB
Nix
475 lines
22 KiB
Nix
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.gateway;
|
|
hyperhiveDomain = config.services.hyperhive.domain;
|
|
matrixCfg = config.services.hyperhive.matrix;
|
|
forgeCfg = config.services.hyperhive.forge;
|
|
|
|
# Per-agent port table for `/agent/<name>/` routing (#15 v0). Single-
|
|
# sourced from `cfg.agentPortsFile` (default
|
|
# `/var/lib/hyperhive/agent-ports.json`), written by hive-c0re on every
|
|
# topology change in shape `{ "<name>": <port>, ... }`.
|
|
#
|
|
# Read at deploy time via `builtins.fromJSON (builtins.readFile ...)`
|
|
# — pure eval (the file lives outside the nix store; nix copies the
|
|
# content into the store as a fixed-output dep). When the file is
|
|
# missing (fresh install before c0re has had a chance to write it),
|
|
# default to an empty map → no per-agent routes generated → gateway
|
|
# falls back to its pre-#15 shape. The container rebuilds on every
|
|
# `hivectl gateway-sync` (operator-initiated) or on the next
|
|
# `nixos-rebuild switch`, picking up whatever c0re has written
|
|
# since the last build.
|
|
#
|
|
# mara on #740 (comment 9295) + #15 (comment 9270): the gateway
|
|
# nginx container lives in system config (not meta), so it can't
|
|
# auto-rebuild from meta-flake events — the JSON file is what
|
|
# bridges the host's nix eval to the agent-lifecycle data c0re owns.
|
|
agentPortsTable =
|
|
if cfg.agentPortsFile == null || !builtins.pathExists cfg.agentPortsFile then
|
|
{ }
|
|
else
|
|
builtins.fromJSON (builtins.readFile cfg.agentPortsFile);
|
|
in
|
|
{
|
|
# Single nginx in front of every hyperhive surface (#609 / #15 v0).
|
|
# Lives in its own nixos-container (like hive-forge / hive-matrix) so
|
|
# the operator can opt out without touching the host's own nginx, and
|
|
# so the static-serve responsibility for the matrix GUI moves off
|
|
# hive-c0re's axum router. Shares host netns so `localhost`
|
|
# upstream resolution works without any port-forward dance.
|
|
#
|
|
# Routes (v0):
|
|
# `location /matrix/` → static-serve fluffychat-web dist (when
|
|
# `services.hyperhive.matrix.gui.enable` is true)
|
|
# `location /` → proxy_pass to hive-c0re's dashboard upstream
|
|
#
|
|
# Container name `hive-gateway` keeps hive-c0re's lifecycle scanner
|
|
# (which only sees `h-*`) out of the picture. State-free — nginx
|
|
# config lives in the nix store, no runtime persistence to manage.
|
|
|
|
options.services.hyperhive.gateway = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Run hive-gateway — a single nginx in front of every hyperhive
|
|
surface. On by default: the gateway hosts the matrix GUI static
|
|
dist (when `services.hyperhive.matrix.gui.enable` is true) and
|
|
proxies everything else to hive-c0re's dashboard upstream. Set
|
|
`services.hyperhive.gateway.enable = false` to bypass nginx
|
|
entirely and reach hive-c0re directly on its dashboard port
|
|
(7000 by default).
|
|
|
|
v0 is HTTP-only; TLS / public-domain shape is tracked
|
|
separately.
|
|
'';
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 80;
|
|
example = 8080;
|
|
description = ''
|
|
TCP port the gateway listens on. Default 80 (canonical web
|
|
port). nginx inside the container binds <1024 because the
|
|
container's init runs as root; if 80 is already taken on the
|
|
host (existing nginx, traefik, etc.) override to an unused
|
|
port like 8080 or move the conflicting service.
|
|
'';
|
|
};
|
|
|
|
upstreamHost = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "127.0.0.1";
|
|
description = ''
|
|
Host the gateway proxies non-static requests to. Defaults to
|
|
`127.0.0.1` because the gateway container shares the host
|
|
netns, so loopback resolves directly to hive-c0re.
|
|
'';
|
|
};
|
|
|
|
upstreamPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 7000;
|
|
description = ''
|
|
TCP port the gateway proxies non-static requests to. Defaults
|
|
to `7000` (hive-c0re's out-of-the-box dashboard port). Operators
|
|
who change `services.hyperhive.c0re.dashboardPort` should set
|
|
`upstreamPort` to match — kept as a hardcoded default rather
|
|
than a cross-reference to keep this module's options eval
|
|
independent of c0re's option tree shape.
|
|
'';
|
|
};
|
|
|
|
openFirewall = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Open `port` in the host firewall. Off by default (#651,
|
|
secure-by-default). Flip to `true` to expose the gateway to
|
|
the operator's browser / external clients — required for any
|
|
out-of-host reach, since the agents themselves talk to
|
|
hive-c0re via the per-agent unix sockets and don't need the
|
|
nginx vhost. Leave off when running behind another reverse
|
|
proxy (e.g. caddy / traefik on the host) that handles TLS
|
|
termination + forwards to `port`.
|
|
|
|
**Breaking change as of #651**: this used to default to
|
|
`true`. If you relied on the old default for external reach
|
|
(the common case — the gateway is the operator's primary
|
|
entry point), add `services.hyperhive.gateway.openFirewall = true;`
|
|
to your host config before rebuilding.
|
|
'';
|
|
};
|
|
|
|
localHostsEntry = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Add an `/etc/hosts` entry mapping `services.hyperhive.domain`
|
|
to `127.0.0.1` on the host. Useful for local deployments +
|
|
tests where there's no real DNS for `services.hyperhive.domain`
|
|
but the operator (or browser-based tests) want to hit
|
|
`http://''${services.hyperhive.domain}` to exercise the
|
|
gateway shape. Off by default — operators running with real
|
|
DNS shouldn't have a stale `/etc/hosts` entry sticking
|
|
around. Requires `services.hyperhive.domain` to be set.
|
|
'';
|
|
};
|
|
|
|
agentPortsFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = "/var/lib/hyperhive/agent-ports.json";
|
|
example = "/var/lib/hyperhive/agent-ports.json";
|
|
description = ''
|
|
Path to a JSON file mapping sub-agent names to their web ports
|
|
for `/agent/<name>/` routing through the gateway (#15 v0).
|
|
Shape: `{ "<name>": <port>, ... }`. Written by hive-c0re on
|
|
every topology change (the rust side knows the canonical port
|
|
allocation via `lifecycle::agent_web_port`; the gateway just
|
|
reads what it's told).
|
|
|
|
For each `<name>: <port>` entry, the gateway adds a
|
|
`location /agent/<name>/` block that `proxy_pass`es to
|
|
`http://127.0.0.1:<port>/`. Empty / missing file → no
|
|
per-agent routes generated → gateway falls back to its pre-#15
|
|
shape (just `/` + matrix surfaces).
|
|
|
|
**Purely additive**: the old `http://<host>:<port>/` direct
|
|
reach keeps working in parallel; this just gives the operator
|
|
a single-origin route. Manager isn't included in the map (no
|
|
per-agent prefix needed; manager already gets the `/` route
|
|
via the c0re upstream block).
|
|
|
|
Set to `null` to disable per-agent routing entirely without
|
|
creating the file. Set to a custom path if the operator's c0re
|
|
writes the table elsewhere.
|
|
|
|
**Rebuild trigger**: the gateway container picks up new entries
|
|
on the next `nixos-rebuild switch` (or `hivectl gateway-sync`
|
|
if that helper lands). c0re writes are not auto-applied to a
|
|
running gateway — see the follow-up in #15 for runtime nginx
|
|
include + reload + eventual per-agent unix sockets.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
|
|
message = ''
|
|
services.hyperhive.gateway.localHostsEntry = true requires
|
|
services.hyperhive.domain to be set. Either pin a hostname
|
|
or leave `localHostsEntry` at its default of false.
|
|
'';
|
|
}
|
|
];
|
|
|
|
containers.hive-gateway = {
|
|
autoStart = true;
|
|
ephemeral = false;
|
|
# Share host netns — nginx then binds host-level ports directly,
|
|
# `localhost` upstream resolution reaches hive-c0re without any
|
|
# port-forward dance, and the firewall config below is the only
|
|
# layer that matters.
|
|
privateNetwork = false;
|
|
config =
|
|
{ pkgs, ... }:
|
|
{
|
|
system.stateVersion = "26.05";
|
|
services.nginx = {
|
|
enable = true;
|
|
recommendedProxySettings = true;
|
|
recommendedOptimisation = true;
|
|
# SPA-fallback target keyed on the `Accept` request header
|
|
# (#686, mara + damocles on PR #729). This decides whether a
|
|
# `/matrix/...` miss falls through to `index.html` (route
|
|
# navigation) or returns a clean 404 (asset miss) — see the
|
|
# `/matrix/` location comment below for the full rationale.
|
|
#
|
|
# Top-frame browser navigations always send
|
|
# `Accept: text/html,...` (chrome/firefox/safari are
|
|
# consistent on this). Asset fetches from script tags / img
|
|
# / fetch() / XHR send asset-typed Accepts (`image/*`,
|
|
# `application/javascript`, `*/*`) without `text/html`.
|
|
# Mapping is purely on the header → no extension allowlist
|
|
# to keep in sync with whatever the SPA ships, no regex
|
|
# heuristic to false-positive on dot-segment routes.
|
|
#
|
|
# `$matrix_spa_target` defaults to a sentinel nonexistent
|
|
# path so `try_files` falls through to the trailing `=404`
|
|
# for asset misses. Browser navigations route to
|
|
# `/matrix/index.html` where the SPA's client-side router
|
|
# takes over.
|
|
#
|
|
# Only emitted when the matrix GUI is on (saves a no-op
|
|
# `map` directive otherwise).
|
|
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
|
map $http_accept $matrix_spa_target {
|
|
default "/__matrix_spa_no_html_fallback";
|
|
"~*text/html" "/matrix/index.html";
|
|
}
|
|
'';
|
|
virtualHosts = {
|
|
"_" = {
|
|
listen = [
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = cfg.port;
|
|
}
|
|
];
|
|
locations =
|
|
# Matrix GUI: when the operator has flipped both
|
|
# `services.hyperhive.matrix.enable` and `matrix.gui.enable`
|
|
# on, nginx serves fluffychat-web (or whatever override)
|
|
# as a static dist at `/matrix/`.
|
|
#
|
|
# SPA fallback (iris/#643, rewritten in #686 per mara
|
|
# + damocles on PR #729): the original
|
|
# `try_files $uri $uri/ /matrix/index.html;` shape
|
|
# silently masked missing assets — flutter's bootstrap
|
|
# requesting e.g. `/matrix/native_executor.js` got
|
|
# `index.html` (Content-Type: text/html, status 200)
|
|
# when the file was absent from the dist, so the JS
|
|
# runtime never loaded and `/matrix/` rendered blank
|
|
# without any visible error.
|
|
#
|
|
# The followup #729 narrowed it with an extension
|
|
# allowlist; this version uses `$matrix_spa_target`
|
|
# (defined in the `appendHttpConfig` above, keyed on
|
|
# the `Accept` header) so the decision lives in HTTP
|
|
# semantics rather than a maintained extension list.
|
|
# Navigations (Accept: text/html) fall to index.html;
|
|
# asset fetches (Accept: */*, image/*, etc.) get a
|
|
# clean 404 via the trailing `=404`.
|
|
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable) {
|
|
"/matrix/" = {
|
|
alias = "${matrixCfg.gui.package}/";
|
|
extraConfig = ''
|
|
try_files $uri $uri/ $matrix_spa_target =404;
|
|
'';
|
|
};
|
|
# FluffyChat fetches `/matrix/config.json` directly on
|
|
# boot for its own branding + default-homeserver
|
|
# bootstrap, BEFORE asking the user to pick a server
|
|
# (#736). The upstream `pkgs.fluffychat-web` dist
|
|
# ships without one, so the fetch 404s and the user
|
|
# sees the empty "enter homeserver" prompt. Serve a
|
|
# minimal config that pre-fills `defaultHomeserver`
|
|
# with the operator's hive domain — the matrix
|
|
# client then runs `.well-known/matrix/client` against
|
|
# that domain (already served by the
|
|
# `= /.well-known/matrix/client` block below) and
|
|
# discovers the actual tuwunel endpoint.
|
|
#
|
|
# Only the `defaultHomeserver` field is overridden —
|
|
# everything else (branding, audio defaults, etc.)
|
|
# falls back to fluffychat's hardcoded defaults so
|
|
# we don't pin against upstream config-schema drift.
|
|
# No-op when `services.hyperhive.domain` is unset
|
|
# (the location block is omitted entirely; the SPA
|
|
# then falls back to its empty form, same as before
|
|
# #736).
|
|
} // lib.optionalAttrs (
|
|
matrixCfg.enable && matrixCfg.gui.enable && hyperhiveDomain != null
|
|
) {
|
|
"= /matrix/config.json" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
|
'';
|
|
};
|
|
}
|
|
//
|
|
# `.well-known/matrix/*` auto-discovery (#660): when
|
|
# `services.hyperhive.matrix.enable` is on and the
|
|
# operator's set a hive domain, the gateway serves
|
|
# the matrix-spec discovery JSON at the canonical
|
|
# location so clients pointed at `${hyperhive.domain}`
|
|
# resolve through to the actual tuwunel endpoint
|
|
# without needing a `matrix.` subdomain.
|
|
#
|
|
# `m.homeserver.base_url` advertises the client-server
|
|
# API. `m.server` advertises the federation
|
|
# `host:port` (tuwunel serves both client + federation
|
|
# on the same `httpPort` — see hive-matrix.nix).
|
|
#
|
|
# CORS `*` on the client endpoint per the matrix spec
|
|
# (https://spec.matrix.org/v1.15/client-server-api/#getwell-knownmatrixclient).
|
|
# No-op until the operator turns matrix on; until then
|
|
# there's no homeserver to advertise.
|
|
lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) {
|
|
"= /.well-known/matrix/client" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
add_header Access-Control-Allow-Origin *;
|
|
return 200 '{"m.homeserver":{"base_url":"http://${hyperhiveDomain}:${toString matrixCfg.httpPort}"}}';
|
|
'';
|
|
};
|
|
"= /.well-known/matrix/server" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
return 200 '{"m.server":"${hyperhiveDomain}:${toString matrixCfg.httpPort}"}';
|
|
'';
|
|
};
|
|
}
|
|
//
|
|
# Per-agent UIs (#15 v0). One `/agent/<name>/`
|
|
# block per `<name>: <port>` entry in
|
|
# `agentPortsTable` (loaded from `cfg.agentPortsFile`
|
|
# — `/var/lib/hyperhive/agent-ports.json` by default,
|
|
# written by hive-c0re on every topology change).
|
|
#
|
|
# Trailing-slash pair (`/agent/<name>/` + `proxy_pass
|
|
# http://...:<port>/`) strips the `/agent/<name>`
|
|
# prefix on the upstream side, so the agent server
|
|
# receives `GET /` for the SPA root, `GET /api/state`
|
|
# for the API, `GET /screen/ws` for the websocket, etc.
|
|
# The agent's emitted asset URLs are document-relative
|
|
# (iris's #731) so they round-trip back through the
|
|
# gateway under the same prefix without the harness
|
|
# needing prefix-awareness.
|
|
#
|
|
# `X-Forwarded-Prefix` set so the harness can build
|
|
# correct absolute URLs for any case where relative
|
|
# isn't enough (server-emitted redirects, OG meta
|
|
# tags, etc.).
|
|
#
|
|
# SSE / websocket support via `proxyWebsockets = true`
|
|
# (same as the c0re `/` block below).
|
|
#
|
|
# Empty / missing `cfg.agentPortsFile` → empty table
|
|
# → no per-agent blocks; old `<host>:<port>/` direct
|
|
# reach still works.
|
|
lib.mapAttrs' (name: port: {
|
|
name = "/agent/${name}/";
|
|
value = {
|
|
proxyPass = "http://127.0.0.1:${toString port}/";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_set_header X-Forwarded-Prefix /agent/${name};
|
|
proxy_buffering off;
|
|
proxy_read_timeout 1d;
|
|
'';
|
|
};
|
|
}) agentPortsTable
|
|
// {
|
|
# Everything else proxies to hive-c0re. Upgrade
|
|
# headers stay set so SSE (`/dashboard/stream`,
|
|
# `/events/stream`) + websocket (`/screen/ws`)
|
|
# endpoints keep working transparently.
|
|
"/" = {
|
|
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
proxy_read_timeout 1d;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
}
|
|
//
|
|
# Forge vhost (#749, mara verdict at issue:9609 —
|
|
# sub-domain over sub-path). When forgejo runs behind the
|
|
# gateway (`forge.behindGateway = true`), it gets its own
|
|
# `server { server_name = forge.domain; }` block. The
|
|
# block proxies all `/` → `http://127.0.0.1:<forge.httpPort>/`
|
|
# so forgejo handles requests at root (default deploy shape
|
|
# — no `ROOT_URL`-prefix translation needed).
|
|
#
|
|
# `forge.domain` is the full hostname (e.g.
|
|
# `forge.darkest.space`, `git.example.com`) — single source
|
|
# of truth for both the forgejo `DOMAIN` setting and the
|
|
# gateway vhost name (mara on #754:9684 — "specify full
|
|
# forge domain in options instead").
|
|
#
|
|
# `client_max_body_size 1G` — git pushes + LFS uploads can
|
|
# be large; nginx's default 1M would 413 most real commits.
|
|
#
|
|
# Long timeouts for big repo operations: a fresh clone of a
|
|
# multi-GB repo can take minutes; the default 60s
|
|
# `proxy_read_timeout` would abort mid-stream.
|
|
#
|
|
# `proxyWebsockets = true` keeps forgejo's live-update
|
|
# endpoints (`/api/v1/events`) + any future websocket
|
|
# endpoints working transparently. SSH stays direct on
|
|
# `forge.sshPort` (separate listener protocol, not HTTP).
|
|
lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) {
|
|
"${forgeCfg.domain}" = {
|
|
listen = [
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = cfg.port;
|
|
}
|
|
];
|
|
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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
|
allowedTCPPorts = [ cfg.port ];
|
|
};
|
|
|
|
# `/etc/hosts` entries for local dev: the bare hive domain plus
|
|
# any sub-domain modules (forge, matrix-via-#751) that are on.
|
|
# All map to `127.0.0.1` since the gateway shares host netns.
|
|
# Operators with real DNS leave `localHostsEntry = false`; this
|
|
# is the dev-loop shortcut for `http://<hive-domain>/` +
|
|
# `http://forge.<hive-domain>/` resolving locally.
|
|
#
|
|
# Forge's `cfg.domain` may equal `hyperhiveDomain` (e.g. operator
|
|
# set `forge.domain = "darkest.space"` matching the hive domain)
|
|
# — `lib.unique` collapses the duplicate so `/etc/hosts` doesn't
|
|
# carry the same entry twice.
|
|
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
|
|
"127.0.0.1" = lib.unique (
|
|
[ hyperhiveDomain ]
|
|
++ lib.optional (
|
|
(config.services.hyperhive.forge.enable or false)
|
|
&& (config.services.hyperhive.forge.behindGateway or false)
|
|
) config.services.hyperhive.forge.domain
|
|
);
|
|
};
|
|
};
|
|
}
|