nix/hive-{gateway,matrix}: matrix vhost at matrix.<hive-domain> + .well-known delegation (#747)

mara on #747:9722: "this still seems to be an issue in current version"
(after #751 closed without merge). Mirroring the forge sub-domain
pattern just merged as #754 for matrix per mara's #749:9609 verdict
(sub-domain over sub-path for forge + matrix, "not user-visible for
matrix because the .well-known/matrix/{client,server} redirect routes
clients through automatically").

## Mechanics

**New `services.hyperhive.matrix.gatewayHost`** — nullable str, defaults
to `matrix.<services.hyperhive.domain>` when hive-domain set, else
null. Full hostname (`matrix.darkest.space`, `homeserver.internal.lan`)
for bespoke shapes per mara's #754:9684 "specify full domain in
options instead" pattern.

**Gateway:** new `server { server_name = matrixCfg.gatewayHost; }`
block proxying `/_matrix/...` → `http://127.0.0.1:<httpPort>/_matrix/...`
with matrix-spec CORS + tuned for long-poll `/sync` (1h timeout) +
typical media uploads (50M body cap). `/` returns 404 — nothing
else lives at the matrix vhost. Matches the forge vhost shape from #754.

**`.well-known/matrix/{client,server}`** (already served at bare hive-
domain since #660): now points at `matrixCfg.gatewayHost` (no port
suffix when gateway is on the canonical port 80) instead of the
direct `<hive-domain>:<httpPort>` shape. Falls back to direct shape
when `gatewayHost = null` (no hive-domain, or operator nulled it).

**`localHostsEntry` extension**: `/etc/hosts` (when set) now adds the
matrix sub-domain → 127.0.0.1 alongside hive-domain + forge.domain.
`lib.unique` collapses any duplicate (edge case if operator sets
gatewayHost equal to hive-domain).

## Verified via `nix eval`

```
vhosts:         ["_", "forge.test.local", "matrix.test.local"]
gatewayHost:    "matrix.test.local"
client wellknown: m.homeserver.base_url = "http://matrix.test.local"
server wellknown: m.server = "matrix.test.local"
/etc/hosts:     ["test.local", "forge.test.local", "matrix.test.local"]
```

## What this fixes for #747

mara's HAR showed `GET /.well-known/matrix/client` and
`GET /_matrix/client/versions` both failing on `pr1ma.darkest.space`:

1. **`.well-known/matrix/client`** was advertising
   `http://pr1ma.darkest.space:8008` — that URL only works if tuwunel's
   port 8008 is firewall-open to the operator's browser (it isn't by
   default — `services.hyperhive.matrix.openFirewall` defaults to false
   since #651). Now advertises `http://matrix.pr1ma.darkest.space/`
   which goes through the gateway on the (already-open) port 80.

2. **`/_matrix/client/versions`** was hitting the bare-domain `"_"`
   vhost, which has no `/_matrix/` location — fell through to `/` →
   c0re's dashboard upstream → 404. Now hits the new `matrix.<hive>`
   vhost which proxies the request to tuwunel cleanly.

server_name + serverName unaffected — matrix identifiers (`@alice:<hive>`)
still embed the bare hive-domain per #660; only the wire-level transport
URL moves to the sub-domain.

## Risk

Medium. Existing matrix tokens / sessions stay valid because:
- `serverName` (the identifier domain) doesn't change
- tuwunel's `/_matrix/` endpoints serve the same requests, just reached
  via the new sub-domain instead of the direct port

Operators with `services.hyperhive.matrix.openFirewall = true` and
external clients reaching `:8008` directly keep working too — the
sub-domain vhost is additive, doesn't take away the direct port.

## Sequencing

This is a parallel matrix-side mirror of #754 (forge). Both follow
the same mara-verdict pattern; once both have soaked, the gateway-
behind-everything story is done for v0.

Closes #747.
This commit is contained in:
atlas 2026-05-31 13:44:30 +02:00 committed by mara
commit 01cc664d76
2 changed files with 151 additions and 24 deletions

View file

@ -326,21 +326,48 @@ in
# (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}"}';
'';
};
}
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
@ -442,6 +469,61 @@ in
'';
};
};
}
//
# 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 *;
'';
};
"/" = {
return = "404";
};
};
};
};
};
};
@ -452,16 +534,16 @@ in
};
# `/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.
# 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.
#
# 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.
# `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 ]
@ -469,6 +551,7 @@ in
(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
);
};
};

View file

@ -252,6 +252,50 @@ in
'';
};
gatewayHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null;
defaultText = lib.literalExpression ''
if services.hyperhive.domain != null then
"matrix.''${services.hyperhive.domain}"
else
null
'';
example = "matrix.example.com";
description = ''
Public hostname for the matrix homeserver behind the
hive-gateway nginx (#747, mara verdict on #749:9609 — sub-domain
over sub-path for matrix, but **not user-visible** because the
`.well-known/matrix/{client,server}` redirect routes clients
through automatically).
When set + gateway is on, the gateway adds a `server { server_name
= gatewayHost; }` block that proxies `/_matrix/...`
`http://127.0.0.1:''${httpPort}/_matrix/...`. The
`.well-known/matrix/{client,server}` endpoints (served by the
gateway at the bare hive-domain) then point at
`http(s)://''${gatewayHost}/` matrix clients automatically
discover + follow that delegation.
Defaults to `matrix.''${services.hyperhive.domain}` when the
hive-domain is set (idiomatic matrix-spec shape `matrix`
labelled under the hive's bare server_name domain). Defaults to
`null` when the hive-domain is unset (gateway vhost not added;
clients reach tuwunel directly on `httpPort`).
Set to a full hostname (`matrix.example.com`,
`homeserver.internal.lan`) for a bespoke vhost shape. Set to
`null` to disable the gateway vhost entirely (tuwunel stays
direct on `httpPort`).
**server_name vs gatewayHost**: `serverName` is the matrix
identifier domain embedded in user/room IDs irrevocably (per
#660 default = bare hive-domain). `gatewayHost` is just where
the API listens behind nginx. The two are different see the
matrix-spec server-discovery flow.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;