Watch
0
0
Fork
You've already forked hyperhive
0

serve matrix discovery at the swarm apex, keep the pinned hive case

This commit is contained in:
atlas 2026-10-25 11:13:22 +01:00 • committed by mara
commit 2e7d49bb88
10 changed files with 187 additions and 83 deletions

View file

@ -54,7 +54,9 @@ homeserver.
⚠️ **they're still not interchangeable, and the difference is the ⚠️ **they're still not interchangeable, and the difference is the
cost of changing one.** `gatewayHost` is a routing detail clients cost of changing one.** `gatewayHost` is a routing detail clients
rediscover through `.well-known`, so it's safe to move on a running 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 — 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 it strands them, because their ids still name a homeserver that no
longer answers. 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 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. 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.
</details> </details>
## Reach ## 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. verifies the gateway's certificate against the hive trust bundle.
Federation peers find the homeserver through `.well-known/matrix/server` Federation peers find the homeserver through `.well-known/matrix/server`
on the hive domain, which names `<gatewayHost>:443` at the server name, which names `<gatewayHost>:443`
([`gateway.md::Discovery flow`](../networking/gateway.md#discovery-flow-matrix)). ([`gateway.md::Discovery flow`](../networking/gateway.md#discovery-flow-matrix)).
Port 8448 isn't used. Port 8448 isn't used.

View file

@ -33,7 +33,8 @@ A service module declares its vhost as `services.hyperhive.gateway.vhosts.<name>
| `<name>.hives.<swarm>/health/` | hive-c0re (`7000`) | always | | `<name>.hives.<swarm>/health/` | hive-c0re (`7000`) | always |
| `<name>.hives.<swarm>/api/docs/` | themed Swagger UI dist (static), behind authelia | always | | `<name>.hives.<swarm>/api/docs/` | themed Swagger UI dist (static), behind authelia | always |
| `<name>.hives.<swarm>/agent/<name>/` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | | `<name>.hives.<swarm>/agent/<name>/` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) |
| `<hive>/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` | | `<swarm>/.well-known/matrix/{client,server}` | inline JSON | `deploy.swarm-controller.enable`, and `swarm.matrix.serverName` is the swarm domain (the default) |
| `<hive>/.well-known/matrix/{client,server}` | inline JSON | `swarm.matrix.serverName` is this hive's domain (a pinned homeserver) |
| `<hive>/matrix/` | 301 → `chat.<swarm>/` | `deploy.matrix.enable` and `gatewayHost` set | | `<hive>/matrix/` | 301 → `chat.<swarm>/` | `deploy.matrix.enable` and `gatewayHost` set |
| `<hive>/` (every other path) | 301 → `<name>.hives.<swarm>`, same path | always | | `<hive>/` (every other path) | 301 → `<name>.hives.<swarm>`, same path | always |
@ -208,9 +209,11 @@ When enabled, every vhost adds `Strict-Transport-Security: max-age=…[; include
## Discovery flow (matrix) ## Discovery flow (matrix)
The operator points a client at `<hive>`: 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://<hive>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat.<swarm>"}}`. The operator points a client at the server name (`<server-name>` below):
1. The client fetches `https://<server-name>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat.<swarm>"}}`.
2. It connects to `chat.<swarm>/_matrix/client/...`. 2. It connects to `chat.<swarm>/_matrix/client/...`.
3. The gateway proxies `/_matrix/*` to tuwunel over its unix socket, `/run/hive-matrix/tuwunel.sock`. 3. The gateway proxies `/_matrix/*` to tuwunel over its unix socket, `/run/hive-matrix/tuwunel.sock`.

View file

@ -553,7 +553,7 @@ mechanism.
2. **Matrix federation** — when this host runs the homeserver (`deploy.matrix.enable`), tuwunel 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 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 Federation validates the peer's TLS certificate against the matrix
**container's** trust bundle, independent of this directory. **container's** trust bundle, independent of this directory.

View file

@ -1,16 +1,17 @@
# Glue: serve the swarm homeserver's discovery on the hive domain's vhost # Glue: matrix's links on the hive domain's vhost.
# wherever both run.
# #
# ONE PAIRING PER FILE — matrix → hive domain vhost, and nothing else. # 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 `<hive>/matrix/*` links 404.
# #
# The locations sit on the hive domain because the matrix spec reads # Two pieces, each under its own condition:
# delegation off the *server name*: it is the hive answering "where is my # - `.well-known/matrix/{client,server}` is served here only when the hive
# homeserver", not the homeserver answering for itself (./hive-matrix.nix's # domain IS the homeserver's server name (a homeserver that pinned
# vhost on `gatewayHost` carries the rest). # `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).
# - `<hive>/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 # domain vhost under. With it looser, this file alone would declare that
# vhost, with discovery and no `/` redirect. # vhost, with discovery and no `/` redirect.
{ {
@ -24,28 +25,32 @@ let
matrixCfg = hyperhiveCfg.swarm.matrix; matrixCfg = hyperhiveCfg.swarm.matrix;
homeserverUrl = "https://${matrixCfg.gatewayHost}"; homeserverUrl = "https://${matrixCfg.gatewayHost}";
in
{ onHiveVhost =
config = matrixCfg.gatewayHost != null
lib.mkIf
(
hyperhiveCfg.deploy.matrix.enable
&& matrixCfg.gatewayHost != null
&& hyperhiveCfg.deploy.hive-controller.enable && hyperhiveCfg.deploy.hive-controller.enable
&& gatewayCfg.enable && gatewayCfg.enable
&& hyperhiveCfg.swarm.dashboardDomain != null && hyperhiveCfg.swarm.dashboardDomain != null;
)
{ # Same default as `effectiveServerName` in ./hive-matrix.nix.
services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations = { serverName =
if matrixCfg.serverName != null then matrixCfg.serverName else hyperhiveCfg.swarm.domain;
in
{
config = lib.mkMerge [
(lib.mkIf (onHiveVhost && hyperhiveCfg.deploy.matrix.enable) {
# `<hive>/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.<swarm-domain>` # `<hive>/matrix/*` → 301 → `gatewayHost`'s vhost/$1 (`chat.<swarm-domain>`
# by default; legacy deep-link shim for the fluffychat sub-domain). # by default; legacy deep-link shim for the fluffychat sub-domain).
# See `docs/networking/gateway.md`. # See `docs/networking/gateway.md`.
"/matrix/" = { services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations."/matrix/" = {
extraConfig = '' extraConfig = ''
rewrite ^/matrix/(.*)$ ${homeserverUrl}/$1 permanent; rewrite ^/matrix/(.*)$ ${homeserverUrl}/$1 permanent;
''; '';
}; };
})
(lib.mkIf (onHiveVhost && hyperhiveCfg.domain == serverName) {
services.hyperhive.gateway.vhosts.${hyperhiveCfg.domain}.locations = {
# `.well-known/matrix/{client,server}` discovery JSON, pointing # `.well-known/matrix/{client,server}` discovery JSON, pointing
# clients and peers at `gatewayHost`: the homeserver has no other # clients and peers at `gatewayHost`: the homeserver has no other
# address. CORS `*` per matrix spec. See `docs/networking/gateway.md`. # address. CORS `*` per matrix spec. See `docs/networking/gateway.md`.
@ -72,5 +77,6 @@ in
''; '';
}; };
}; };
}; })
];
} }

View file

@ -28,7 +28,7 @@ in
identity to a single hive's domain would make relocating the identity to a single hive's domain would make relocating the
container between hives look like a different homeserver. container between hives look like a different homeserver.
Combined with the `.well-known/matrix/{client,server}` routes 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 actual matrix endpoint without needing a subdomain. Override
here only if you need a different server_name shape (e.g. here only if you need a different server_name shape (e.g.
`chat.example.org` for a bespoke hostname). `chat.example.org` for a bespoke hostname).

View file

@ -454,11 +454,10 @@ in
# longest-prefix rule puts `/_matrix/` # longest-prefix rule puts `/_matrix/`
# ahead of `/` with no ordering needed. # ahead of `/` with no ordering needed.
# #
# ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here. # ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here:
# It stays on the hive's own vhost because the spec requires it to # the spec requires it at the *server name*. It is served by the swarm
# be served at the *server name*, which is the hive domain — it is # apex (./swarm-ui.nix) for the default server name, or by the hive
# the hive answering "where is my homeserver", not the homeserver # domain's vhost (./glue-matrix-hive-discovery.nix) for a pinned one.
# answering for itself.
services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) { services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) {
"${cfg.gatewayHost}" = { "${cfg.gatewayHost}" = {
locations = { locations = {

View file

@ -64,6 +64,16 @@ let
# rather than a coercion error from here — same idiom as # rather than a coercion error from here — same idiom as
# `swarm-otel-service.nix`'s `domainBase`. # `swarm-otel-service.nix`'s `domainBase`.
apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain; 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 in
{ {
# No `enable` of its own: the UI is on wherever # No `enable` of its own: the UI is on wherever
@ -196,6 +206,25 @@ in
extraConfig = swarmAuthRequest; 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 # swarm-controller's whole HTTP surface, including the live
# `/api/openapi.json` spec — proxied untouched (no URI segment # `/api/openapi.json` spec — proxied untouched (no URI segment

View file

@ -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 = 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 # main got eval-borked twice by this exact class of bug (once on the

View file

@ -19,6 +19,7 @@ let
; ;
}) })
hive hive
hiveWithController
runGroup runGroup
; ;
@ -36,6 +37,16 @@ let
# A hive host that does not run the homeserver: its hive-c0re reaches the # A hive host that does not run the homeserver: its hive-c0re reaches the
# homeserver across the network. # homeserver across the network.
matrixElsewhere = shared.bareWithController; 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 = [ cases = [
{ {
# Two limits governed a matrix upload and nothing kept them in agreement, # Two limits governed a matrix upload and nothing kept them in agreement,
@ -129,32 +140,55 @@ let
== "https://chat.t.local"; == "https://chat.t.local";
} }
{ {
# The discovery locations reach the hive domain's vhost from a glue # The legacy redirect reaches the hive domain's vhost from a glue
# module, merged into the vhost hive-c0re declares. The matrix-elsewhere # module, merged into the vhost hive-c0re declares, and only where the
# arm reads the same vhost, which still exists there with its redirect, # homeserver runs. The matrix-elsewhere arm reads the same vhost, which
# so its absence of discovery cannot pass on a misspelled name. The # still exists there with its redirect, so its absence of the redirect
# fixture's gateway is on a non-default TLS port, which none of the URLs # cannot pass on a misspelled name. Under the default server name (the
# carry. # swarm domain) the hive domain answers no discovery on either host.
name = "matrix discovery sits on the hive domain's vhost only where the homeserver runs"; # 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 = ok =
let let
here = matrixNoBaoIdentity.services.nginx.virtualHosts."h1.t.local".locations; here = matrixNoBaoIdentity.services.nginx.virtualHosts."h1.t.local".locations;
elsewhere = matrixElsewhere.services.nginx.virtualHosts."h1.t.local".locations; elsewhere = matrixElsewhere.services.nginx.virtualHosts."h1.t.local".locations;
in in
matrixNoBaoIdentity.services.hyperhive.gateway.httpsPort == 8443 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;" ( && lib.hasInfix "rewrite ^/matrix/(.*)$ https://chat.t.local/$1 permanent;" (
here."/matrix/".extraConfig or "" here."/matrix/".extraConfig or ""
) )
&& lib.hasInfix "return 301 https://h1.hives.t.local$request_uri;" here."/".extraConfig && 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 ? "/"
&& !(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 in

View file

@ -366,6 +366,27 @@ let
name = "a named exposeHostPorts opens its bridge port on the host's own say-so"; name = "a named exposeHostPorts opens its bridge port on the host's own say-so";
ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive); 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"; name = "the old swarm UI package spelling sets the controller's UI package, which the vhost serves";
ok = ok =
@ -547,7 +568,7 @@ let
vh = swarmServicesHere.services.nginx.virtualHosts; vh = swarmServicesHere.services.nginx.virtualHosts;
l = vh."h1.hives.t.local".locations; l = vh."h1.hives.t.local".locations;
in 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); && !(noHiveLeafAnywhere swarmServicesHere);
} }