hyperhive/nix/modules/hive-gateway.nix
atlas df549ed2a5 nix/hive-{matrix,gateway}: host fluffychat at matrix.<hive>/, 301 from <hive>/matrix/ (#772)
mara on #764:9897: "host the fluffy chat app at / as follow up?"

Moves fluffychat-web from the bare-domain sub-path
(`<hive>/matrix/`) to the matrix sub-domain root
(`matrix.<hive>/`). Follow-up to #764 (matrix vhost itself), per
mara's gateway-architecture verdict (sub-domain for external standard
apps, sub-path for hyperhive-internal). Stacked on
`atlas/747-matrix-behind-gateway` — depends on #764 landing first.

## Mechanics

**hive-matrix.nix:**
- Drop `flutterBuildFlags = [ "--base-href" "/matrix/" ]` from
  `fluffychat-web-fixed`. Upstream default `--base-href "/"` is correct
  at sub-domain root.
- Update option docs to reflect new mount point.

**hive-gateway.nix:**
- `$matrix_spa_target` map target flips from `/matrix/index.html` →
  `/index.html` (sub-domain root now).
- New `<hive>/matrix/*` location: `rewrite ^/matrix/(.*)$
  matrix.<hive>/$1 permanent;` — 301 redirect preserves bookmark +
  deep-link compatibility for `<hive>/matrix/#/rooms/...` URLs during
  the transition.
- `<hive>/matrix/config.json` location removed (moved to `/config.json`
  on the matrix vhost).
