From 1a3f82a459b21ef5b6b267316be87ef424cb9c92 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 17 Jun 2026 20:26:03 +0200 Subject: [PATCH] nix(gateway): self-signed TLS as the implicit default, deprecate the toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make self-signed TLS the gateway's default whenever no external TLS source is configured, and deprecate the explicit selfSignedTls toggle. Self-signed is now derived as `tls.certDir == null && !tls.acme.enable`, so an operator selects a TLS mode by setting tls.certDir or tls.acme — or neither, for the self-signed default. There is no http-only mode: matrix discovery hardcodes https, so the gateway always terminates TLS. The selfSignedTls option is kept as a deprecated no-op (warns when set to false) so existing configs still eval. The two selfSignedTls mutual- exclusion assertions and the HSTS-requires-TLS assertion are dropped — they are impossible or vacuous now that self-signed is the floor. The hive-tls module and the forge ROOT_URL scheme consume the derived value: the gateway always terminates TLS, so behind the gateway the forge is always advertised over https. Updates docs/gateway.md (TLS-modes table, self-signed section, the removed http-only section, firewall + discovery notes). Eval-proven: default → self-signed (hive CA active, https ROOT_URL); tls.certDir → CA inactive; selfSignedTls=false → deprecation warning fires. --- docs/gateway.md | 60 +++++++++------- nix/modules/hive-forge.nix | 19 +++-- nix/modules/hive-gateway.nix | 131 +++++++++++++++++------------------ nix/modules/hive-tls.nix | 11 +-- 4 files changed, 112 insertions(+), 109 deletions(-) diff --git a/docs/gateway.md b/docs/gateway.md index 000a5680..d790f9e2 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -21,7 +21,7 @@ Per-agent UIs stay sub-path because they're hyperhive-internal and base-path-awa Operator points client at ``. Sequence: -1. Client fetches `https:///.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://matrix."}}` (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. +1. Client fetches `https:///.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://matrix."}}` (no port suffix when gateway listens on 443). The gateway always terminates TLS, so the scheme is always `https`; a non-default `httpsPort` is reflected as the port suffix. 2. Client connects to `matrix./_matrix/client/...`. 3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`. @@ -164,14 +164,20 @@ frontend-side derivation. ## TLS modes -Four modes: +The gateway always terminates TLS — self-signed is the implicit floor when +nothing else is configured, so there is no http-only mode. Three modes, +selected by which (if any) external TLS source is set: | mode | config | cert source | `.well-known` scheme | |---|---|---|---| -| self-signed (default) | `selfSignedTls = true` | host hive-CA signs a gateway leaf (RSA-4096) | `https` | -| ACME (Let's Encrypt) | `selfSignedTls = false` + `tls.acme.enable = true` | nginx inside container via HTTP-01 | `https` | -| operator cert | `selfSignedTls = false` + `tls.certDir` set | bind-mounted from host | `https` | -| http-only | `selfSignedTls = false`, no `tls.certDir`, no `tls.acme` | none | `http` | +| self-signed (default) | neither `tls.certDir` nor `tls.acme` set | host hive-CA signs a gateway leaf (RSA-4096) | `https` | +| ACME (Let's Encrypt) | `tls.acme.enable = true` | nginx inside container via HTTP-01 | `https` | +| operator cert | `tls.certDir` set | bind-mounted from host | `https` | + +The `gateway.selfSignedTls` option is **deprecated and ignored** — self-signed +is now derived from the absence of `tls.certDir` / `tls.acme`. Setting it to +`false` (which used to select http-only or force an external cert) warns and +has no effect; use `tls.certDir` / `tls.acme` to override the default. ### ACME / Let's Encrypt (`tls.acme`) @@ -179,7 +185,6 @@ Simplest production path for operators with a public domain: ```nix services.hyperhive.gateway = { - selfSignedTls = false; openFirewall = true; tls.acme = { enable = true; @@ -192,11 +197,11 @@ nginx inside the gateway container obtains and auto-renews certs via the ACME HT **Requirements**: `services.hyperhive.domain` must be publicly DNS-resolvable to this host, and `openFirewall = true` so Let's Encrypt can reach `/.well-known/acme-challenge/`. Each active vhost (main domain, `forge.`, `matrix.`) gets its own cert via separate ACME challenges. -Mutual exclusion: `selfSignedTls = true` or `tls.certDir` set together with `tls.acme.enable = true` fails an assertion. +Mutual exclusion: `tls.certDir` set together with `tls.acme.enable = true` fails an assertion — pick one external TLS source (or neither, for the self-signed default). **Swarm peers**: CA-signed certs are trusted by default — remote hives need no `certFingerprint` in `swarm.peers`. -### Self-signed TLS (`selfSignedTls`) +### Self-signed TLS (default) On by default, and listens on `httpsPort` (default 443) on every vhost beside the plain-http `port` (default 80). @@ -218,7 +223,6 @@ For operators with a real CA cert (Let's Encrypt, corporate CA, etc.): ```nix services.hyperhive.gateway = { - selfSignedTls = false; tls.certDir = "/var/lib/acme/example.com"; # nixpkgs security.acme output dir # tls.certName = "cert.pem"; # default — matches security.acme layout # tls.keyName = "key.pem"; # default — matches security.acme layout @@ -227,7 +231,7 @@ services.hyperhive.gateway = { The directory is bind-mounted read-only into the gateway container at `/run/hive-tls/`. nginx uses `cert.pem` + `key.pem` (override `tls.certName`/`tls.keyName` for different filenames). Both modes listen on `httpsPort` (default 443) and emit `https://` in `.well-known` responses. -`selfSignedTls = true` and `tls.certDir` set together is an assertion error. +`tls.certDir` and `tls.acme.enable` set together is an assertion error. **Key file permissions**: nixpkgs's `security.acme` outputs private keys as `0640 root:acme` by default. nginx inside the gateway container runs as the `nginx` user and cannot read a key with that ownership. Fix with: @@ -243,11 +247,19 @@ or make the key world-readable (`0644`) if your threat model allows it. nginx er services.hyperhive.swarm.peers."example.com" = { }; # no certFingerprint needed ``` -### HTTP-only (`selfSignedTls = false`, no `tls.certDir`) +### Fronting with an external TLS terminator -For operators who front the gateway with an external TLS terminator (caddy, traefik, nginx + ACME on the host). The gateway listens on `port` (default 80) only. `.well-known/matrix` responses use `http://`, which breaks matrix-dart-sdk discovery — acceptable when matrix GUI is off or the external proxy handles the `.well-known` redirect. +There is no http-only mode: the gateway always terminates TLS (self-signed +floor). Two paths for an operator who wants their own TLS terminator: -**`.well-known/matrix/{client,server}` scheme**: `https` when TLS is active (either mode), `http` when http-only. The scheme must match what clients see at the external hostname. +- give the gateway the real cert via `tls.certDir` (or `tls.acme`) so it + serves proper TLS directly — no separate proxy needed; or +- front it over a **unix socket** rather than a plain-http TCP port (the + intended direction for "bring your own proxy" — the gateway is not meant + to expose an unencrypted TCP upstream). + +**`.well-known/matrix/{client,server}` scheme** is always `https` now — the +gateway always terminates TLS, so discovery responses always advertise https. ## Firewall posture (host-level) @@ -258,10 +270,9 @@ For operators who front the gateway with an external TLS terminator (caddy, trae `127.0.0.1:` internally — leaving the per-agent ports firewall-open would defeat the single-front-door story. -`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. +`services.hyperhive.gateway.openFirewall = true` opens both `port` and +`httpsPort` — the gateway always terminates TLS (self-signed floor), so +both are always served. The manager hashes into the same port range as sub-agents (no "manager pinned at 8000" special case), so one range opening covers @@ -358,9 +369,9 @@ covers most cases: | `behindGateway = false` | `http://:/` | The auto-derivation always uses `http://`. Set `rootUrl` explicitly when -you need `https://` (e.g. behind a TLS-terminating reverse proxy or when -`selfSignedTls = true` and clone URLs must carry `https://`), or when -`forge.domain` resolves differently from the public URL. Must end with +you need `https://` (e.g. behind a TLS-terminating reverse proxy, or when +clone URLs must carry `https://` because the gateway terminates TLS), or +when `forge.domain` resolves differently from the public URL. Must end with `/` (Forgejo requirement; an assertion enforces this). ## Per-agent static frontend split @@ -571,8 +582,7 @@ header is added alongside the other security headers. enabling it on a deployment that later loses TLS locks browsers out until `max-age` expires. Only enable when TLS is permanent. -**Assertion**: `hsts.enable = true` without a TLS mode configured -(`selfSignedTls`, `tls.certDir`, or `tls.acme.enable`) is a NixOS -build-time assertion failure — HSTS over plain HTTP is harmless but -almost always a misconfiguration. +The gateway always terminates TLS now (self-signed floor), so HSTS is +always served over https when enabled — the old "HSTS requires a TLS mode" +assertion is gone (it can no longer be violated). diff --git a/nix/modules/hive-forge.nix b/nix/modules/hive-forge.nix index f9ca2306..db63ebd9 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/modules/hive-forge.nix @@ -14,15 +14,14 @@ let # forgejo `DOMAIN` setting AND the gateway vhost server-name, so # ROOT_URL just uses it directly (dropping the port suffix on the # canonical port for the scheme — 80 for http, 443 for https). The - # scheme + port follow what the gateway actually serves: `https` when - # the gateway terminates TLS (a self-signed cert or an external - # `tls.certDir`), `http` otherwise — advertising `http://` for a TLS - # gateway produces broken clone links + mixed-content redirects. + # gateway always terminates TLS now — self-signed is the implicit floor + # when neither `tls.certDir` nor ACME is configured — so behind the + # gateway the forge is always advertised over `https` on `httpsPort`. # When direct (gateway off or `behindGateway = false`), keep the # host:httpPort shape so direct browser access still produces correct # links. Operators can still override via `cfg.rootUrl` for bespoke # shapes. - gatewayTls = gatewayCfg.selfSignedTls || gatewayCfg.tls.certDir != null; + gatewayTls = true; defaultRootUrl = if cfg.behindGateway then let @@ -157,11 +156,11 @@ in (default), `ROOT_URL` is derived from `cfg.domain` + gateway state, including the scheme: - - `behindGateway = true` → `https://''${cfg.domain}/` when the - gateway terminates TLS (`gateway.selfSignedTls = true` or - `gateway.tls.certDir` set), otherwise `http://''${cfg.domain}/`. - A non-canonical gateway port (`gateway.port` for http, - `gateway.httpsPort` for https) is appended as `:`. + - `behindGateway = true` → `https://''${cfg.domain}/`. The gateway + always terminates TLS (self-signed is the implicit floor when no + `gateway.tls.certDir` / ACME is set), so the forge is always + advertised over https. A non-canonical `gateway.httpsPort` is + appended as `:`. - `behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/` The TLS scheme is derived automatically now, so you only need to diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index cb4b0dc9..5c870d0c 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -11,6 +11,14 @@ let forgeCfg = config.services.hyperhive.forge; networkCfg = config.services.hyperhive.network; + # Self-signed TLS is the implicit floor: when neither an operator cert + # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway + # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no + # explicit toggle and no http-only mode — matrix discovery requires https, + # so the gateway always terminates TLS. The deprecated `selfSignedTls` + # option is a no-op kept only so existing configs eval (see warnings). + useSelfSigned = cfg.tls.certDir == null && !cfg.tls.acme.enable; + # Static error pages for `/agent//` mishaps. # Useful pages of nginx's default 404/502 for routes # we've already special-cased. See `docs/gateway.md::Per-agent @@ -152,24 +160,16 @@ in 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:///.well-known/matrix/client` for homeserver - discovery and refuses to fall back to plain http — without - TLS the browser client just won't connect. - - 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. + **DEPRECATED — ignored.** Self-signed TLS is now the implicit + default: when neither `tls.certDir` nor `tls.acme.enable` is + configured, the gateway generates and serves a hive-CA-signed + leaf (see the `hive-tls` module). There is no explicit toggle and + no http-only mode — matrix discovery hardcodes + `https:///.well-known/matrix/client`, so the gateway always + terminates TLS. This option is retained as a no-op so existing + configs eval; setting it (to either value) warns and has no + effect, and it will be removed in a future release. Use + `tls.certDir` or `tls.acme` to override the self-signed default. See `docs/gateway.md` ("Self-signed TLS"). ''; @@ -180,10 +180,10 @@ in default = 443; example = 8443; description = '' - TCP port for the TLS-terminated vhosts. Active when - `selfSignedTls = true` OR `tls.certDir` is set. Default 443. - Setting `selfSignedTls = false` and leaving `tls.certDir = null` - renders this inert (the gateway listens on `port` only). + TCP port for the TLS-terminated vhosts. Default 443. The gateway + always terminates TLS (self-signed is the implicit floor when no + `tls.certDir` / ACME is configured), so this port is always active + alongside the plain-http `port`. ''; }; @@ -195,8 +195,8 @@ in description = '' Path to a host directory containing a TLS certificate and private key for nginx. When set, nginx listens on `httpsPort` - and uses this cert, making `selfSignedTls` unnecessary — - the auto-generated self-signed cert is skipped entirely. + and uses this cert, overriding the self-signed default — the + auto-generated hive-CA-signed leaf is skipped entirely. The directory is bind-mounted read-only into the gateway container at `/run/hive-tls/`. nginx reads @@ -209,15 +209,14 @@ in security.acme.certs."example.com" = { ... }; services.hyperhive.gateway.tls.certDir = config.security.acme.certs."example.com".directory; - services.hyperhive.gateway.selfSignedTls = false; ``` When using an external CA cert, peer hives can declare this hive in `services.hyperhive.swarm.peers` without `certFingerprint` — the standard CA bundle validates. - Mutual exclusion: `selfSignedTls = true` and `tls.certDir` - set together fails an assertion at eval time. + Mutual exclusion with `tls.acme.enable` — set one or the other, + not both. ''; }; @@ -258,13 +257,12 @@ in Let's Encrypt can reach `/.well-known/acme-challenge/`. - `tls.acme.email` must be set (ACME account contact). - Mutual exclusion: `selfSignedTls = true` or `tls.certDir` - set together with `tls.acme.enable = true` fails at eval. + Mutual exclusion: `tls.certDir` set together with + `tls.acme.enable = true` fails at eval — pick one TLS source. Typical setup: ```nix services.hyperhive.gateway = { - selfSignedTls = false; openFirewall = true; tls.acme = { enable = true; @@ -332,10 +330,10 @@ in will lock browsers out until the max-age expires. Only enable this when you are certain TLS is permanent. - Requires TLS to be active (`selfSignedTls = true`, a `tls.certDir`, - or `tls.acme.enable = true`). Enabling HSTS without TLS is - technically harmless (browsers ignore the header over plain HTTP) - but is almost certainly a misconfiguration. + The gateway always terminates TLS now (self-signed floor), so + HSTS is always served over https when enabled — but mind the + warning above: HSTS pins https in the browser, so only enable it + when TLS is permanent for this deployment. ''; }; @@ -374,23 +372,6 @@ in or leave `localHostsEntry` at its default of false. ''; } - { - assertion = !(cfg.selfSignedTls && cfg.tls.certDir != null); - message = '' - services.hyperhive.gateway.selfSignedTls = true and - services.hyperhive.gateway.tls.certDir are mutually exclusive. - Set `selfSignedTls = false` when providing an external cert - via `tls.certDir`. - ''; - } - { - assertion = !(cfg.tls.acme.enable && cfg.selfSignedTls); - message = '' - services.hyperhive.gateway.tls.acme.enable = true and - selfSignedTls = true are mutually exclusive. - Set `selfSignedTls = false` when using ACME. - ''; - } { assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); message = '' @@ -406,16 +387,22 @@ in Let's Encrypt needs a contact address for the ACME account. ''; } - { - assertion = !cfg.hsts.enable || cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable; - message = '' - services.hyperhive.gateway.hsts.enable = true requires TLS to be - configured (selfSignedTls, tls.certDir, or tls.acme.enable). HSTS - over plain HTTP is ignored by browsers and indicates a config error. - ''; - } ]; + # Deprecation surface for the removed `selfSignedTls` toggle. Self-signed + # is now the implicit floor (used whenever neither `tls.certDir` nor + # `tls.acme` is set), so the toggle no longer does anything. Warn only + # when it's set to `false` — that's the case that previously meant + # "http-only / external-only", which no longer exists; `= true` matches + # the effective behaviour and stays silent to avoid noise. + warnings = lib.optional (!cfg.selfSignedTls) '' + services.hyperhive.gateway.selfSignedTls = false is deprecated and + ignored — self-signed TLS is now the default whenever no other TLS + source is configured, and there is no http-only mode. Remove the + setting; configure `tls.certDir` or `tls.acme` to override the + self-signed default. + ''; + # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs # tmpfiles rules make the intent explicit @@ -472,9 +459,9 @@ in isReadOnly = true; }; # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). - # Only mounted when `tls.certDir` is set; mutually exclusive with - # `selfSignedTls = true` (assertion above). nginx reads cert + - # key from `/run/hive-tls/` and `/run/hive-tls/`. + # Only mounted when `tls.certDir` is set; when it is, the self-signed + # floor is off (so the `/run/hive-ca` mount above is absent). nginx + # reads cert + key from `/run/hive-tls/` and ``. bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { hostPath = cfg.tls.certDir; isReadOnly = true; @@ -484,7 +471,7 @@ in # Bind-mount that dir read-only so the in-container import service # (below) can copy the leaf into nginx's state dir with the right # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. - bindMounts."/run/hive-ca" = lib.mkIf cfg.selfSignedTls { + bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { hostPath = config.services.hyperhive.tls.stateDir; isReadOnly = true; }; @@ -493,14 +480,18 @@ in let tlsDir = "/var/lib/hive-gateway/tls"; # TLS cert + key paths inside the container. - # - selfSignedTls=true: generated cert stored in persistent state dir. + # - self-signed (default): imported hive-CA-signed leaf in the + # persistent state dir. # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. tlsCert = if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; tlsKey = if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; - # True when nginx should listen with TLS (any mode). - hasTls = cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable; + # The gateway always terminates TLS now: self-signed is the + # implicit floor (`useSelfSigned`) when neither `tls.certDir` nor + # ACME is set, so there is no http-only mode. Kept as a named + # binding for the vhost listen/ssl wiring below. + hasTls = true; # Listen addresses every vhost shares. Plain http on `cfg.port` # always; `cfg.httpsPort` with TLS sits beside it when TLS is # active (any mode). See `docs/gateway.md` ("TLS modes"). @@ -797,7 +788,7 @@ in # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` # so all sub-domains validate under the same cert + the hive CA. # See `docs/gateway.md` ("Self-signed TLS"). - systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls { + systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { description = "Import host-generated TLS leaf for hive-gateway"; wantedBy = [ "multi-user.target" ]; before = [ "nginx.service" ]; @@ -986,8 +977,10 @@ in networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port - ] - ++ lib.optional (cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable) cfg.httpsPort; + # The gateway always terminates TLS now (self-signed floor), so + # `httpsPort` is always opened alongside the plain-http `port`. + cfg.httpsPort + ]; }; # `/etc/hosts` entries for local dev — bare hive domain + any diff --git a/nix/modules/hive-tls.nix b/nix/modules/hive-tls.nix index e68733cc..b7716c5d 100644 --- a/nix/modules/hive-tls.nix +++ b/nix/modules/hive-tls.nix @@ -12,11 +12,12 @@ let # The host-managed hive CA is the trust anchor for self-signed mode. # It is only stood up when the gateway actually serves a self-signed - # cert: a domain must be set (the leaf SANs derive from it) and - # `gateway.selfSignedTls` must be on. With an operator-supplied - # `tls.certDir` or ACME the public/operator CA already validates, so - # the hive CA is unnecessary. - active = hyperhiveCfg.enable && gatewayCfg.selfSignedTls && domain != null; + # cert: a domain must be set (the leaf SANs derive from it) and the + # gateway must be in self-signed mode — i.e. neither an operator cert + # (`tls.certDir`) nor ACME is configured. With either of those the + # public/operator CA already validates, so the hive CA is unnecessary. + useSelfSigned = gatewayCfg.tls.certDir == null && !gatewayCfg.tls.acme.enable; + active = hyperhiveCfg.enable && useSelfSigned && domain != null; in { # Host-side TLS trust root for the self-signed gateway mode.