1026 lines
46 KiB
Nix
1026 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 = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Run hive-gateway — a single nginx in front of every hyperhive
|
|
surface. On by default: the gateway hosts the matrix GUI static
|
|
dist (when `services.hyperhive.matrix.gui.enable` is true) and
|
|
proxies everything else to hive-c0re's dashboard upstream. Set
|
|
`services.hyperhive.gateway.enable = false` to bypass nginx
|
|
entirely and reach hive-c0re directly on its dashboard port
|
|
(7000 by default).
|
|
|
|
v0 is HTTP-only; TLS / public-domain shape is tracked
|
|
separately.
|
|
'';
|
|
};
|
|
|
|
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 cfg.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.
|
|
# /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 root root - -"
|
|
"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;''}
|
|
'';
|
|
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` — lets systemd
|
|
# resolve the nginx binary path, avoiding exit-203 EXEC failures.
|
|
# after each agents.conf write. A path unit watching the
|
|
# bind-mounted file inside the container was tried first
|
|
# (A path unit inside the container was tried but IN_MOVED_TO from an atomic rename on the host
|
|
# does not propagate across the nspawn mount-namespace boundary,
|
|
# does not cross the mount-namespace boundary. 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 =
|
|
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1`
|
|
# (fluffychat moved to sub-domain root; this
|
|
# keeps bookmarks + deep-links working during the
|
|
# transition). See `docs/gateway.md` for the vhost
|
|
# map.
|
|
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` (sub-domain
|
|
# vhost) when set; falls back to direct `<hive>:<httpPort>`
|
|
# when no gateway target. CORS `*` per matrix spec.
|
|
# See `docs/gateway.md` "Discovery flow" for the full
|
|
# client-bootstrap sequence.
|
|
lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) (
|
|
let
|
|
clientBaseUrl =
|
|
if matrixCfg.gatewayHost != null then
|
|
"${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"
|
|
else
|
|
"${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
|
|
serverHostPort =
|
|
if matrixCfg.gatewayHost != null then
|
|
"${matrixCfg.gatewayHost}${publicPortSuffix}"
|
|
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: hits when an operator
|
|
# requests `/agent/<unknown>/...`. Without this the
|
|
# request falls through to `/` (c0re dashboard) and
|
|
# returns 404 with no useful context. Custom 404
|
|
# page instead. Per-agent `location /agent/<name>/`
|
|
# blocks live in `/run/hive-state/agents.conf` —
|
|
# nginx picks them up via the `include` in
|
|
# `extraConfig` below; the catch-all only matches
|
|
# names that aren't in that file (nginx longest-
|
|
# prefix-match: `/agent/atlas/` beats `/agent/`).
|
|
{
|
|
"/agent/" = {
|
|
extraConfig = ''
|
|
error_page 404 = /__hive_agent_not_found;
|
|
return 404;
|
|
'';
|
|
};
|
|
# Internal static-file locations the error_page
|
|
# directives above point at. `internal` keeps
|
|
# operators from hitting the file directly (only
|
|
# nginx's error-handling can reach it); `alias`
|
|
# serves the exact file regardless of request URI.
|
|
"= /__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 credentials against the htpasswd file
|
|
# bind-mounted at `/run/gateway-auth/`. No extra
|
|
# service or host-side daemon required.
|
|
"/" = {
|
|
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;
|
|
''}
|
|
'';
|
|
};
|
|
}
|
|
// 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;
|
|
'';
|
|
};
|
|
}
|
|
//
|
|
# 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`.
|
|
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`.
|
|
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";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
# 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
|
|
# #14 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
|
|
);
|
|
};
|
|
};
|
|
}
|