hyperhive/nix/modules/hive-gateway.nix
atlas 3164d8cec3 nix/matrix+gateway: server_name defaults to hive domain + .well-known routes (#660)
mara on #660: "Matrix domain should default to hive domain if not
set otherwise / redirect matrix clients with .well-known"

Two coupled changes:

1. `services.hyperhive.matrix.serverName` default flipped from
   `matrix.${services.hyperhive.domain}` (subdomain) to just
   `${services.hyperhive.domain}` (bare hive domain).

   This is a "for new deploys only" change — `server_name` is
   embedded irrevocably in every user/room ID, so existing
   homeservers must set `serverName` explicitly to preserve the
   subdomain shape if that's where their identifiers were minted.
   Description updated to point at the .well-known piece below.

2. `hive-gateway` nginx now serves matrix-spec `.well-known`
   auto-discovery JSON at the canonical location when matrix is
   enabled + hive domain set:

       GET /.well-known/matrix/client
           {"m.homeserver":{"base_url":"http://<domain>:<httpPort>"}}
           + Access-Control-Allow-Origin: *  (per matrix spec)

       GET /.well-known/matrix/server
           {"m.server":"<domain>:<httpPort>"}

   tuwunel serves both client + federation on the same `httpPort`
   (see hive-matrix.nix), so both records point at the same
   endpoint. No-op when matrix isn't enabled or hive domain isn't
   set — nothing to advertise.

Combined effect: with `services.hyperhive.domain = "darkest.space"` +
matrix enabled, a matrix client pointed at `darkest.space` resolves
through `.well-known` to the actual `:8008` endpoint, no subdomain
needed. MXIDs become `@atlas:darkest.space` (was: `@atlas:matrix.darkest.space`).

Verified via `nix eval`:
- server_name = "darkest.space" (was "matrix.darkest.space")
- gateway locations include `= /.well-known/matrix/client` + `= /.well-known/matrix/server`
- well-known/matrix/client returns the spec-shaped JSON

Caveat: `m.homeserver.base_url` advertises HTTP (no TLS yet —
follow-up). matrix clients increasingly require HTTPS for new
account creation, so the v0 setup works for local-network testing
but won't satisfy public clients until the gateway TLS story lands.

Closes #660.
2026-05-30 21:05:10 +02:00

231 lines
9.2 KiB
Nix

{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
matrixCfg = config.services.hyperhive.matrix;
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.
'';
};
};
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;
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/`. fluffychat is a SPA
# — fall back to its index.html on deep links.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable) {
"/matrix/" = {
alias = "${matrixCfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ /matrix/index.html;
'';
};
}
//
# `.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}"}';
'';
};
}
// {
# 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;
'';
};
};
};
};
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
"127.0.0.1" = [ hyperhiveDomain ];
};
};
}