diff --git a/docs/gateway.md b/docs/gateway.md index 2496810f..54f56860 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -158,21 +158,88 @@ tracked by `forge.behindGateway`); `external`-kind links are already absolute. See `docs/web-ui/dashboard.md::Container row` for the frontend-side derivation. -## Self-signed TLS (`selfSignedTls`) +## TLS modes + +Four modes: + +| mode | config | cert source | `.well-known` scheme | +|---|---|---|---| +| self-signed (default) | `selfSignedTls = true` | auto-generated RSA-4096, 10-year | `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` | + +### ACME / Let's Encrypt (`tls.acme`) + +Simplest production path for operators with a public domain: + +```nix +services.hyperhive.gateway = { + selfSignedTls = false; + openFirewall = true; + tls.acme = { + enable = true; + email = "admin@example.com"; + }; +}; +``` + +nginx inside the gateway container obtains and auto-renews certs via the ACME HTTP-01 challenge on `port` (default 80). The gateway container shares the host network namespace (`privateNetwork = false`) so outbound ACME requests work without any extra routing. Certs are stored inside the container's persistent state dir (`/var/lib/acme/` inside `hive-gateway`; survives restarts because `ephemeral = false`). + +**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. + +**Swarm peers**: CA-signed certs are trusted by default — remote hives need no `certFingerprint` in `swarm.peers`. + +### Self-signed TLS (`selfSignedTls`) On by default. 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:///.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client cannot bootstrap. For headless agent traffic and operator dashboard reach plain http is fine, so the http listen stays in parallel — operators can keep using `http:///` from the dashboard if they don't care about the cert prompt. +**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https:///.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client cannot bootstrap. **Cert shape**: subject CN = bare hive domain; subjectAltName covers `` + wildcard `*.` 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. +**Regeneration**: the generator unit (`hive-gateway-self-signed-cert.service`) always runs (idempotent). To rotate (e.g. cert leak, expiry approaching), delete `cert.pem` inside the gateway container and restart `nginx.service`. -**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:///`, `https://matrix./`, and `https://forge./` are covered by the same cert, but the browser still prompts per origin. -**Cert prompts**: browsers warn once per host on first visit. With the wildcard SAN, `https:///`, `https://matrix./`, and `https://forge./` 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.` (the SPA itself) and `` (the well-known fetch endpoint). +### Operator-provided cert (`tls.certDir`) -**`.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. +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 +}; +``` + +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. + +**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: + +```nix +security.acme.certs."example.com".group = "nginx"; +``` + +or make the key world-readable (`0644`) if your threat model allows it. nginx errors out at startup on a key it can't read — the error is explicit in the journal, not a silent failure. + +**Peer hive config**: when using a CA-signed cert, peer hives can declare this hive without `certFingerprint` in `swarm.peers` — the standard CA bundle validates: + +```nix +services.hyperhive.swarm.peers."example.com" = { }; # no certFingerprint needed +``` + +### HTTP-only (`selfSignedTls = false`, no `tls.certDir`) + +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. + +**`.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. ## Firewall posture (host-level) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 036b569b..a2decf1a 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -191,13 +191,118 @@ in 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). + 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). ''; }; + tls = { + certDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression ''"/var/lib/acme/example.com"''; + 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. + + The directory is bind-mounted read-only into the gateway + container at `/run/hive-tls/`. nginx reads + `/` and `/`. + Default filenames (`cert.pem` / `key.pem`) match the output + layout of nixpkgs's `security.acme` module. + + Typical ACME setup: + ```nix + 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. + ''; + }; + + certName = lib.mkOption { + type = lib.types.str; + default = "cert.pem"; + description = '' + Filename of the TLS certificate within `tls.certDir`. Defaults + to `cert.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + keyName = lib.mkOption { + type = lib.types.str; + default = "key.pem"; + description = '' + Filename of the TLS private key within `tls.certDir`. Defaults + to `key.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + acme = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Let nginx inside the gateway container obtain and renew TLS + certificates automatically via ACME (Let's Encrypt). When + enabled, each vhost calls out to Let's Encrypt using the + HTTP-01 challenge on `port` (default 80) and stores certs + inside the gateway container's persistent state dir. + + Requirements: + - `services.hyperhive.domain` must be set and publicly + DNS-resolvable to this host. + - `services.hyperhive.gateway.openFirewall = true` so + 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. + + Typical setup: + ```nix + services.hyperhive.gateway = { + selfSignedTls = false; + openFirewall = true; + tls.acme = { + enable = true; + email = "admin@example.com"; + }; + }; + ``` + + After enabling, peer hives can omit `certFingerprint` in + `swarm.peers` — Let's Encrypt certs are CA-trusted + by default. + ''; + }; + + email = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "admin@example.com"; + description = '' + Email address for the ACME account registration with + Let's Encrypt. Required when `tls.acme.enable = true`. + Let's Encrypt sends expiry warnings to this address. + ''; + }; + }; + }; + auth = { enable = lib.mkEnableOption '' HTTP basic auth on the gateway using an htpasswd file. When @@ -238,6 +343,38 @@ 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 = '' + services.hyperhive.gateway.tls.acme.enable = true and + tls.certDir are mutually exclusive. Pick one TLS mode. + ''; + } + { + assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; + message = '' + services.hyperhive.gateway.tls.acme.enable = true requires + services.hyperhive.gateway.tls.acme.email to be set — + Let's Encrypt needs a contact address for the ACME account. + ''; + } ]; # Ensure bind-mount sources exist at host boot before the gateway @@ -291,59 +428,91 @@ in hostPath = "/var/lib/hyperhive/gateway"; 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/`. + bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { + hostPath = cfg.tls.certDir; + isReadOnly = true; + }; config = { pkgs, ... }: let tlsDir = "/var/lib/hive-gateway/tls"; - tlsCert = "${tlsDir}/cert.pem"; - tlsKey = "${tlsDir}/key.pem"; + # TLS cert + key paths inside the container. + # - selfSignedTls=true: generated cert stored in 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; # 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. + # always; `cfg.httpsPort` with TLS sits beside it when TLS is + # active (any mode). See `docs/gateway.md` ("TLS modes"). vhostListen = [ { addr = "0.0.0.0"; port = cfg.port; } ] - ++ lib.optional cfg.selfSignedTls { + ++ lib.optional hasTls { addr = "0.0.0.0"; port = cfg.httpsPort; ssl = true; }; - # nixos `services.nginx.virtualHosts.` 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; - }; + # nixos `services.nginx.virtualHosts.` ssl attrs merged + # into each vhost. For ACME mode: `enableACME` + `addSSL` — + # NixOS's ACME integration manages the cert lifecycle and sets + # ssl_certificate automatically. For self-signed / certDir: + # explicit cert paths. Empty for http-only. + vhostTls = + if cfg.tls.acme.enable then + { + addSSL = true; + enableACME = true; + } + else + lib.optionalAttrs hasTls { + addSSL = true; + sslCertificate = tlsCert; + sslCertificateKey = tlsKey; + }; # Public-facing scheme + port-suffix for URLs the gateway # mints into responses (well-known JSON, the deprecated # `/matrix/*` 301 redirect, future absolute-URL needs). - # When self-signed TLS is on, prefer `https://` (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"). Shared at this scope - # Shared to avoid repetition. - publicScheme = if cfg.selfSignedTls then "https" else "http"; - publicPort = if cfg.selfSignedTls then cfg.httpsPort else cfg.port; - publicPortDefault = if cfg.selfSignedTls then 443 else 80; + # When TLS is active (self-signed OR operator cert), prefer + # `https://` (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"). + publicScheme = if hasTls then "https" else "http"; + publicPort = if hasTls then cfg.httpsPort else cfg.port; + publicPortDefault = if hasTls then 443 else 80; publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; in { system.stateVersion = "26.05"; + # ACME (Let's Encrypt) integration. nginx vhosts set + # `enableACME = true` via `vhostTls`; this provides the + # shared ACME config (acceptTerms + email). The gateway + # container has shared host netns so outbound ACME requests + # work without extra routing config. Certs are stored in the + # container's persistent state (`ephemeral = false`). + security.acme = lib.mkIf cfg.tls.acme.enable { + acceptTerms = true; + defaults.email = cfg.tls.acme.email; + }; + # Ensure a valid self-signed cert exists before nginx starts. # nginx `Requires=` this via `requiredBy`, so systemd refuses # to start nginx until the script succeeds. ALWAYS runs (no @@ -764,7 +933,10 @@ in }; networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ cfg.port ] ++ lib.optional cfg.selfSignedTls cfg.httpsPort; + allowedTCPPorts = + [ cfg.port ] + ++ lib.optional (cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable) + cfg.httpsPort; }; # `/etc/hosts` entries for local dev — bare hive domain + any