Compare commits

...
Author SHA1 Message Date
atlas
b106bb8c87 nix/hive-matrix: argus 🟡 on #764 — empty-gatewayHost assertion + federation port doc
Folds both 🟡 notes from argus's #764 review:

1. **Empty-string assertion on `cfg.gatewayHost`**: same footgun as
   the forge.domain rejection from #754 — empty would render `.<hive>`
   shaped garbage in both nginx server_name (wildcard catch-all,
   surprising) and /etc/hosts (invalid entry). Fail loud at toplevel
   build with a message pointing at `null` as the right opt-out.

2. **Federation port-8448 caveat in `gatewayHost` docs**: when the
   gateway listens on 80, `.well-known/matrix/server` advertises
   `${gatewayHost}` with no port suffix → matrix federation spec
   falls back to port 8448 → no listener on 8448 → cross-hive
   federation requires either `_matrix._tcp.${gatewayHost}` SRV
   record OR `services.hyperhive.matrix.openFirewall = true`.
   Hyperhive is mostly closed/internal so this rarely bites, but
   the option docs now flag it for the federation-curious operator.

Verified: `gatewayHost = ""` triggers the new assertion at toplevel
build with the expected message; default still resolves to
`matrix.<hive>` cleanly.
2026-05-31 14:08:40 +02:00
atlas
01cc664d76 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.
2026-05-31 14:08:40 +02:00
2 changed files with 180 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,63 @@ 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.
**Federation port caveat**: the `.well-known/matrix/server`
delegation advertises `''${gatewayHost}` with no port suffix
when the gateway listens on 80. Per the matrix federation
spec, peers fall back to port 8448 when no explicit port is
present but the gateway only listens on the configured
`services.hyperhive.gateway.port`. Cross-hive federation
therefore needs either:
- a DNS SRV record (`_matrix._tcp.''${gatewayHost}` port 80),
- or `services.hyperhive.matrix.openFirewall = true` so peers
can reach tuwunel's federation port directly.
Hyperhive is mostly closed/internal, so this rarely bites in
practice but flagging for the federation-curious operator.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
@ -395,6 +452,22 @@ in
stable hostname before enabling.
'';
}
{
# Same footgun as forge.domain (#754): empty string renders
# `.<hive>` shaped garbage in both nginx server_name (treated
# as wildcard catch-all, surprising) and /etc/hosts (invalid
# entry). Argus 🟡 on #764 — fail loud here rather than ship
# the surprising behaviour.
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
message = ''
services.hyperhive.matrix.gatewayHost = "" is rejected. The
rendered URLs would be invalid (nginx wildcard catch-all for
an empty server_name, /etc/hosts rejects empty entries).
Use `null` to disable the gateway vhost entirely (tuwunel
stays direct on httpPort), or set a non-empty hostname like
"matrix.example.com" or "homeserver.internal".
'';
}
];
# Generate the registration token at system activation time, BEFORE