Compare commits

...
Author SHA1 Message Date
atlas
fb93cbf5c2 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"; };
2026-06-03 16:46:52 +02:00
atlas
6c4b47a7ec docs: note ACME key file permissions for tls.certDir
security.acme defaults key to 0640 root:acme — nginx in the container
can't read it. operator must set group = "nginx" on the ACME cert.

addresses argus yellow note on PR #1153.
2026-06-03 16:46:52 +02:00
atlas
44122c66de feat(#594): gateway operator-cert TLS mode (tls.certDir)
add services.hyperhive.gateway.tls.certDir option: operators with a
CA-signed cert (Let's Encrypt, corporate CA) point at the ACME output
dir instead of using the auto-generated self-signed cert.

- tls.certDir: host path bind-mounted r/o at /run/hive-tls/ in gateway
- tls.certName / tls.keyName: filenames within certDir (default: cert.pem / key.pem, matches nixpkgs security.acme layout)
- hasTls = selfSignedTls || certDir != null: publicScheme=https in both cases
- assertion: selfSignedTls=true + certDir set together is an error
- openFirewall: httpsPort opened in both TLS modes
- docs/gateway.md: TLS modes table + operator-cert section
- docs updated in swarm.md peer config reference in the cert TLS section

when using operator cert, swarm peers can omit certFingerprint —
standard CA bundle handles trust automatically.
2026-06-03 16:46:52 +02:00
2 changed files with 279 additions and 40 deletions

View file

@ -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.<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`)
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://<host>/.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://<hive>/` from the dashboard if they don't care about the cert prompt.
**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.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 `<hive>` + wildcard `*.<hive>` 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://<hive>/`, `https://matrix.<hive>/`, and `https://forge.<hive>/` 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://<hive>/`, `https://matrix.<hive>/`, and `https://forge.<hive>/` 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.<hive>` (the SPA itself) and `<hive>` (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)

View file

@ -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
`<certDir>/<tls.certName>` and `<certDir>/<tls.keyName>`.
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/<certName>` and `/run/hive-tls/<keyName>`.
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.<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 cfg.selfSignedTls {
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
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs).
# When self-signed TLS is on, prefer `https://<host>` (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://<host>` (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