- Matrix vhost (#764) gains `/` location: serves fluffychat dist as
  static files with the Accept-header SPA fallback (`/_matrix/`
  proxying to tuwunel keeps working via nginx longer-prefix-wins
  precedence). When `gui.enable = false`, `/` returns 404 cleanly.
- Matrix vhost gains `= /config.json` for the FluffyChat boot-config
  pre-fill (#736).

## Verified

```
vhosts:        ["_", "forge.test.local", "matrix.test.local"]
bare locations: ["/", "/matrix/", "= /.well-known/matrix/client",
                 "= /.well-known/matrix/server"]
matrix vhost locations: ["/", "/_matrix/", "= /config.json"]
/matrix/ extraConfig: "rewrite ^/matrix/(.*)$ http://matrix.test.local/$1 permanent;"
matrix vhost / alias: /nix/store/...fluffychat-web-2.6.0/
```

Full container toplevel builds clean.

## Risk

Medium. Two breaking changes for operators:

1. **Bookmark migration**: `http://<hive>/matrix/#/rooms/...` 301s
   to `http://matrix.<hive>/#/rooms/...`. Browser bookmarks +
   shared links keep working via the redirect; can be cleaned up
   once it's been in the wild long enough.

2. **fluffychat-web dist hash changes**: dropping the
   `--base-href "/matrix/"` flag changes the derivation hash, so
   `gui.package` rebuilds even though the source is the same.
   Operators on substitute caches will fetch the new dist; building
   from source takes the same time as before.

The `.well-known/matrix/{client,server}` delegation (already
advertising `matrix.<hive>` per #764) means matrix clients
auto-discover the new location — no client config change needed.

## Sequencing

**Depends on #764** — needs the matrix vhost to host the new `/`
location. Merge after #764 lands + soaks. If #764 changes shape
during review I'll rebase + force-push.

Closes #772.
2026-05-31 14:29:40 +02:00

576 lines
27 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). Target now points at the
# sub-domain-root `/index.html` (fluffychat moved off
# `<hive>/matrix/` sub-path to `matrix.<hive>/` root in
# #772; `--base-href` reverts to upstream-default `/`).
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 = {
"_" = {
listen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
];
locations =
# Bare-domain `<hive>/matrix/*` → 301 redirect to the
# matrix sub-domain root (#772). fluffychat-web used to
# live at this sub-path; #772 moved it to `matrix.<hive>/`
# so it gets full sub-domain origin isolation + sub-spec
# matches the matrix-spec deploy shape. The redirect
# preserves bookmark + deep-link compatibility for
# `<hive>/matrix/#/...` URLs during the transition;
# operators can drop the redirect block once it's been
# in the wild long enough that no stale bookmarks remain.
#
# Only emitted when both the matrix GUI is on AND
# `matrixCfg.gatewayHost` is set (else there's no
# canonical sub-domain to redirect to).
lib.optionalAttrs (
matrixCfg.enable
&& matrixCfg.gui.enable
&& matrixCfg.gatewayHost != null
) (
let
portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}";
target = "http://${matrixCfg.gatewayHost}${portSuffix}";
in
{
# `rewrite ^/matrix/(.*)$ → matrix.<hive>/$1` —
# strips the `/matrix/` prefix on the way out so
# `<hive>/matrix/#/rooms/...` lands at the right
# SPA route on the sub-domain side. `permanent`
# emits 301 + sets the canonical Location header.
"/matrix/" = {
extraConfig = ''
rewrite ^/matrix/(.*)$ ${target}/$1 permanent;
'';
};
}
)
//
# `.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) (
let
# `.well-known/matrix/{client,server}` advertise where
# the actual matrix API lives. When `matrixCfg.gatewayHost`
# is set (default `matrix.<hive-domain>`, #747), point
# at the sub-domain — no port suffix when the gateway
# is on the canonical port 80, transparent to clients
# (mara on #749:9609 sub-domain verdict, "not user-
# visible because the .well-known redirect routes
# clients through automatically"). When `gatewayHost`
# is unset (no hive-domain, or operator nulled it),
# fall back to the direct `host:port` shape — clients
# reach tuwunel without going through the gateway,
# no sub-domain delegation.
portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}";
clientBaseUrl =
if matrixCfg.gatewayHost != null then
"http://${matrixCfg.gatewayHost}${portSuffix}"
else
"http://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
serverHostPort =
if matrixCfg.gatewayHost != null then
"${matrixCfg.gatewayHost}${portSuffix}"
else
"${hyperhiveDomain}:${toString matrixCfg.httpPort}";
in
{
"= /.well-known/matrix/client" = {
extraConfig = ''
default_type application/json;
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}"}';
'';
};
}
)
//
# 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;
'';
};
};
}
//
# Matrix homeserver vhost (#747, mara verdict on #749:9609 —
# sub-domain over sub-path for matrix; "not user-visible"
# because clients discover the sub-domain via the
# `.well-known/matrix/{client,server}` delegation served
# above on the bare hive-domain).
#
# `server { server_name = matrixCfg.gatewayHost; }` proxies
# `/_matrix/...` → `http://127.0.0.1:''${matrixCfg.httpPort}/_matrix/...`.
# Tuwunel listens on `:''${httpPort}` (default 8008); the
# gateway terminates on `:''${cfg.port}` (80) so external
# clients speak matrix over the canonical web port without
# operators having to open the tuwunel port through firewalls.
#
# `/` returns 404 — nothing else lives at the matrix vhost;
# the matrix client-server API is entirely under `/_matrix/`,
# and federation under `/_matrix/federation/...`.
#
# CORS `*` on the matrix vhost per the matrix spec —
# federation + client requests come from any origin.
#
# `client_max_body_size 50M` covers typical media uploads
# (matrix-spec media size cap default); operators with bigger
# uploads override via the matrix module's own cap when that
# lands.
#
# `proxy_read_timeout 1h` for long-poll `/sync`; the default
# 60s would abort `/sync?timeout=30000` legitimately when
# tuwunel's keepalive exceeds that.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = {
listen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
];
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;
add_header Access-Control-Allow-Origin *;
'';
};
}
//
# Fluffychat-web (or override) served at sub-domain
# root (#772 — moved from bare-domain `/matrix/`).
# nginx location-precedence: longer prefix wins,
# so `/_matrix/` (above) handles the matrix API
# and `/` falls through to fluffychat for
# everything else.
#
# SPA fallback uses the same Accept-header `$matrix_spa_target`
# map from `appendHttpConfig` above — navigations
# fall to `/index.html` (now sub-domain-root path),
# asset misses return clean 404.
#
# `= /config.json` serves the FluffyChat boot-config
# pre-fill with the operator's hive-domain so the
# client's `.well-known/matrix/client` lookup hits
# the right delegation endpoint (#736).
lib.optionalAttrs (matrixCfg.gui.enable) (
{
"/" = {
alias = "${matrixCfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ $matrix_spa_target =404;
'';
};
}
// lib.optionalAttrs (hyperhiveDomain != null) {
"= /config.json" = {
extraConfig = ''
default_type application/json;
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
'';
};
}
)
//
# Fall-through `/` when GUI is off: nothing else
# lives at the matrix vhost, return 404 cleanly.
lib.optionalAttrs (!matrixCfg.gui.enable) {
"/" = {
return = "404";
};
};
};
};
};
};
};
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 via #749/#754, matrix via #747)
# 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>/` + `http://matrix.<hive-domain>/`
# resolving locally.
#
# `lib.unique` collapses any duplicate (e.g. if forge.domain
# happens to equal hyperhiveDomain or matrixCfg.gatewayHost) 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
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
);
};
};
}