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.
This commit is contained in:
atlas 2026-06-03 16:22:21 +02:00 committed by mara
commit 44122c66de
2 changed files with 144 additions and 26 deletions

View file

@ -191,13 +191,66 @@ 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.
'';
};
};
auth = {
enable = lib.mkEnableOption ''
HTTP basic auth on the gateway using an htpasswd file. When
@ -238,6 +291,15 @@ 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`.
'';
}
];
# Ensure bind-mount sources exist at host boot before the gateway
@ -291,23 +353,44 @@ 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 (either mode).
hasTls = cfg.selfSignedTls || cfg.tls.certDir != null;
# 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 (either self-signed or operator-provided cert).
# See `docs/gateway.md` ("Self-signed TLS") for cert lifecycle.
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;
@ -322,7 +405,7 @@ in
# 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 {
vhostTls = lib.optionalAttrs hasTls {
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
@ -331,14 +414,13 @@ in
# 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
{
@ -764,7 +846,8 @@ 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.httpsPort;
};
# `/etc/hosts` entries for local dev — bare hive domain + any