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
|
||||
|
||||
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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue