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:
parent
6c4b47a7ec
commit
fb93cbf5c2
2 changed files with 135 additions and 22 deletions
|
|
@ -160,13 +160,37 @@ frontend-side derivation.
|
||||||
|
|
||||||
## TLS modes
|
## TLS modes
|
||||||
|
|
||||||
Three modes:
|
Four modes:
|
||||||
|
|
||||||
| mode | config | cert source | `.well-known` scheme |
|
| mode | config | cert source | `.well-known` scheme |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| self-signed (default) | `selfSignedTls = true` | auto-generated RSA-4096, 10-year | `https` |
|
| 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` |
|
| 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`)
|
### Self-signed TLS (`selfSignedTls`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,58 @@ in
|
||||||
to `key.pem` which matches nixpkgs's `security.acme` output.
|
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 = {
|
auth = {
|
||||||
|
|
@ -300,6 +352,29 @@ in
|
||||||
via `tls.certDir`.
|
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
|
# Ensure bind-mount sources exist at host boot before the gateway
|
||||||
|
|
@ -378,12 +453,11 @@ in
|
||||||
"/run/hive-tls/${cfg.tls.keyName}"
|
"/run/hive-tls/${cfg.tls.keyName}"
|
||||||
else
|
else
|
||||||
"${tlsDir}/key.pem";
|
"${tlsDir}/key.pem";
|
||||||
# True when nginx should listen with TLS (either mode).
|
# True when nginx should listen with TLS (any mode).
|
||||||
hasTls = cfg.selfSignedTls || cfg.tls.certDir != null;
|
hasTls = cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable;
|
||||||
# Listen addresses every vhost shares. Plain http on `cfg.port`
|
# Listen addresses every vhost shares. Plain http on `cfg.port`
|
||||||
# always; `cfg.httpsPort` with TLS sits beside it when TLS is
|
# always; `cfg.httpsPort` with TLS sits beside it when TLS is
|
||||||
# active (either self-signed or operator-provided cert).
|
# active (any mode). See `docs/gateway.md` ("TLS modes").
|
||||||
# See `docs/gateway.md` ("Self-signed TLS") for cert lifecycle.
|
|
||||||
vhostListen = [
|
vhostListen = [
|
||||||
{
|
{
|
||||||
addr = "0.0.0.0";
|
addr = "0.0.0.0";
|
||||||
|
|
@ -395,21 +469,23 @@ in
|
||||||
port = cfg.httpsPort;
|
port = cfg.httpsPort;
|
||||||
ssl = true;
|
ssl = true;
|
||||||
};
|
};
|
||||||
# nixos `services.nginx.virtualHosts.<name>` ssl attrs to mix
|
# nixos `services.nginx.virtualHosts.<name>` ssl attrs merged
|
||||||
# into each vhost when self-signed TLS is on. `addSSL = true`
|
# into each vhost. For ACME mode: `enableACME` + `addSSL` —
|
||||||
# is what gates `ssl_certificate` directive emission in the
|
# NixOS's ACME integration manages the cert lifecycle and sets
|
||||||
# nixos nginx module (`hasSSL` checks addSSL / onlySSL /
|
# ssl_certificate automatically. For self-signed / certDir:
|
||||||
# forceSSL). The actual ssl listen is the explicit entry
|
# explicit cert paths. Empty for http-only.
|
||||||
# with `ssl = true` in `vhostListen` — the nixos module's
|
vhostTls =
|
||||||
# auto-listen-generation only kicks in when `listen` is
|
if cfg.tls.acme.enable then
|
||||||
# empty, so the explicit listen wins and there's no
|
{
|
||||||
# duplicate-listen risk. Empty otherwise so the http-only
|
addSSL = true;
|
||||||
# path stays identical.
|
enableACME = true;
|
||||||
vhostTls = lib.optionalAttrs hasTls {
|
}
|
||||||
addSSL = true;
|
else
|
||||||
sslCertificate = tlsCert;
|
lib.optionalAttrs hasTls {
|
||||||
sslCertificateKey = tlsKey;
|
addSSL = true;
|
||||||
};
|
sslCertificate = tlsCert;
|
||||||
|
sslCertificateKey = tlsKey;
|
||||||
|
};
|
||||||
|
|
||||||
# Public-facing scheme + port-suffix for URLs the gateway
|
# Public-facing scheme + port-suffix for URLs the gateway
|
||||||
# mints into responses (well-known JSON, the deprecated
|
# mints into responses (well-known JSON, the deprecated
|
||||||
|
|
@ -426,6 +502,17 @@ in
|
||||||
{
|
{
|
||||||
system.stateVersion = "26.05";
|
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.
|
# Ensure a valid self-signed cert exists before nginx starts.
|
||||||
# nginx `Requires=` this via `requiredBy`, so systemd refuses
|
# nginx `Requires=` this via `requiredBy`, so systemd refuses
|
||||||
# to start nginx until the script succeeds. ALWAYS runs (no
|
# to start nginx until the script succeeds. ALWAYS runs (no
|
||||||
|
|
@ -847,7 +934,9 @@ in
|
||||||
|
|
||||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||||
allowedTCPPorts =
|
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
|
# `/etc/hosts` entries for local dev — bare hive domain + any
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue