hive-gateway: self-signed TLS for matrix-client well-known fetch (closes #837)

This commit is contained in:
damocles 2026-05-31 17:04:36 +02:00 committed by mara
commit 314f1774d8
2 changed files with 167 additions and 29 deletions

View file

@ -21,11 +21,13 @@ Per-agent UIs stay sub-path because they're hyperhive-internal and base-path-awa
Operator points client at `<hive>`. Sequence: Operator points client at `<hive>`. Sequence:
1. Client fetches `http://<hive>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"http://matrix.<hive>"}}` (no port suffix when gateway listens on 80). 1. Client fetches `https://<hive>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://matrix.<hive>"}}` (no port suffix when gateway listens on 443). With `selfSignedTls = false` the scheme drops to http and the port suffix reflects the bare `port` instead.
2. Client connects to `matrix.<hive>/_matrix/client/...`. 2. Client connects to `matrix.<hive>/_matrix/client/...`.
3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`. 3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`.
Federation peers fetch `.well-known/matrix/server``{"m.server":"matrix.<hive>"}` and connect to `matrix.<hive>:8448` per spec default. Gateway only listens on configured `port`; cross-hive federation needs either an SRV record (`_matrix._tcp.matrix.<hive>` → port 80) OR `matrix.openFirewall = true` so peers reach tuwunel's federation port directly. Hyperhive is mostly closed/internal, so this rarely bites. matrix-dart-sdk (FluffyChat etc.) hardcodes `https` for the well-known fetch regardless of input scheme, so the discovery endpoint MUST be https — see "Self-signed TLS" below for the cert generation that backs the default-on path.
Federation peers fetch `.well-known/matrix/server``{"m.server":"matrix.<hive>"}` and connect to `matrix.<hive>:8448` per spec default. Gateway only listens on configured `port` (+ `httpsPort` when TLS on); cross-hive federation needs either an SRV record (`_matrix._tcp.matrix.<hive>` → port 80 / 443) OR `matrix.openFirewall = true` so peers reach tuwunel's federation port directly. Hyperhive is mostly closed/internal, so this rarely bites.
## SPA fallback (Accept-header pattern) ## SPA fallback (Accept-header pattern)
@ -131,6 +133,22 @@ frontend-side derivation.
Next-up tracked separately: #14 (container netns isolation), TLS (#594). Next-up tracked separately: #14 (container netns isolation), TLS (#594).
## Self-signed TLS (`selfSignedTls`)
On by default since #837. The gateway generates a self-signed RSA-4096 cert at first boot (10-year validity) and listens on `httpsPort` (default 443) on every vhost beside the plain-http `port` (default 80).
**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client cannot bootstrap (#837). For headless agent traffic and operator dashboard reach plain http is fine, so the http listen stays in parallel — operators can keep using `http://<hive>/` from the dashboard if they don't care about the cert prompt.
**Cert shape**: subject CN = bare hive domain; subjectAltName covers `<hive>` + wildcard `*.<hive>` so all current and future sub-domain vhosts (matrix, forge, ...) validate under the same cert. Stored at `/var/lib/hive-gateway/tls/{cert,key}.pem` inside the gateway container (`ephemeral = false`, so persisted across container restart).
**Regeneration**: the generator unit (`hive-gateway-self-signed-cert.service`) is gated by `ConditionPathExists=!cert.pem`, so it's a one-shot. To rotate (e.g. cert leak, expiry approaching), delete `cert.pem` inside the gateway container and restart `nginx.service` — the generator runs as a `Before=` dependency. No automatic rotation; the cert is purely a workaround for the well-known fetch.
**Production**: operators fronting hyperhive with a real reverse proxy (caddy + ACME, traefik + Let's Encrypt, etc.) set `selfSignedTls = false`. The proxy handles termination upstream, the gateway listens on `port` only.
**Cert prompts**: browsers warn once per host on first visit. With the wildcard SAN, `https://<hive>/`, `https://matrix.<hive>/`, and `https://forge.<hive>/` are covered by the same cert, but the browser still prompts per origin (per-host security state). FluffyChat needs the user to accept both `matrix.<hive>` (the SPA itself) and `<hive>` (the well-known fetch endpoint).
**`.well-known/matrix/{client,server}` scheme**: switches to `https` when `selfSignedTls` is on, so matrix-dart-sdk doesn't downgrade and tuwunel federation peers get a TLS-fronted base_url. The legacy `/matrix/*` 301 redirect also flips to https.
## Firewall posture (host-level) ## Firewall posture (host-level)
`hive-c0re.nix` opens the per-agent web-port range `hive-c0re.nix` opens the per-agent web-port range
@ -141,6 +159,11 @@ Next-up tracked separately: #14 (container netns isolation), TLS (#594).
firewall-open would defeat the single-front-door story (closes firewall-open would defeat the single-front-door story (closes
#621). #621).
`services.hyperhive.gateway.openFirewall = true` opens `port` plus
`httpsPort` when `selfSignedTls = true` (default). Operators who
flip `selfSignedTls = false` to front the gateway with a real
TLS-terminating reverse proxy on the host get only `port` opened.
Manager hashes into the same range since #753 (no more Manager hashes into the same range since #753 (no more
"manager pinned at 8000" special case), so one range opening covers "manager pinned at 8000" special case), so one range opening covers
every container. every container.

View file

@ -197,6 +197,46 @@ in
''; '';
}; };
selfSignedTls = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Generate a self-signed TLS cert at first gateway boot and
listen on `httpsPort` (default 443) with it on every vhost.
On by default because matrix-dart-sdk (the SDK behind
FluffyChat + several other Matrix clients) hardcodes
`https://<host>/.well-known/matrix/client` for homeserver
discovery and refuses to fall back to plain http without
TLS the browser client just won't connect (#837).
Self-signed means browsers will show a "not secure" warning
on first visit; the operator clicks through once per
browser. For production deployments, set this to `false`
and front the gateway with a reverse proxy (caddy, traefik,
or nginx with ACME) that does proper TLS termination.
The cert is regenerated on demand if the file is missing
but never rotated automatically; delete
`/var/lib/hive-gateway/tls/cert.pem` inside the gateway
container to force a fresh one.
See `docs/gateway.md` ("Self-signed TLS").
'';
};
httpsPort = lib.mkOption {
type = lib.types.port;
default = 443;
example = 8443;
description = ''
TCP port for the TLS-terminated vhosts when `selfSignedTls`
is enabled. Default 443. Setting `selfSignedTls = false`
renders this option inert (the gateway listens on `port`
only).
'';
};
agentPortsFile = lib.mkOption { agentPortsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
default = "/var/lib/hyperhive/agent-ports.json"; default = "/var/lib/hyperhive/agent-ports.json";
@ -311,8 +351,87 @@ in
}; };
config = config =
{ pkgs, ... }: { pkgs, ... }:
let
tlsDir = "/var/lib/hive-gateway/tls";
tlsCert = "${tlsDir}/cert.pem";
tlsKey = "${tlsDir}/key.pem";
# Listen addresses every vhost shares. Plain http on `cfg.port`
# always; `cfg.httpsPort` with TLS sits beside it when
# `cfg.selfSignedTls` is on. See `docs/gateway.md`
# ("Self-signed TLS") for the cert lifecycle.
vhostListen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
]
++ lib.optional cfg.selfSignedTls {
addr = "0.0.0.0";
port = cfg.httpsPort;
ssl = true;
};
# nixos `services.nginx.virtualHosts.<name>` ssl attrs to mix
# into each vhost when self-signed TLS is on. `addSSL = true`
# is what gates `ssl_certificate` directive emission in the
# nixos nginx module (`hasSSL` checks addSSL / onlySSL /
# forceSSL). The actual ssl listen is the explicit entry
# with `ssl = true` in `vhostListen` — the nixos module's
# auto-listen-generation only kicks in when `listen` is
# empty, so the explicit listen wins and there's no
# duplicate-listen risk. Empty otherwise so the http-only
# path stays identical.
vhostTls = lib.optionalAttrs cfg.selfSignedTls {
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
};
in
{ {
system.stateVersion = "26.05"; system.stateVersion = "26.05";
# Generate a self-signed cert on first boot if missing. nginx
# waits on this (Before=) so we never start with a broken
# ssl_certificate path. Cert covers the bare hive domain plus
# `*.${hyperhiveDomain}` so the matrix + forge sub-domains
# are valid under the same cert. See `docs/gateway.md`
# ("Self-signed TLS").
systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls {
description = "Generate self-signed TLS cert for hive-gateway";
wantedBy = [ "multi-user.target" ];
before = [ "nginx.service" ];
unitConfig.ConditionPathExists = "!${tlsCert}";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
UMask = "0077";
};
path = [
pkgs.openssl
pkgs.coreutils
];
script =
let
subjectCN = if hyperhiveDomain != null then hyperhiveDomain else "hyperhive.local";
# `subjectAltName` covers the bare hive + the canonical
# sub-domains. Includes a wildcard `*.${domain}` so any
# future sub-domain we mint (per-agent UI under a
# sub-domain, etc.) inherits the cert without a rebuild.
sanLines = lib.concatStringsSep "," (
[ "DNS:${subjectCN}" ] ++ lib.optional (hyperhiveDomain != null) "DNS:*.${hyperhiveDomain}"
);
in
''
mkdir -p ${tlsDir}
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
-keyout ${tlsKey} \
-out ${tlsCert} \
-subj "/CN=${subjectCN}" \
-addext "subjectAltName=${sanLines}"
chmod 0600 ${tlsKey}
chmod 0644 ${tlsCert}
'';
};
services.nginx = { services.nginx = {
enable = true; enable = true;
recommendedProxySettings = true; recommendedProxySettings = true;
@ -329,13 +448,8 @@ in
} }
''; '';
virtualHosts = { virtualHosts = {
"_" = { "_" = vhostTls // {
listen = [ listen = vhostListen;
{
addr = "0.0.0.0";
port = cfg.port;
}
];
locations = locations =
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` # `<hive>/matrix/*` → 301 → `matrix.<hive>/$1`
# (fluffychat moved to sub-domain root in #772; this # (fluffychat moved to sub-domain root in #772; this
@ -344,8 +458,16 @@ in
# map. # map.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) ( lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) (
let let
portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}"; # Public-facing scheme + port-suffix. When self-signed TLS
target = "http://${matrixCfg.gatewayHost}${portSuffix}"; # is on, prefer https://<host> (matrix-spec compliance);
# 443 elides the port. Otherwise fall back to the
# plain-http listen with the bare port. See
# `docs/gateway.md` ("Self-signed TLS").
scheme = if cfg.selfSignedTls then "https" else "http";
activePort = if cfg.selfSignedTls then cfg.httpsPort else cfg.port;
defaultPort = if cfg.selfSignedTls then 443 else 80;
portSuffix = if activePort == defaultPort then "" else ":${toString activePort}";
target = "${scheme}://${matrixCfg.gatewayHost}${portSuffix}";
in in
{ {
"/matrix/" = { "/matrix/" = {
@ -364,12 +486,15 @@ in
# client-bootstrap sequence. # client-bootstrap sequence.
lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) ( lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) (
let let
portSuffix = if cfg.port == 80 then "" else ":${toString cfg.port}"; scheme = if cfg.selfSignedTls then "https" else "http";
activePort = if cfg.selfSignedTls then cfg.httpsPort else cfg.port;
defaultPort = if cfg.selfSignedTls then 443 else 80;
portSuffix = if activePort == defaultPort then "" else ":${toString activePort}";
clientBaseUrl = clientBaseUrl =
if matrixCfg.gatewayHost != null then if matrixCfg.gatewayHost != null then
"http://${matrixCfg.gatewayHost}${portSuffix}" "${scheme}://${matrixCfg.gatewayHost}${portSuffix}"
else else
"http://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; "${scheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
serverHostPort = serverHostPort =
if matrixCfg.gatewayHost != null then if matrixCfg.gatewayHost != null then
"${matrixCfg.gatewayHost}${portSuffix}" "${matrixCfg.gatewayHost}${portSuffix}"
@ -474,13 +599,8 @@ in
# (multi-GB clones). SSH stays direct on `forge.sshPort`. # (multi-GB clones). SSH stays direct on `forge.sshPort`.
# See `docs/gateway.md`. # See `docs/gateway.md`.
lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) { lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = { "${forgeCfg.domain}" = vhostTls // {
listen = [ listen = vhostListen;
{
addr = "0.0.0.0";
port = cfg.port;
}
];
locations."/" = { locations."/" = {
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
proxyWebsockets = true; proxyWebsockets = true;
@ -501,13 +621,8 @@ in
# longer-prefix-wins puts `/_matrix/` ahead of `/`. # longer-prefix-wins puts `/_matrix/` ahead of `/`.
# See `docs/gateway.md`. # See `docs/gateway.md`.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = { "${matrixCfg.gatewayHost}" = vhostTls // {
listen = [ listen = vhostListen;
{
addr = "0.0.0.0";
port = cfg.port;
}
];
locations = { locations = {
"/_matrix/" = { "/_matrix/" = {
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
@ -613,7 +728,7 @@ in
}; };
networking.firewall = lib.mkIf cfg.openFirewall { networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ]; allowedTCPPorts = [ cfg.port ] ++ lib.optional cfg.selfSignedTls cfg.httpsPort;
}; };
# `/etc/hosts` entries for local dev — bare hive domain + any # `/etc/hosts` entries for local dev — bare hive domain + any