hive-gateway: self-signed TLS for matrix-client well-known fetch (closes #837)
This commit is contained in:
parent
76d3267b55
commit
314f1774d8
2 changed files with 167 additions and 29 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue