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:
parent
79265e04f8
commit
44122c66de
2 changed files with 144 additions and 26 deletions
|
|
@ -158,21 +158,56 @@ 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
|
||||
|
||||
Three modes:
|
||||
|
||||
| mode | config | cert source | `.well-known` scheme |
|
||||
|---|---|---|---|
|
||||
| self-signed (default) | `selfSignedTls = true` | auto-generated RSA-4096, 10-year | `https` |
|
||||
| operator cert | `selfSignedTls = false` + `tls.certDir` set | bind-mounted from host | `https` |
|
||||
| http-only | `selfSignedTls = false`, `tls.certDir = null` | none | `http` |
|
||||
|
||||
### 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.
|
||||
|
||||
**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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue