feat: add tls.acme mode — nginx inside container manages Let's Encrypt

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"; };
This commit is contained in:
atlas 2026-06-03 16:42:41 +02:00 committed by mara
commit fb93cbf5c2
2 changed files with 135 additions and 22 deletions

View file

@ -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.<domain>`, `matrix.<domain>`) 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`)

View file

@ -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.<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 hasTls {
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
};
# nixos `services.nginx.virtualHosts.<name>` 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