Part of the tracker-tag cleanup: the hive convention is prose, not issue-tracker tags, in code. Reword the 21 tags in the nix tree (flake.nix + the hive-c0re/ci/gateway/network modules) to describe the thing they pointed at, preserving the context without the tag. Comment-only — no eval or logic change. Validated with nix fmt (no reformatting) and nix flake check --no-build (all checks evaluate clean); the full build check was skipped locally because the shared remote builder is degraded, so CI will exercise the build derivations once the runner recovers.
1025 lines
46 KiB
Nix
1025 lines
46 KiB
Nix
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.gateway;
|
|
hyperhiveDomain = config.services.hyperhive.domain;
|
|
matrixCfg = config.services.hyperhive.matrix;
|
|
forgeCfg = config.services.hyperhive.forge;
|
|
networkCfg = config.services.hyperhive.network;
|
|
|
|
# Static error pages for `/agent/<name>/` mishaps.
|
|
# Useful pages of nginx's default 404/502 for routes
|
|
# we've already special-cased. See `docs/gateway.md::Per-agent
|
|
# error pages` for the design rationale + page-vs-status semantics.
|
|
agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } ''
|
|
mkdir -p $out
|
|
cat > $out/not-found.html <<'EOF'
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>agent not found ◆ hyperhive</title>
|
|
<style>
|
|
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
|
h1 { color: #cba6f7; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
|
|
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
|
|
a { color: #89b4fa; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>◆ agent not found</h1>
|
|
<p>No agent matches the requested <code>/agent/<name>/</code> path on this hive.</p>
|
|
<p>Operator: check the agent name in <a href="/">the dashboard</a>.</p>
|
|
</body>
|
|
</html>
|
|
EOF
|
|
cat > $out/unreachable.html <<'EOF'
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>agent unreachable ◆ hyperhive</title>
|
|
<style>
|
|
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
|
h1 { color: #f9e2af; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
|
|
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
|
|
a { color: #89b4fa; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>◆ agent unreachable</h1>
|
|
<p>The agent's harness web server isn't responding. Container restarting, or the agent crashed.</p>
|
|
<p>Operator: <a href="/">dashboard</a> → check the container status / journal; the page will recover on retry once the harness is back up.</p>
|
|
</body>
|
|
</html>
|
|
EOF
|
|
'';
|
|
in
|
|
{
|
|
# Single nginx in front of every hyperhive web surface — dashboard,
|
|
# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known
|
|
# delegations. Container `hive-gateway`, shared host netns,
|
|
# state-free. Full vhost map + discovery flow + design rationale in
|
|
# `docs/gateway.md`.
|
|
|
|
options.services.hyperhive.gateway = {
|
|
# The gateway is always run alongside hyperhive (it's the single nginx
|
|
# in front of every surface and the only thing exposed to the outside);
|
|
# there is no enable flag. An operator who wants their own reverse proxy
|
|
# in front points it at the gateway's `port`. The gateway config below
|
|
# is gated on the top-level `services.hyperhive.enable`.
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 80;
|
|
example = 8080;
|
|
description = ''
|
|
TCP port the gateway listens on. Default 80 (canonical web
|
|
port). nginx inside the container binds <1024 because the
|
|
container's init runs as root; if 80 is already taken on the
|
|
host (existing nginx, traefik, etc.) override to an unused
|
|
port like 8080 or move the conflicting service.
|
|
'';
|
|
};
|
|
|
|
upstreamHost = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "127.0.0.1";
|
|
description = ''
|
|
Host the gateway proxies non-static requests to. Defaults to
|
|
`127.0.0.1` because the gateway container shares the host
|
|
netns, so loopback resolves directly to hive-c0re.
|
|
'';
|
|
};
|
|
|
|
upstreamPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 7000;
|
|
description = ''
|
|
TCP port the gateway proxies non-static requests to. Defaults
|
|
to `7000` (hive-c0re's out-of-the-box dashboard port). Operators
|
|
who change `services.hyperhive.c0re.dashboardPort` should set
|
|
`upstreamPort` to match — kept as a hardcoded default rather
|
|
than a cross-reference to keep this module's options eval
|
|
independent of c0re's option tree shape.
|
|
'';
|
|
};
|
|
|
|
openFirewall = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Open `port` in the host firewall. Off by default (secure-by-default).
|
|
Flip to `true` to expose the gateway to
|
|
the operator's browser / external clients — required for any
|
|
out-of-host reach, since the agents themselves talk to
|
|
hive-c0re via the per-agent unix sockets and don't need the
|
|
nginx vhost. Leave off when running behind another reverse
|
|
proxy (e.g. caddy / traefik on the host) that handles TLS
|
|
termination + forwards to `port`.
|
|
|
|
**Note**: this used to default to `true`. Add
|
|
`services.hyperhive.gateway.openFirewall = true;` to your host
|
|
config if external reach stopped working after a recent upgrade.
|
|
'';
|
|
};
|
|
|
|
localHostsEntry = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Add an `/etc/hosts` entry mapping `services.hyperhive.domain`
|
|
to `127.0.0.1` on the host. Useful for local deployments +
|
|
tests where there's no real DNS for `services.hyperhive.domain`
|
|
but the operator (or browser-based tests) want to hit
|
|
`http://''${services.hyperhive.domain}` to exercise the
|
|
gateway shape. Off by default — operators running with real
|
|
DNS shouldn't have a stale `/etc/hosts` entry sticking
|
|
around. Requires `services.hyperhive.domain` to be set.
|
|
'';
|
|
};
|
|
|
|
selfSignedTls = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
example = false;
|
|
description = ''
|
|
Generate a self-signed TLS cert at first gateway boot and
|
|
listen on `httpsPort` (default 443) with it on every vhost.
|
|
On by default because matrix-dart-sdk (the SDK behind
|
|
FluffyChat + several other Matrix clients) hardcodes
|
|
`https://<host>/.well-known/matrix/client` for homeserver
|
|
discovery and refuses to fall back to plain http — without
|
|
TLS the browser client just won't connect.
|
|
|
|
Self-signed means browsers will show a "not secure" warning
|
|
on first visit; the operator clicks through once per
|
|
browser. For production deployments, set this to `false`
|
|
and front the gateway with a reverse proxy (caddy, traefik,
|
|
or nginx with ACME) that does proper TLS termination.
|
|
|
|
The cert is regenerated on demand if the file is missing
|
|
but never rotated automatically; delete
|
|
`/var/lib/hive-gateway/tls/cert.pem` inside the gateway
|
|
container to force a fresh one.
|
|
|
|
See `docs/gateway.md` ("Self-signed TLS").
|
|
'';
|
|
};
|
|
|
|
httpsPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 443;
|
|
example = 8443;
|
|
description = ''
|
|
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
|
|
enabled, every request to the gateway's main vhost requires a
|
|
valid username and password. nginx's built-in `auth_basic`
|
|
module validates credentials against
|
|
`/var/lib/hyperhive/gateway/gateway.htpasswd` on the host
|
|
(exposed as `/run/hive-state/gateway.htpasswd` inside the
|
|
container via the existing gateway state bind-mount). Off by default.
|
|
|
|
Manage users with `hivectl gateway create-user`, `delete-user`,
|
|
and `list-users` — see `hivectl gateway --help` for usage.
|
|
The htpasswd file is created automatically when auth is enabled;
|
|
add at least one user before enabling to avoid locking everyone out.
|
|
'';
|
|
|
|
realm = lib.mkOption {
|
|
type = lib.types.strMatching "[^\"$]*";
|
|
default = "hyperhive";
|
|
example = "my-hive";
|
|
description = ''
|
|
HTTP Basic auth `realm` value sent in the `WWW-Authenticate`
|
|
header when credentials are absent or rejected. Must not
|
|
contain `"` or `$` (nginx string metacharacters).
|
|
'';
|
|
};
|
|
};
|
|
|
|
hsts = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Add `Strict-Transport-Security` to all gateway vhosts.
|
|
|
|
Disabled by default: HSTS pins HTTPS in the browser's HSTS
|
|
preload list; enabling it on a deployment that later loses TLS
|
|
will lock browsers out until the max-age expires. Only enable
|
|
this when you are certain TLS is permanent.
|
|
|
|
Requires TLS to be active (`selfSignedTls = true`, a `tls.certDir`,
|
|
or `tls.acme.enable = true`). Enabling HSTS without TLS is
|
|
technically harmless (browsers ignore the header over plain HTTP)
|
|
but is almost certainly a misconfiguration.
|
|
'';
|
|
};
|
|
|
|
maxAge = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 31536000;
|
|
example = 86400;
|
|
description = ''
|
|
Value for the `max-age` directive in seconds.
|
|
Default: 31536000 (1 year), which is the value required for
|
|
HSTS preload list submission. Use a shorter value (e.g. 86400)
|
|
while testing so browsers forget the pin quickly.
|
|
'';
|
|
};
|
|
|
|
includeSubDomains = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Whether to include `includeSubDomains` in the HSTS header.
|
|
Only disable this if the gateway host has sub-domains that
|
|
intentionally serve plain HTTP.
|
|
'';
|
|
};
|
|
};
|
|
|
|
};
|
|
|
|
config = lib.mkIf config.services.hyperhive.enable {
|
|
assertions = [
|
|
{
|
|
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
|
|
message = ''
|
|
services.hyperhive.gateway.localHostsEntry = true requires
|
|
services.hyperhive.domain to be set. Either pin a hostname
|
|
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.
|
|
'';
|
|
}
|
|
{
|
|
assertion = !cfg.hsts.enable || cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable;
|
|
message = ''
|
|
services.hyperhive.gateway.hsts.enable = true requires TLS to be
|
|
configured (selfSignedTls, tls.certDir, or tls.acme.enable). HSTS
|
|
over plain HTTP is ignored by browsers and indicates a config error.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# Ensure bind-mount sources exist at host boot before the gateway
|
|
# container's first start. nspawn would auto-create missing dirs
|
|
# tmpfiles rules make the intent explicit
|
|
# and cover the fresh-boot window before c0re has run.
|
|
#
|
|
# /run/hive-agent — per-agent UDS socket dir, written by c0re's
|
|
# set_nspawn_flags when agents start. Owned by `hive-core` (the
|
|
# unprivileged coordinator user, privsep phase 2): c0re does the
|
|
# `create_dir_all(/run/hive-agent/<name>)` itself, so a root-owned
|
|
# parent would EACCES on the very first agent create on a fresh host
|
|
# (hive-priv only chowns the subdir afterwards, it doesn't make it).
|
|
# /var/lib/hyperhive — hyperhive state dir, created by c0re on
|
|
# first run. Also pre-seed agents.conf with an empty-but-valid
|
|
# header so nginx can start + include the file before c0re writes
|
|
# its first real content (f = create-if-absent, no overwrite).
|
|
systemd.tmpfiles.rules = [
|
|
"d /run/hive-agent 0755 hive-core hive-core - -"
|
|
"d /var/lib/hyperhive 0755 root root - -"
|
|
"d /var/lib/hyperhive/gateway 0755 root root - -"
|
|
"f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n"
|
|
# Pre-create the htpasswd file so nginx can open it even before any
|
|
# users have been added. An empty file causes all auth checks to
|
|
# return 401 (no valid credentials), which is the correct no-users
|
|
# behaviour. `f` = create-if-absent, never overwrite.
|
|
"f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -"
|
|
];
|
|
|
|
containers.hive-gateway = {
|
|
autoStart = true;
|
|
ephemeral = false;
|
|
# Share host netns — nginx then binds host-level ports directly,
|
|
# `localhost` upstream resolution reaches hive-c0re without any
|
|
# port-forward dance, and the firewall config below is the only
|
|
# layer that matters.
|
|
privateNetwork = false;
|
|
# Bind-mount the per-agent socket dir so nginx inside the gateway
|
|
# container can `connect(2)` to the UDS upstreams.
|
|
# Read-only (we just connect; harness writes the socket inside
|
|
# the agent's own container). Host-side dir is pre-created by a
|
|
# tmpfiles rule so nspawn always finds a source at boot.
|
|
bindMounts."/run/hive-agent" = {
|
|
hostPath = "/run/hive-agent";
|
|
isReadOnly = true;
|
|
};
|
|
# Bind-mount ONLY the gateway-specific subdir of the hyperhive
|
|
# state dir. Scoped to /var/lib/hyperhive/gateway/ rather than
|
|
# the whole parent so the gateway container can't read forge
|
|
# tokens or other files that may live at the parent level.
|
|
# c0re writes agents.conf under this subdir and triggers an nginx
|
|
# reload from the host via systemd-run after each write.
|
|
# Pre-created by a tmpfiles rule.
|
|
bindMounts."/run/hive-state" = {
|
|
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";
|
|
# 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 TLS is
|
|
# active (any mode). See `docs/gateway.md` ("TLS modes").
|
|
vhostListen = [
|
|
{
|
|
addr = "0.0.0.0";
|
|
port = cfg.port;
|
|
}
|
|
]
|
|
++ lib.optional hasTls {
|
|
addr = "0.0.0.0";
|
|
port = cfg.httpsPort;
|
|
ssl = true;
|
|
};
|
|
# 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 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}";
|
|
|
|
# Security headers added at the server scope on every vhost.
|
|
# nginx's add_header inheritance rule: a location that defines its
|
|
# own add_header does NOT inherit the server-level ones. Any
|
|
# location with its own add_header (e.g. CORS on /.well-known or
|
|
# /_matrix/) must repeat the security headers explicitly — see those
|
|
# locations below. HTML-serving and proxy locations that carry no
|
|
# add_header of their own pick these up from the server scope
|
|
# automatically.
|
|
hstsDirectives = lib.concatStringsSep "; " (
|
|
[ "max-age=${toString cfg.hsts.maxAge}" ]
|
|
++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains"
|
|
);
|
|
securityHeaders = ''
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
|
|
'';
|
|
|
|
# Forge sub-domain vhost. `server_name = forge.domain`, proxies
|
|
# all `/` → forgejo. Tuned for git: `client_max_body_size 1G`,
|
|
# `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on
|
|
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
|
|
# forge isn't behind the gateway.
|
|
forgeVhost = lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) {
|
|
"${forgeCfg.domain}" = vhostTls // {
|
|
listen = vhostListen;
|
|
extraConfig = securityHeaders;
|
|
locations."/" = {
|
|
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
client_max_body_size 1G;
|
|
proxy_read_timeout 1h;
|
|
proxy_send_timeout 1h;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
# Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`.
|
|
# `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll
|
|
# timeout). `/` serves fluffychat or 404 if GUI off. nginx
|
|
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
|
|
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
|
|
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
|
|
"${matrixCfg.gatewayHost}" = vhostTls // {
|
|
listen = vhostListen;
|
|
extraConfig = securityHeaders;
|
|
locations = {
|
|
"/_matrix/" = {
|
|
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
client_max_body_size 50M;
|
|
proxy_read_timeout 1h;
|
|
proxy_send_timeout 1h;
|
|
${securityHeaders}
|
|
add_header Access-Control-Allow-Origin *;
|
|
'';
|
|
};
|
|
}
|
|
// lib.optionalAttrs (matrixCfg.gui.enable) (
|
|
{
|
|
# fluffychat at sub-domain root, SPA-fallback via
|
|
# the Accept-header `$matrix_spa_target` map.
|
|
"/" = {
|
|
alias = "${matrixCfg.gui.package}/";
|
|
extraConfig = ''
|
|
try_files $uri $uri/ $matrix_spa_target =404;
|
|
'';
|
|
};
|
|
}
|
|
// lib.optionalAttrs (hyperhiveDomain != null) {
|
|
# FluffyChat boot-config pre-fill so the client's
|
|
# `.well-known/matrix/client` lookup hits the
|
|
# right delegation endpoint.
|
|
"= /config.json" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
|
'';
|
|
};
|
|
}
|
|
)
|
|
// lib.optionalAttrs (!matrixCfg.gui.enable) {
|
|
"/" = {
|
|
return = "404";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
# `_` (default) server location groups, lifted out of the inline
|
|
# `//`-chain so each conditional group reads on its own. Composed
|
|
# into the `_` vhost's `locations` below alongside the still-inline
|
|
# auth-401 group (a self-contained `lib.optionalAttrs`).
|
|
|
|
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` (legacy deep-link
|
|
# shim during the fluffychat sub-domain move). See `docs/gateway.md`.
|
|
matrixRedirectLocations =
|
|
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null)
|
|
(
|
|
let
|
|
target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}";
|
|
in
|
|
{
|
|
"/matrix/" = {
|
|
extraConfig = ''
|
|
rewrite ^/matrix/(.*)$ ${target}/$1 permanent;
|
|
'';
|
|
};
|
|
}
|
|
);
|
|
|
|
# `.well-known/matrix/{client,server}` discovery JSON. Points
|
|
# clients at `matrixCfg.gatewayHost` when set; falls back to direct
|
|
# `<hive>:<httpPort>`. CORS `*` per matrix spec. The `m.server`
|
|
# port-8448 carve-out is documented inline. See `docs/gateway.md`.
|
|
wellKnownLocations = lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) (
|
|
let
|
|
clientBaseUrl =
|
|
if matrixCfg.gatewayHost != null then
|
|
"${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"
|
|
else
|
|
"${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
|
|
# `m.server` is NOT a URL: per the matrix server-server spec
|
|
# (Resolving Server Names) a delegated host with NO port resolves
|
|
# to the federation default 8448 (after the SRV check) — the
|
|
# https-implies-443 rule does NOT apply here. So the port must be
|
|
# explicit even when it's the HTTPS default; `publicPortSuffix`
|
|
# (which drops :443) is right for the client base_url above but
|
|
# wrong for federation delegation. Without this, peers federate to
|
|
# <gatewayHost>:8448 (closed) while the endpoint actually lives on
|
|
# the gateway's 443 vhost. See docs/gateway.md discovery flow.
|
|
serverHostPort =
|
|
if matrixCfg.gatewayHost != null then
|
|
"${matrixCfg.gatewayHost}:${toString publicPort}"
|
|
else
|
|
"${hyperhiveDomain}:${toString matrixCfg.httpPort}";
|
|
in
|
|
{
|
|
"= /.well-known/matrix/client" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
${securityHeaders}
|
|
add_header Access-Control-Allow-Origin *;
|
|
return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}';
|
|
'';
|
|
};
|
|
"= /.well-known/matrix/server" = {
|
|
extraConfig = ''
|
|
default_type application/json;
|
|
return 200 '{"m.server":"${serverHostPort}"}';
|
|
'';
|
|
};
|
|
}
|
|
);
|
|
|
|
# `/agent/` catch-all 404 + the two internal error-page targets it
|
|
# points at. Per-agent `location /agent/<name>/` blocks live in the
|
|
# runtime-generated `/run/hive-state/agents.conf` (included via
|
|
# `extraConfig` on the vhost); nginx longest-prefix-match makes a
|
|
# real `/agent/<name>/` beat this catch-all. `internal` keeps the
|
|
# error pages reachable only through nginx's error handling.
|
|
agentLocations = {
|
|
"/agent/" = {
|
|
extraConfig = ''
|
|
error_page 404 = /__hive_agent_not_found;
|
|
return 404;
|
|
'';
|
|
};
|
|
"= /__hive_agent_not_found" = {
|
|
extraConfig = ''
|
|
internal;
|
|
alias ${agentErrorPagesDir}/not-found.html;
|
|
default_type text/html;
|
|
'';
|
|
};
|
|
"= /__hive_agent_unreachable" = {
|
|
extraConfig = ''
|
|
internal;
|
|
alias ${agentErrorPagesDir}/unreachable.html;
|
|
default_type text/html;
|
|
'';
|
|
};
|
|
};
|
|
|
|
# Everything else proxies to hive-c0re. Upgrade headers stay set so
|
|
# SSE (`/dashboard/stream`, `/events/stream`) + websocket
|
|
# (`/screen/ws`) endpoints keep working transparently. When auth is
|
|
# enabled, nginx's built-in `auth_basic` validates against the
|
|
# bind-mounted htpasswd; the `=401` error_page points at the
|
|
# internal unauthorized page (the auth-only location below).
|
|
dashboardProxyLocation = {
|
|
"/" = {
|
|
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
|
proxyWebsockets = true;
|
|
extraConfig = ''
|
|
proxy_buffering off;
|
|
proxy_read_timeout 1d;
|
|
${lib.optionalString cfg.auth.enable ''
|
|
auth_basic "${cfg.auth.realm}";
|
|
# htpasswd file lives in the gateway state dir,
|
|
# already bind-mounted read-only at /run/hive-state/.
|
|
# Host path: /var/lib/hyperhive/gateway/gateway.htpasswd
|
|
auth_basic_user_file /run/hive-state/gateway.htpasswd;
|
|
# Serve a custom page when credentials are missing or wrong.
|
|
# `=401` forces the final status to remain 401 so browsers
|
|
# still present the login dialog on first visit; users who
|
|
# dismiss the dialog see a page explaining how to add users
|
|
# with `hivectl gateway create-user`.
|
|
# The exact-match location below beats `location /` in nginx's
|
|
# prefix ordering, so the internal subrequest does not loop back
|
|
# through auth_basic.
|
|
error_page 401 =401 /__hive_auth_unauthorized;
|
|
''}
|
|
'';
|
|
};
|
|
};
|
|
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
|
|
# ConditionPathExists) and is idempotent — that's necessary
|
|
# to reconcile broken state left over from prior failed
|
|
# boots (a 0700 dir from a stale UMask, a truncated cert
|
|
# from an interrupted oneshot, etc.) which a guarded-on-
|
|
# missing-cert script would silently skip and leave broken.
|
|
# Cert covers the bare hive domain plus `*.${hyperhiveDomain}`
|
|
# so the matrix + forge sub-domains are valid under the same
|
|
# cert. See `docs/gateway.md` ("Self-signed TLS").
|
|
systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls {
|
|
description = "Ensure self-signed TLS cert for hive-gateway";
|
|
wantedBy = [ "multi-user.target" ];
|
|
before = [ "nginx.service" ];
|
|
requiredBy = [ "nginx.service" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
};
|
|
path = [
|
|
pkgs.openssl
|
|
pkgs.coreutils
|
|
];
|
|
script =
|
|
let
|
|
subjectCN = if hyperhiveDomain != null then hyperhiveDomain else "hyperhive.local";
|
|
# `subjectAltName` covers the bare hive + the canonical
|
|
# sub-domains. Includes a wildcard `*.${domain}` so any
|
|
# future sub-domain we mint (per-agent UI under a
|
|
# sub-domain, etc.) inherits the cert without a rebuild.
|
|
sanLines = lib.concatStringsSep "," (
|
|
[ "DNS:${subjectCN}" ] ++ lib.optional (hyperhiveDomain != null) "DNS:*.${hyperhiveDomain}"
|
|
);
|
|
in
|
|
''
|
|
set -eu
|
|
mkdir -p ${tlsDir}
|
|
# 0755 on BOTH the cert dir and its parent so the
|
|
# nginx user can traverse the full path. The parent
|
|
# `/var/lib/hive-gateway` lands at 0700 by default
|
|
# (systemd StateDirectory / mkdir umask depending on
|
|
# which service created it first), which on its own
|
|
# blocks traversal. Re-applied every boot in case a
|
|
# prior run left a tighter mode behind.
|
|
chmod 0755 ${builtins.dirOf tlsDir}
|
|
chmod 0755 ${tlsDir}
|
|
# Generate the cert when EITHER the cert or key is
|
|
# missing/empty, OR the cert fails an openssl parse —
|
|
# catches truncated / corrupt leftovers from a previous
|
|
# interrupted run AND the "cert clean but key absent"
|
|
# edge case (argus 🟡 on the first revision) which
|
|
# otherwise tripped `chmod 0600 ${tlsKey}` below with
|
|
# ENOENT under `set -eu`. The whole oneshot is safe to
|
|
# re-run; a healthy cert+key pair is left alone.
|
|
if [ ! -s ${tlsCert} ] || [ ! -s ${tlsKey} ] || ! openssl x509 -in ${tlsCert} -noout >/dev/null 2>&1; then
|
|
echo "generating fresh self-signed cert at ${tlsCert}"
|
|
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
|
|
-keyout ${tlsKey} \
|
|
-out ${tlsCert} \
|
|
-subj "/CN=${subjectCN}" \
|
|
-addext "subjectAltName=${sanLines}"
|
|
fi
|
|
# Key owned by root:nginx, mode 0640 so nginx-pre-start
|
|
# (which runs `nginx -t` as the nginx user, not root)
|
|
# can read it. A 0600 root:root key passes the master-
|
|
# process load (master starts as root) but fails the
|
|
# pre-start config test with `BIO_new_file() …
|
|
# Permission denied`, blocking the unit from starting
|
|
# at all. Cert is world-readable.
|
|
chown root:nginx ${tlsKey}
|
|
chmod 0640 ${tlsKey}
|
|
chmod 0644 ${tlsCert}
|
|
'';
|
|
};
|
|
|
|
# nginx reload is triggered from the HOST side by hive-c0re
|
|
# via `systemctl -M hive-gateway reload nginx` after each
|
|
# agents.conf write — letting systemd resolve the nginx binary
|
|
# path avoids exit-203 EXEC failures. A path unit watching the
|
|
# bind-mounted file inside the container was tried first but
|
|
# doesn't work: an IN_MOVED_TO from an atomic rename on the host
|
|
# does not propagate across the nspawn mount-namespace boundary.
|
|
# The host-side trigger is the correct approach.
|
|
|
|
services.nginx = {
|
|
enable = true;
|
|
recommendedProxySettings = true;
|
|
recommendedTlsSettings = true;
|
|
recommendedGzipSettings = true;
|
|
recommendedOptimisation = true;
|
|
# Accept-header SPA fallback: navigations
|
|
# (`Accept: text/html,...`) fall to index.html, asset
|
|
# fetches (Accept *anything else*) fall to a sentinel
|
|
# nonexistent path → `try_files` returns 404. Pattern
|
|
# detailed in `docs/gateway.md` ("SPA fallback").
|
|
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
|
map $http_accept $matrix_spa_target {
|
|
default "/__matrix_spa_no_html_fallback";
|
|
"~*text/html" "/index.html";
|
|
}
|
|
'';
|
|
virtualHosts = {
|
|
"_" = vhostTls // {
|
|
listen = vhostListen;
|
|
locations =
|
|
matrixRedirectLocations
|
|
// wellKnownLocations
|
|
// agentLocations
|
|
// dashboardProxyLocation
|
|
// lib.optionalAttrs cfg.auth.enable {
|
|
# Internal-only target for the 401 error_page above.
|
|
# `internal` prevents direct client access; `alias` serves
|
|
# the pre-built HTML from the Nix store.
|
|
"= /__hive_auth_unauthorized" =
|
|
let
|
|
page = pkgs.writeText "hive-gateway-unauthorized.html" ''
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>unauthorized ◆ hyperhive</title>
|
|
<style>
|
|
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
|
h1 { color: #f38ba8; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
p { max-width: 36rem; margin: 0.5rem auto; color: #a6adc8; }
|
|
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; font-size: 0.92em; }
|
|
pre { background: #181825; color: #cdd6f4; text-align: left; display: inline-block; padding: 0.75rem 1.25rem; border-radius: 0.4rem; margin: 0.75rem 0; font-size: 0.88em; line-height: 1.6; }
|
|
.hint { color: #a6adc8; font-size: 0.9em; margin-top: 1.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>◆ unauthorized</h1>
|
|
<p>This hive is protected by HTTP Basic auth. Valid credentials are required.</p>
|
|
<p class="hint">Operator: add a user with <code>hivectl gateway create-user</code>:</p>
|
|
<pre>hivectl gateway create-user \
|
|
<username> --password-stdin</pre>
|
|
<p class="hint">Then reload your browser and enter the credentials when prompted.</p>
|
|
</body>
|
|
</html>
|
|
'';
|
|
in
|
|
{
|
|
extraConfig = ''
|
|
internal;
|
|
alias ${page};
|
|
default_type text/html;
|
|
'';
|
|
};
|
|
};
|
|
# Per-agent location blocks, generated at runtime by
|
|
# hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf
|
|
# on the host. The bind-mount at /run/hive-state/ exposes
|
|
# that file here. nginx parses `include` at config-load
|
|
# time so a reload (triggered by c0re via systemd-run
|
|
# after each agents.conf write) picks up new or removed
|
|
# agents without a nixos-rebuild. nginx's longest-prefix-
|
|
# match rule ensures `/agent/<name>/` from this file beats
|
|
# the `/agent/` catch-all above.
|
|
extraConfig = securityHeaders + ''
|
|
include /run/hive-state/agents.conf;
|
|
'';
|
|
};
|
|
}
|
|
// forgeVhost
|
|
// matrixVhost;
|
|
};
|
|
|
|
# Hive-internal DNS resolver, co-located in the
|
|
# gateway container — single
|
|
# front-door for both DNS and HTTP, saves a sibling
|
|
# container. Listens on the bridge interface from
|
|
# `services.hyperhive.network`; authoritative for the hive
|
|
# domain + sub-domains, forwards everything else upstream.
|
|
# No-op when `network.enable = false`.
|
|
services.dnsmasq = lib.mkIf networkCfg.enable {
|
|
enable = true;
|
|
# Don't substitute the container's /etc/resolv.conf —
|
|
# the gateway uses the host's resolver for its own
|
|
# outbound traffic; dnsmasq is purely for incoming
|
|
# queries from agent containers.
|
|
resolveLocalQueries = false;
|
|
settings = {
|
|
# Bind only on the bridge interface (and lo for
|
|
# health-checks). Outside hosts can't even see the
|
|
# listener.
|
|
interface = [
|
|
networkCfg.bridgeName
|
|
"lo"
|
|
];
|
|
bind-interfaces = true;
|
|
port = 53;
|
|
# Don't read /etc/resolv.conf — we control upstream
|
|
# explicitly to dodge dependency on the gateway
|
|
# container's own resolver state.
|
|
no-resolv = true;
|
|
server = networkCfg.upstreamDns;
|
|
# Hive authoritative records — answer queries for the
|
|
# hive domain + its sub-domains with the bridge IP
|
|
# (where nginx is reachable from container netns once
|
|
# per-agent netns isolation lands; today it's the host
|
|
# loopback alias and works in either shape).
|
|
#
|
|
# The forge / matrix entries are redundant in the
|
|
# common case where `forge.domain` /
|
|
# `matrix.gatewayHost` are sub-domains of
|
|
# `hyperhive.domain` — dnsmasq's `/<domain>/` rule
|
|
# already matches sub-domains.
|
|
# Kept explicit because operators can override either
|
|
# to a cross-domain hostname (e.g.
|
|
# `forge.domain = "git.example.com"`); listing them
|
|
# explicitly keeps that case routed without needing
|
|
# an extra config block.
|
|
address = [
|
|
"/${hyperhiveDomain}/${networkCfg.bridgeIp}"
|
|
]
|
|
++ lib.optional (
|
|
(forgeCfg.enable or false) && (forgeCfg.behindGateway or false)
|
|
) "/${forgeCfg.domain}/${networkCfg.bridgeIp}"
|
|
++ lib.optional (
|
|
matrixCfg.enable && matrixCfg.gatewayHost != null
|
|
) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
|
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
|
|
# sub-domain modules that are on. `lib.unique` dedupes if any
|
|
# sub-domain happens to equal another. See `docs/gateway.md`
|
|
# ("Local dev").
|
|
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
|
|
"127.0.0.1" = lib.unique (
|
|
[ hyperhiveDomain ]
|
|
++ lib.optional (
|
|
(config.services.hyperhive.forge.enable or false)
|
|
&& (config.services.hyperhive.forge.behindGateway or false)
|
|
) config.services.hyperhive.forge.domain
|
|
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
|
|
);
|
|
};
|
|
};
|
|
}
|