From fb93cbf5c22f4abbd3af7de862d3d3fe9f8ec727 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 16:42:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20tls.acme=20mode=20=E2=80=94=20ngi?= =?UTF-8?q?nx=20inside=20container=20manages=20Let's=20Encrypt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit per mara's suggestion: instead of bind-mounting operator certs, let nginx handle ACME directly inside the gateway container. - tls.acme.enable: lets nginx obtain + renew via HTTP-01 challenge - tls.acme.email: ACME account contact (required when enable=true) - security.acme in container config when acme.enable - hasTls includes acme.enable → https, httpsPort listen, firewall - mutual exclusion assertions: acme vs selfSignedTls vs certDir - docs/gateway.md: four-mode TLS table + ACME section typical setup: selfSignedTls = false; openFirewall = true; tls.acme = { enable = true; email = "admin@example.com"; }; --- docs/gateway.md | 28 +++++++- nix/modules/hive-gateway.nix | 129 +++++++++++++++++++++++++++++------ 2 files changed, 135 insertions(+), 22 deletions(-) diff --git a/docs/gateway.md b/docs/gateway.md index a582ca7c..54f56860 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -160,13 +160,37 @@ frontend-side derivation. ## TLS modes -Three 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`, `tls.certDir = null` | none | `http` | +| 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`) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 2b3fc7f0..a2decf1a 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -249,6 +249,58 @@ in 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 = { @@ -300,6 +352,29 @@ in 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 @@ -378,12 +453,11 @@ in "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; - # True when nginx should listen with TLS (either mode). - hasTls = cfg.selfSignedTls || cfg.tls.certDir != null; + # 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 TLS is - # active (either self-signed or operator-provided cert). - # See `docs/gateway.md` ("Self-signed TLS") for cert lifecycle. + # active (any mode). See `docs/gateway.md` ("TLS modes"). vhostListen = [ { addr = "0.0.0.0"; @@ -395,21 +469,23 @@ in 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 hasTls { - 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 @@ -426,6 +502,17 @@ 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 @@ -847,7 +934,9 @@ in networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = - [ cfg.port ] ++ lib.optional (cfg.selfSignedTls || cfg.tls.certDir != null) cfg.httpsPort; + [ 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