From 2e7d49bb886bd8be00b2e32c81da1c9a27c231bd Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 25 Oct 2026 11:13:22 +0100 Subject: [PATCH] serve matrix discovery at the swarm apex, keep the pinned hive case --- docs/integrations/matrix.md | 10 +- docs/networking/gateway.md | 9 +- docs/swarm/README.md | 2 +- .../glue-matrix-hive-discovery.nix | 112 +++++++++--------- nix/host-modules/hive-matrix-service.nix | 2 +- nix/host-modules/hive-matrix.nix | 9 +- nix/host-modules/swarm-ui.nix | 29 +++++ nix/module-eval/core-toggle.nix | 10 +- nix/module-eval/matrix-core.nix | 64 +++++++--- nix/module-eval/swarm-services-switch.nix | 23 +++- 10 files changed, 187 insertions(+), 83 deletions(-) diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index de4601b4..e358462f 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -54,7 +54,9 @@ homeserver. ⚠️ **they're still not interchangeable, and the difference is the cost of changing one.** `gatewayHost` is a routing detail clients rediscover through `.well-known`, so it's safe to move on a running -deployment. The matrix id format bakes `serverName` into every user and +deployment. The controller's host serves that `.well-known` on the swarm +domain, and a homeserver pinned to a hive domain gets it on that hive's +vhost. The matrix id format bakes `serverName` into every user and room id, so adopting a new one does **not** rename the existing users and rooms — it strands them, because their ids still name a homeserver that no longer answers. @@ -78,6 +80,10 @@ A rebuild on a host that already has a homeserver prints a naming the value it's about to default to. It never fails the rebuild, so pin the value before the homeserver mints any ids under the default. +A pinned hive domain answers `.well-known/matrix/*` itself, built from +`gatewayHost`, on any host that runs that hive's gateway. It doesn't need +the homeserver on the same host. + ## Reach @@ -98,7 +104,7 @@ homeserver it crosses the network, so the default is TLS; hive-c0re verifies the gateway's certificate against the hive trust bundle. Federation peers find the homeserver through `.well-known/matrix/server` -on the hive domain, which names `:443` +at the server name, which names `:443` ([`gateway.md::Discovery flow`](../networking/gateway.md#discovery-flow-matrix)). Port 8448 isn't used. diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index 613a0379..2566e908 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -33,7 +33,8 @@ A service module declares its vhost as `services.hyperhive.gateway.vhosts. | `.hives./health/` | hive-c0re (`7000`) | always | | `.hives./api/docs/` | themed Swagger UI dist (static), behind authelia | always | | `.hives./agent//` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | -| `/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` | +| `/.well-known/matrix/{client,server}` | inline JSON | `deploy.swarm-controller.enable`, and `swarm.matrix.serverName` is the swarm domain (the default) | +| `/.well-known/matrix/{client,server}` | inline JSON | `swarm.matrix.serverName` is this hive's domain (a pinned homeserver) | | `/matrix/` | 301 → `chat./` | `deploy.matrix.enable` and `gatewayHost` set | | `/` (every other path) | 301 → `.hives.`, same path | always | @@ -208,9 +209,11 @@ When enabled, every vhost adds `Strict-Transport-Security: max-age=…[; include ## Discovery flow (matrix) -The operator points a client at ``: +The server name's own vhost serves discovery, whichever host runs the homeserver. By default that vhost is the swarm apex on the controller's host; a homeserver pinned to a hive domain gets it on that hive's vhost instead. Both build the JSON from `swarm.matrix.gatewayHost` alone, and the `base_url` carries no port. -1. The client fetches `https:///.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat."}}`. +The operator points a client at the server name (`` below): + +1. The client fetches `https:///.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat."}}`. 2. It connects to `chat./_matrix/client/...`. 3. The gateway proxies `/_matrix/*` to tuwunel over its unix socket, `/run/hive-matrix/tuwunel.sock`. diff --git a/docs/swarm/README.md b/docs/swarm/README.md index e9c4666c..cef2c113 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -553,7 +553,7 @@ mechanism. 2. **Matrix federation** — when this host runs the homeserver (`deploy.matrix.enable`), tuwunel federates with the peer's matrix server (discovered via the peer's - `.well-known/matrix/server` delegation, which the gateway serves). + `.well-known/matrix/server` delegation, which the controller's host or the pinned hive's gateway serves). Federation validates the peer's TLS certificate against the matrix **container's** trust bundle, independent of this directory. diff --git a/nix/host-modules/glue-matrix-hive-discovery.nix b/nix/host-modules/glue-matrix-hive-discovery.nix index 12c78ae8..1f33d4b6 100644 --- a/nix/host-modules/glue-matrix-hive-discovery.nix +++ b/nix/host-modules/glue-matrix-hive-discovery.nix @@ -1,16 +1,17 @@ -# Glue: serve the swarm homeserver's discovery on the hive domain's vhost -# wherever both run. +# Glue: matrix's links on the hive domain's vhost. # # ONE PAIRING PER FILE — matrix → hive domain vhost, and nothing else. -# Deleting this leaves federation and clients with no `.well-known/matrix/*` -# answer at the server name, and the legacy `/matrix/*` links 404. # -# The locations sit on the hive domain because the matrix spec reads -# delegation off the *server name*: it is the hive answering "where is my -# homeserver", not the homeserver answering for itself (./hive-matrix.nix's -# vhost on `gatewayHost` carries the rest). +# Two pieces, each under its own condition: +# - `.well-known/matrix/{client,server}` is served here only when the hive +# domain IS the homeserver's server name (a homeserver that pinned +# `swarm.matrix.serverName` to it): the spec reads delegation off the +# *server name*. The host need not run the homeserver, since the JSON is +# built from `swarm.matrix.gatewayHost` alone. For the default server name +# (the swarm domain) the swarm UI's apex vhost answers (./swarm-ui.nix). +# - `/matrix/*` redirects to the homeserver where the homeserver runs. # -# ⚠️ The condition repeats the one ./hive-c0re/vhosts.nix declares the hive +# ⚠️ Both conditions repeat the one ./hive-c0re/vhosts.nix declares the hive # domain vhost under. With it looser, this file alone would declare that # vhost, with discovery and no `/` redirect. { @@ -24,53 +25,58 @@ let matrixCfg = hyperhiveCfg.swarm.matrix; homeserverUrl = "https://${matrixCfg.gatewayHost}"; + + onHiveVhost = + matrixCfg.gatewayHost != null + && hyperhiveCfg.deploy.hive-controller.enable + && gatewayCfg.enable + && hyperhiveCfg.swarm.dashboardDomain != null; + + # Same default as `effectiveServerName` in ./hive-matrix.nix. + serverName = + if matrixCfg.serverName != null then matrixCfg.serverName else hyperhiveCfg.swarm.domain; in { - config = - lib.mkIf - ( - hyperhiveCfg.deploy.matrix.enable - && matrixCfg.gatewayHost != null - && hyperhiveCfg.deploy.hive-controller.enable - && gatewayCfg.enable - && hyperhiveCfg.swarm.dashboardDomain != null - ) - { - services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations = { - # `/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.` - # by default; legacy deep-link shim for the fluffychat sub-domain). - # See `docs/networking/gateway.md`. - "/matrix/" = { - extraConfig = '' - rewrite ^/matrix/(.*)$ ${homeserverUrl}/$1 permanent; - ''; - }; + config = lib.mkMerge [ + (lib.mkIf (onHiveVhost && hyperhiveCfg.deploy.matrix.enable) { + # `/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.` + # by default; legacy deep-link shim for the fluffychat sub-domain). + # See `docs/networking/gateway.md`. + services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations."/matrix/" = { + extraConfig = '' + rewrite ^/matrix/(.*)$ ${homeserverUrl}/$1 permanent; + ''; + }; + }) - # `.well-known/matrix/{client,server}` discovery JSON, pointing - # clients and peers at `gatewayHost`: the homeserver has no other - # address. CORS `*` per matrix spec. See `docs/networking/gateway.md`. - "= /.well-known/matrix/client" = { - extraConfig = '' - default_type application/json; - ${gatewayCfg.lib.securityHeaders} - add_header Access-Control-Allow-Origin *; - return 200 '{"m.homeserver":{"base_url":"${homeserverUrl}"}}'; - ''; - }; - # `m.server` is NOT a URL: per the matrix server-server spec - # (Resolving Server Names) a delegated host with NO port resolves - # to the federation default 8448 (after the SRV check) — the - # https-implies-443 rule does NOT apply here. So the port must be - # explicit even when it's the HTTPS default, unlike the client - # base_url above. Without it, peers federate to :8448 - # (closed) while the endpoint actually lives on the gateway's 443 - # vhost. See docs/networking/gateway.md discovery flow. - "= /.well-known/matrix/server" = { - extraConfig = '' - default_type application/json; - return 200 '{"m.server":"${matrixCfg.gatewayHost}:443"}'; - ''; - }; + (lib.mkIf (onHiveVhost && hyperhiveCfg.domain == serverName) { + services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations = { + # `.well-known/matrix/{client,server}` discovery JSON, pointing + # clients and peers at `gatewayHost`: the homeserver has no other + # address. CORS `*` per matrix spec. See `docs/networking/gateway.md`. + "= /.well-known/matrix/client" = { + extraConfig = '' + default_type application/json; + ${gatewayCfg.lib.securityHeaders} + add_header Access-Control-Allow-Origin *; + return 200 '{"m.homeserver":{"base_url":"${homeserverUrl}"}}'; + ''; + }; + # `m.server` is NOT a URL: per the matrix server-server spec + # (Resolving Server Names) a delegated host with NO port resolves + # to the federation default 8448 (after the SRV check) — the + # https-implies-443 rule does NOT apply here. So the port must be + # explicit even when it's the HTTPS default, unlike the client + # base_url above. Without it, peers federate to :8448 + # (closed) while the endpoint actually lives on the gateway's 443 + # vhost. See docs/networking/gateway.md discovery flow. + "= /.well-known/matrix/server" = { + extraConfig = '' + default_type application/json; + return 200 '{"m.server":"${matrixCfg.gatewayHost}:443"}'; + ''; }; }; + }) + ]; } diff --git a/nix/host-modules/hive-matrix-service.nix b/nix/host-modules/hive-matrix-service.nix index 52301cb6..a2340839 100644 --- a/nix/host-modules/hive-matrix-service.nix +++ b/nix/host-modules/hive-matrix-service.nix @@ -28,7 +28,7 @@ in identity to a single hive's domain would make relocating the container between hives look like a different homeserver. Combined with the `.well-known/matrix/{client,server}` routes - the gateway serves at that domain, clients auto-discover the + the controller's host serves at that domain, clients auto-discover the actual matrix endpoint without needing a subdomain. Override here only if you need a different server_name shape (e.g. `chat.example.org` for a bespoke hostname). diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index d998967f..3adc511b 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -454,11 +454,10 @@ in # longest-prefix rule puts `/_matrix/` # ahead of `/` with no ordering needed. # - # ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here. - # It stays on the hive's own vhost because the spec requires it to - # be served at the *server name*, which is the hive domain — it is - # the hive answering "where is my homeserver", not the homeserver - # answering for itself. + # ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here: + # the spec requires it at the *server name*. It is served by the swarm + # apex (./swarm-ui.nix) for the default server name, or by the hive + # domain's vhost (./glue-matrix-hive-discovery.nix) for a pinned one. services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) { "${cfg.gatewayHost}" = { locations = { diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 7d74cd37..738cb113 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -64,6 +64,16 @@ let # rather than a coercion error from here — same idiom as # `swarm-otel-service.nix`'s `domainBase`. apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain; + + # Matrix discovery is answered at the server name, so the apex serves it when + # that is the swarm domain (the default). A homeserver pinned to a hive + # domain is answered by that hive's vhost (./glue-matrix-hive-discovery.nix). + # Built from `gatewayHost` alone, so it does not matter which host runs the + # homeserver. The URLs carry no port: `gateway.httpsPort` only sets what the + # gateway listens on. + matrixCfg = swarmCfg.matrix; + serverName = if matrixCfg.serverName != null then matrixCfg.serverName else apex; + servesMatrixDiscovery = matrixCfg.gatewayHost != null && serverName == apex; in { # No `enable` of its own: the UI is on wherever @@ -196,6 +206,25 @@ in extraConfig = swarmAuthRequest; }; } + // lib.optionalAttrs servesMatrixDiscovery { + # Same JSON, headers and `:443` as the hive-domain copy in + # ./glue-matrix-hive-discovery.nix (see there for why `m.server` names + # the port). No `auth_request`: peers and clients fetch these unauthenticated. + "= /.well-known/matrix/client" = { + extraConfig = '' + default_type application/json; + ${gatewayCfg.lib.securityHeaders} + add_header Access-Control-Allow-Origin *; + return 200 '{"m.homeserver":{"base_url":"https://${matrixCfg.gatewayHost}"}}'; + ''; + }; + "= /.well-known/matrix/server" = { + extraConfig = '' + default_type application/json; + return 200 '{"m.server":"${matrixCfg.gatewayHost}:443"}'; + ''; + }; + } // { # swarm-controller's whole HTTP surface, including the live # `/api/openapi.json` spec — proxied untouched (no URI segment diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index ebcc733e..ac342de9 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -662,9 +662,15 @@ let ]; } { - name = "a hive with matrix off serves no matrix discovery endpoint"; + # The hive domain answers discovery only for a server name pinned to it, + # which `bare` does not do. Its vhost exists (the redirect is there), so + # the absence arms cannot pass on a misspelled name. + name = "an unpinned hive serves no matrix discovery on its domain"; ok = - !(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations); + let + l = bare.services.nginx.virtualHosts."h1.t.local".locations; + in + l ? "/" && !(l ? "= /.well-known/matrix/client") && !(l ? "= /.well-known/matrix/server"); } { # main got eval-borked twice by this exact class of bug (once on the diff --git a/nix/module-eval/matrix-core.nix b/nix/module-eval/matrix-core.nix index c45f0e90..f0c31209 100644 --- a/nix/module-eval/matrix-core.nix +++ b/nix/module-eval/matrix-core.nix @@ -19,6 +19,7 @@ let ; }) hive + hiveWithController runGroup ; @@ -36,6 +37,16 @@ let # A hive host that does not run the homeserver: its hive-c0re reaches the # homeserver across the network. matrixElsewhere = shared.bareWithController; + + # A homeserver whose server name was pinned to the hive domain, on the + # controller's host, with no homeserver of its own: the discovery answer + # sits on the hive domain and needs neither the homeserver nor the apex. + matrixPinned = hiveWithController { + swarm.matrix.serverName = "h1.t.local"; + deploy.swarm-controller.enable = true; + deploy.swarm-ui.package = pkgs.emptyDirectory; + }; + cases = [ { # Two limits governed a matrix upload and nothing kept them in agreement, @@ -129,32 +140,55 @@ let == "https://chat.t.local"; } { - # The discovery locations reach the hive domain's vhost from a glue - # module, merged into the vhost hive-c0re declares. The matrix-elsewhere - # arm reads the same vhost, which still exists there with its redirect, - # so its absence of discovery cannot pass on a misspelled name. The - # fixture's gateway is on a non-default TLS port, which none of the URLs - # carry. - name = "matrix discovery sits on the hive domain's vhost only where the homeserver runs"; + # The legacy redirect reaches the hive domain's vhost from a glue + # module, merged into the vhost hive-c0re declares, and only where the + # homeserver runs. The matrix-elsewhere arm reads the same vhost, which + # still exists there with its redirect, so its absence of the redirect + # cannot pass on a misspelled name. Under the default server name (the + # swarm domain) the hive domain answers no discovery on either host. + # The fixture's gateway is on a non-default TLS port, which none of the + # URLs carry. + name = "an unpinned hive domain's vhost carries the matrix redirect where the homeserver runs, and no discovery"; ok = let here = matrixNoBaoIdentity.services.nginx.virtualHosts."h1.t.local".locations; elsewhere = matrixElsewhere.services.nginx.virtualHosts."h1.t.local".locations; in matrixNoBaoIdentity.services.hyperhive.gateway.httpsPort == 8443 - && lib.hasInfix ''{"m.homeserver":{"base_url":"https://chat.t.local"}}'' ( - here."= /.well-known/matrix/client".extraConfig or "" - ) - && lib.hasInfix ''{"m.server":"chat.t.local:443"}'' ( - here."= /.well-known/matrix/server".extraConfig or "" - ) && lib.hasInfix "rewrite ^/matrix/(.*)$ https://chat.t.local/$1 permanent;" ( here."/matrix/".extraConfig or "" ) && lib.hasInfix "return 301 https://h1.hives.t.local$request_uri;" here."/".extraConfig + && !(here ? "= /.well-known/matrix/client") + && !(here ? "= /.well-known/matrix/server") && elsewhere ? "/" - && !(elsewhere ? "= /.well-known/matrix/client") - && !(elsewhere ? "/matrix/"); + && !(elsewhere ? "/matrix/") + && !(elsewhere ? "= /.well-known/matrix/client"); + } + { + # A homeserver pinned to the hive domain keeps its discovery on that + # vhost, even though the host runs no homeserver, and not on the apex, which is a + # different server name. The apex arm is read on a vhost that + # exists (its `/` and `/api/` are there), so it cannot pass on a + # misspelled name. + name = "a server name pinned to the hive domain is answered on the hive domain's vhost and not on the apex"; + ok = + let + onHive = matrixPinned.services.nginx.virtualHosts."h1.t.local".locations; + apex = matrixPinned.services.nginx.virtualHosts."t.local".locations; + in + !matrixPinned.services.hyperhive.deploy.matrix.enable + && lib.hasInfix ''{"m.homeserver":{"base_url":"https://chat.t.local"}}'' ( + onHive."= /.well-known/matrix/client".extraConfig or "" + ) + && lib.hasInfix ''{"m.server":"chat.t.local:443"}'' ( + onHive."= /.well-known/matrix/server".extraConfig or "" + ) + && onHive ? "/" + && apex ? "/" + && apex ? "/api/" + && !(apex ? "= /.well-known/matrix/client") + && !(apex ? "= /.well-known/matrix/server"); } ]; in diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 7c65716e..27128cff 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -366,6 +366,27 @@ let name = "a named exposeHostPorts opens its bridge port on the host's own say-so"; ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive); } + { + # The default server name is the swarm domain, so the apex answers + # discovery from `gatewayHost` alone: this fixture runs no homeserver and + # no hive. The URLs carry no port, and `m.server` names 443 because the + # spec reads a bare host as 8448. The `/` arm is the presence control on + # the vhost; the pinned counterpart is in ./matrix-core.nix. + name = "the swarm apex serves both matrix discovery routes under the default server name"; + ok = + let + l = swarmUiHere.services.nginx.virtualHosts."t.local".locations; + in + l ? "/" + && lib.hasInfix ''{"m.homeserver":{"base_url":"https://chat.t.local"}}'' ( + l."= /.well-known/matrix/client".extraConfig or "" + ) + && lib.hasInfix ''{"m.server":"chat.t.local:443"}'' ( + l."= /.well-known/matrix/server".extraConfig or "" + ) + && !(lib.hasInfix "auth_request" l."= /.well-known/matrix/client".extraConfig) + && !(lib.hasInfix "auth_request" l."= /.well-known/matrix/server".extraConfig); + } { name = "the old swarm UI package spelling sets the controller's UI package, which the vhost serves"; ok = @@ -547,7 +568,7 @@ let vh = swarmServicesHere.services.nginx.virtualHosts; l = vh."h1.hives.t.local".locations; in - l ? "/api/" && l ? "/health/" && vh."h1.t.local".locations ? "= /.well-known/matrix/client" + l ? "/api/" && l ? "/health/" && vh."h1.t.local".locations ? "/" ) && !(noHiveLeafAnywhere swarmServicesHere); }