refactor: split hive-gateway into module dir, DHCP range into hive-network
This commit is contained in:
parent
ecaad48fad
commit
5f9af9fc97
8 changed files with 1143 additions and 1075 deletions
|
|
@ -123,7 +123,7 @@ in
|
|||
imports = [
|
||||
./hive-ci.nix
|
||||
./hive-forge.nix
|
||||
./hive-gateway.nix
|
||||
./hive-gateway
|
||||
./hive-matrix.nix
|
||||
./hive-network.nix
|
||||
./hive-tls.nix
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
298
nix/modules/hive-gateway/default.nix
Normal file
298
nix/modules/hive-gateway/default.nix
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# Single nginx in front of every hyperhive web surface — dashboard,
|
||||
# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known
|
||||
# delegations — plus the hive-internal dnsmasq resolver, co-located in
|
||||
# the same `hive-gateway` container (shared host netns, state-free).
|
||||
# Full vhost map + discovery flow + design rationale in
|
||||
# `docs/gateway.md`. Layout: ./options.nix (option declarations),
|
||||
# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix
|
||||
# (styled static pages), ./dnsmasq.nix (resolver + DHCP config).
|
||||
{
|
||||
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;
|
||||
|
||||
# Dashboard SPA dist, static-served by nginx. Read in OUTER scope so
|
||||
# `config` is the host's (inside the container block it'd be the
|
||||
# container's).
|
||||
dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard";
|
||||
|
||||
# Self-signed TLS is the implicit floor: when neither an operator cert
|
||||
# (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway
|
||||
# generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no
|
||||
# explicit toggle and no http-only mode — matrix discovery requires https,
|
||||
# so the gateway always terminates TLS. The deprecated `selfSignedTls`
|
||||
# option is a no-op kept only so existing configs eval (see warnings).
|
||||
# `cfg.useSelfSigned` (options.nix) is the derived single source of truth.
|
||||
useSelfSigned = cfg.useSelfSigned;
|
||||
in
|
||||
{
|
||||
imports = [ ./options.nix ];
|
||||
|
||||
config = lib.mkIf config.services.hyperhive.enable {
|
||||
assertions = [
|
||||
{
|
||||
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.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Deprecation surface for the removed `selfSignedTls` toggle. Self-signed
|
||||
# is now the implicit floor (used whenever neither `tls.certDir` nor
|
||||
# `tls.acme` is set), so the toggle no longer does anything. Warn only
|
||||
# when it's set to `false` — that's the case that previously meant
|
||||
# "http-only / external-only", which no longer exists; `= true` matches
|
||||
# the effective behaviour and stays silent to avoid noise.
|
||||
warnings = lib.optional (!cfg.selfSignedTls) ''
|
||||
services.hyperhive.gateway.selfSignedTls = false is deprecated and
|
||||
ignored — self-signed TLS is now the default whenever no other TLS
|
||||
source is configured, and there is no http-only mode. Remove the
|
||||
setting; configure `tls.certDir` or `tls.acme` to override the
|
||||
self-signed default.
|
||||
'';
|
||||
|
||||
# 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): 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;
|
||||
# dnsmasq refuses to start once a dhcp-range is configured unless it
|
||||
# holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network
|
||||
# containers retain NET_ADMIN implicitly, but this container shares the
|
||||
# host netns (above), so nspawn's default bounding set drops it — grant
|
||||
# it explicitly. Note this is NET_ADMIN over the *host* netns; the
|
||||
# gateway container is trusted infra (it already terminates TLS and
|
||||
# fronts every vhost), so no new trust boundary is crossed.
|
||||
additionalCapabilities = [ "CAP_NET_ADMIN" ];
|
||||
# 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; when it is, the self-signed
|
||||
# floor is off (so the `/run/hive-ca` mount below is absent). nginx
|
||||
# reads cert + key from `/run/hive-tls/<certName>` and `<keyName>`.
|
||||
bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) {
|
||||
hostPath = cfg.tls.certDir;
|
||||
isReadOnly = true;
|
||||
};
|
||||
# Self-signed mode: the host `hive-tls-ca` service generates a hive
|
||||
# CA + a leaf signed by it under `services.hyperhive.tls.stateDir`.
|
||||
# Bind-mount that dir read-only so the in-container import service
|
||||
# (below) can copy the leaf into nginx's state dir with the right
|
||||
# owner/mode. Source files: `gateway.pem` + `gateway-key.pem`.
|
||||
bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned {
|
||||
hostPath = config.services.hyperhive.tls.stateDir;
|
||||
isReadOnly = true;
|
||||
};
|
||||
config =
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
tlsDir = "/var/lib/hive-gateway/tls";
|
||||
# TLS cert + key paths inside the container.
|
||||
# - self-signed (default): imported hive-CA-signed leaf in the
|
||||
# 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";
|
||||
nginxTree = import ./vhosts.nix {
|
||||
inherit
|
||||
lib
|
||||
cfg
|
||||
forgeCfg
|
||||
matrixCfg
|
||||
hyperhiveDomain
|
||||
dashboardDist
|
||||
tlsCert
|
||||
tlsKey
|
||||
;
|
||||
errorPages = import ./error-pages.nix { inherit pkgs; };
|
||||
};
|
||||
in
|
||||
{
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
# ACME (Let's Encrypt) integration. nginx vhosts set
|
||||
# `enableACME = true` via the vhost builder; 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;
|
||||
};
|
||||
|
||||
# Import the host-generated leaf cert before nginx starts.
|
||||
# The hive CA + gateway leaf are generated on the HOST by
|
||||
# `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only
|
||||
# at `/run/hive-ca`; this service copies the leaf into nginx's
|
||||
# state dir with the owner/mode nginx needs, rather than reading
|
||||
# the bind-mount directly (the host key is 0600 root:root and a
|
||||
# cross-namespace bind-mount can't be relaxed in place). nginx
|
||||
# `Requires=` this via `requiredBy`, so it refuses to start until
|
||||
# the copy succeeds. ALWAYS runs (no ConditionPathExists) and is
|
||||
# idempotent — necessary to reconcile broken state from prior
|
||||
# failed boots (a 0700 dir from a stale UMask, a truncated copy
|
||||
# from an interrupted oneshot, etc.). The leaf covers the bare
|
||||
# hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}`
|
||||
# so all sub-domains validate under the same cert + the hive CA.
|
||||
# See `docs/gateway.md` ("Self-signed TLS").
|
||||
systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned {
|
||||
description = "Import host-generated TLS leaf for hive-gateway";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "nginx.service" ];
|
||||
requiredBy = [ "nginx.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "hive-gateway-self-signed-cert";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
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}
|
||||
# Copy the host leaf in. `install` writes atomically with the
|
||||
# target mode; run as root (container root == host root,
|
||||
# privateUsers=false) so the 0600 root:root host key is
|
||||
# readable. Key ends up root:nginx 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 load but
|
||||
# fails the pre-start config test with `BIO_new_file() …
|
||||
# Permission denied`, blocking the unit. Cert is world-read.
|
||||
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
|
||||
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
|
||||
'';
|
||||
};
|
||||
|
||||
# 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 does not 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;
|
||||
inherit (nginxTree) appendHttpConfig virtualHosts;
|
||||
};
|
||||
|
||||
services.dnsmasq = lib.mkIf networkCfg.enable (
|
||||
import ./dnsmasq.nix {
|
||||
inherit
|
||||
lib
|
||||
networkCfg
|
||||
forgeCfg
|
||||
matrixCfg
|
||||
hyperhiveDomain
|
||||
;
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||
allowedTCPPorts = [
|
||||
cfg.port
|
||||
# The gateway always terminates TLS (self-signed floor), so
|
||||
# `httpsPort` is always opened alongside the plain-http `port`.
|
||||
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 {
|
||||
"127.0.0.1" = lib.unique (
|
||||
[ hyperhiveDomain ]
|
||||
++ lib.optional (config.services.hyperhive.forge.behindGateway or false
|
||||
) config.services.hyperhive.forge.domain
|
||||
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
59
nix/modules/hive-gateway/dnsmasq.nix
Normal file
59
nix/modules/hive-gateway/dnsmasq.nix
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Hive-internal DNS resolver + DHCP, 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. Returns the
|
||||
# `services.dnsmasq` value for the container config (see
|
||||
# ./default.nix); the DHCP pool bounds are computed by hive-network.
|
||||
{
|
||||
lib,
|
||||
networkCfg,
|
||||
forgeCfg,
|
||||
matrixCfg,
|
||||
hyperhiveDomain,
|
||||
}:
|
||||
{
|
||||
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 every container netns.
|
||||
#
|
||||
# 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.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}"
|
||||
++ lib.optional (
|
||||
matrixCfg.enable && matrixCfg.gatewayHost != null
|
||||
) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}";
|
||||
# DHCP pool covering all usable host addresses on the bridge
|
||||
# subnet — bounds computed by hive-network.nix from
|
||||
# bridgeIp/bridgePrefixLength. All containers (agents and service
|
||||
# containers such as hive-ci) receive their IPs dynamically.
|
||||
dhcp-range = "${networkCfg.dhcpRangeStart},${networkCfg.dhcpRangeEnd},1h";
|
||||
dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases";
|
||||
};
|
||||
}
|
||||
71
nix/modules/hive-gateway/error-pages.nix
Normal file
71
nix/modules/hive-gateway/error-pages.nix
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Static error/help pages the gateway serves for routes it has
|
||||
# special-cased, all rendered from one Catppuccin-styled template.
|
||||
# Useful pages instead of nginx's default 404/502 — see
|
||||
# `docs/gateway.md::Per-agent error pages` for the design rationale +
|
||||
# page-vs-status semantics. Consumed by ./vhosts.nix.
|
||||
{ pkgs }:
|
||||
let
|
||||
mkPage =
|
||||
{
|
||||
name,
|
||||
title,
|
||||
accent,
|
||||
body,
|
||||
}:
|
||||
pkgs.writeText "hive-gateway-${name}.html" ''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title} ◆ 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: ${accent}; 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; }
|
||||
a { color: #89b4fa; }
|
||||
.hint { color: #a6adc8; font-size: 0.9em; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>◆ ${title}</h1>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
'';
|
||||
in
|
||||
{
|
||||
notFound = mkPage {
|
||||
name = "agent-not-found";
|
||||
title = "agent not found";
|
||||
accent = "#cba6f7";
|
||||
body = ''
|
||||
<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>
|
||||
'';
|
||||
};
|
||||
|
||||
unreachable = mkPage {
|
||||
name = "agent-unreachable";
|
||||
title = "agent unreachable";
|
||||
accent = "#f9e2af";
|
||||
body = ''
|
||||
<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>
|
||||
'';
|
||||
};
|
||||
|
||||
unauthorized = mkPage {
|
||||
name = "unauthorized";
|
||||
title = "unauthorized";
|
||||
accent = "#f38ba8";
|
||||
body = ''
|
||||
<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>
|
||||
'';
|
||||
};
|
||||
}
|
||||
310
nix/modules/hive-gateway/options.nix
Normal file
310
nix/modules/hive-gateway/options.nix
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
# Option declarations for `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`.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.gateway;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.gateway = {
|
||||
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 = ''
|
||||
**DEPRECATED — ignored.** Self-signed TLS is now the implicit
|
||||
default: when neither `tls.certDir` nor `tls.acme.enable` is
|
||||
configured, the gateway generates and serves a hive-CA-signed
|
||||
leaf (see the `hive-tls` module). There is no explicit toggle and
|
||||
no http-only mode — matrix discovery hardcodes
|
||||
`https://<host>/.well-known/matrix/client`, so the gateway always
|
||||
terminates TLS. This option is retained as a no-op so existing
|
||||
configs eval; setting it (to either value) warns and has no
|
||||
effect, and it will be removed in a future release. Use
|
||||
`tls.certDir` or `tls.acme` to override the self-signed default.
|
||||
|
||||
See `docs/gateway.md` ("Self-signed TLS").
|
||||
'';
|
||||
};
|
||||
|
||||
useSelfSigned = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = cfg.tls.certDir == null && !cfg.tls.acme.enable;
|
||||
defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable";
|
||||
description = ''
|
||||
Read-only derived flag: `true` when the gateway serves the
|
||||
self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor
|
||||
`tls.acme.enable` is configured. Single source of truth for the
|
||||
self-signed condition; consumed by the `hive-tls` and `hive-ci`
|
||||
modules so the derivation isn't duplicated. Internal — not meant to
|
||||
be set by operators (use `tls.certDir` / `tls.acme` to override the
|
||||
self-signed default).
|
||||
'';
|
||||
};
|
||||
|
||||
httpsPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
example = 8443;
|
||||
description = ''
|
||||
TCP port for the TLS-terminated vhosts. Default 443. The gateway
|
||||
always terminates TLS (self-signed is the implicit floor when no
|
||||
`tls.certDir` / ACME is configured), so this port is always active
|
||||
alongside the plain-http `port`.
|
||||
'';
|
||||
};
|
||||
|
||||
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, overriding the self-signed default — the
|
||||
auto-generated hive-CA-signed leaf 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;
|
||||
```
|
||||
|
||||
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 with `tls.acme.enable` — set one or the other,
|
||||
not both.
|
||||
'';
|
||||
};
|
||||
|
||||
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: `tls.certDir` set together with
|
||||
`tls.acme.enable = true` fails at eval — pick one TLS source.
|
||||
|
||||
Typical setup:
|
||||
```nix
|
||||
services.hyperhive.gateway = {
|
||||
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.
|
||||
|
||||
The gateway always terminates TLS (self-signed floor), so
|
||||
HSTS is always served over https when enabled — but mind the
|
||||
warning above: HSTS pins https in the browser, so only enable it
|
||||
when TLS is permanent for this deployment.
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
353
nix/modules/hive-gateway/vhosts.nix
Normal file
353
nix/modules/hive-gateway/vhosts.nix
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
# nginx virtual-host tree for the gateway container: the `_` default
|
||||
# server (dashboard, per-agent routing, matrix discovery), the forge
|
||||
# and matrix sub-domain vhosts, and the Accept-header SPA map for the
|
||||
# matrix GUI. Pure function — called from ./default.nix inside the
|
||||
# container config with the outer-scope config values as arguments;
|
||||
# returns `{ virtualHosts, appendHttpConfig }`.
|
||||
{
|
||||
lib,
|
||||
cfg, # services.hyperhive.gateway
|
||||
forgeCfg,
|
||||
matrixCfg,
|
||||
hyperhiveDomain,
|
||||
dashboardDist,
|
||||
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
}:
|
||||
let
|
||||
# The gateway always terminates TLS: self-signed is the implicit
|
||||
# floor when neither `tls.certDir` nor ACME is set, so there is no
|
||||
# http-only mode. Kept as a named binding for the vhost listen/ssl
|
||||
# wiring below.
|
||||
hasTls = true;
|
||||
# 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.
|
||||
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.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;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// {
|
||||
# FluffyChat boot-config pre-fill so the client's
|
||||
# `.well-known/matrix/client` lookup hits the
|
||||
# right delegation endpoint. `domain` is required, so
|
||||
# this is always present.
|
||||
"= /config.json" = {
|
||||
extraConfig = ''
|
||||
default_type application/json;
|
||||
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
|
||||
'';
|
||||
};
|
||||
}
|
||||
)
|
||||
// lib.optionalAttrs (!matrixCfg.gui.enable) {
|
||||
"/" = {
|
||||
return = "404";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# `<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 (
|
||||
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 ${errorPages.notFound};
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
"= /__hive_agent_unreachable" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${errorPages.unreachable};
|
||||
default_type text/html;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Shared auth block — separate locations don't inherit auth_basic, so
|
||||
# each dashboard location (`/`, `/api/`) needs it or that surface is
|
||||
# unauthed. `/webhook/` is intentionally excluded: Forgejo cannot
|
||||
# send HTTP Basic credentials with webhook deliveries, and the HMAC
|
||||
# secret (`X-Hub-Signature-256`) protects those endpoints instead.
|
||||
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
||||
auth_basic "${cfg.auth.realm}";
|
||||
auth_basic_user_file /run/hive-state/gateway.htpasswd;
|
||||
# `=401` keeps the status 401 so the login dialog shows; the
|
||||
# internal page explains `hivectl gateway create-user`.
|
||||
error_page 401 =401 /__hive_auth_unauthorized;
|
||||
'';
|
||||
|
||||
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
|
||||
# is by PATH, never content-type. c0re serves exactly two prefixes —
|
||||
# `/api/` (all dashboard data + actions + the SSE streams) and
|
||||
# `/webhook/` (knowledge push + config-PR approval triggers, HMAC-
|
||||
# guarded) — so those proxy to c0re and everything else serves the
|
||||
# dist with an SPA fallback to index.html. Path routing is
|
||||
# deterministic where an Accept-header split would make the SAME url
|
||||
# behave differently by content-type (e.g. `/api/state` fetched with
|
||||
# `Accept: text/html` wrongly getting index.html). A new top-level
|
||||
# c0re route prefix (beyond /api + /webhook) needs a matching
|
||||
# location added here.
|
||||
dashboardProxyLocation = {
|
||||
"/" = {
|
||||
root = dashboardDist;
|
||||
extraConfig = ''
|
||||
try_files $uri /index.html;
|
||||
${dashboardAuth}
|
||||
'';
|
||||
};
|
||||
"/api/" = {
|
||||
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
# off + 1d keep the SSE streams (/api/dashboard/stream,
|
||||
# /api/build-logs/id/{id}/stream) live.
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1d;
|
||||
${dashboardAuth}
|
||||
'';
|
||||
};
|
||||
"/webhook/" = {
|
||||
# No dashboardAuth here: Forgejo cannot send HTTP Basic credentials
|
||||
# with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth
|
||||
# for these endpoints; hive-c0re verifies it in the handler.
|
||||
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
|
||||
# "SPA fallback"): text/html → index.html, else a sentinel so
|
||||
# try_files falls through to 404. The dashboard doesn't use an
|
||||
# Accept-header map — it routes by path (see dashboardProxyLocation).
|
||||
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" = {
|
||||
extraConfig = ''
|
||||
internal;
|
||||
alias ${errorPages.unauthorized};
|
||||
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;
|
||||
}
|
||||
|
|
@ -5,6 +5,28 @@
|
|||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.network;
|
||||
|
||||
# IPv4 helpers for the DHCP-pool computation below — nix integers
|
||||
# are 64-bit so all /0-/32 values are safe.
|
||||
ipToInt =
|
||||
ip:
|
||||
builtins.foldl' (acc: x: acc * 256 + x) 0 (
|
||||
map lib.strings.toIntBase10 (lib.strings.splitString "." ip)
|
||||
);
|
||||
intToIp =
|
||||
n:
|
||||
let
|
||||
a = n / 16777216;
|
||||
b = (n - a * 16777216) / 65536;
|
||||
c = (n - a * 16777216 - b * 65536) / 256;
|
||||
d = n - a * 16777216 - b * 65536 - c * 256;
|
||||
in
|
||||
"${toString a}.${toString b}.${toString c}.${toString d}";
|
||||
# 2^n via recursion (nix has no pow builtin).
|
||||
pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1));
|
||||
hostCount = pow2 (32 - cfg.bridgePrefixLength);
|
||||
# Mask off host bits to get the network base address.
|
||||
networkBase = builtins.bitAnd (ipToInt cfg.bridgeIp) (4294967295 - hostCount + 1);
|
||||
in
|
||||
{
|
||||
# Hive-internal network — host-side bridge + per-agent DNS resolver.
|
||||
|
|
@ -117,6 +139,35 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# DHCP pool covering all usable host addresses on the bridge
|
||||
# subnet, computed from bridgeIp/bridgePrefixLength: .2 (first
|
||||
# usable after the .1 gateway) to .(hostCount-2) (last usable
|
||||
# before broadcast). All containers — agents and service
|
||||
# containers alike — receive their IPs dynamically from this pool;
|
||||
# there are no hash-derived static assignments. Consumed by the
|
||||
# dnsmasq that runs in the gateway container (hive-gateway module).
|
||||
dhcpRangeStart = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = intToIp (networkBase + 2);
|
||||
defaultText = lib.literalMD "first usable bridge address after the gateway";
|
||||
description = ''
|
||||
Read-only computed first address of the bridge DHCP pool.
|
||||
'';
|
||||
};
|
||||
|
||||
dhcpRangeEnd = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = intToIp (networkBase + hostCount - 2);
|
||||
defaultText = lib.literalMD "last usable bridge address before broadcast";
|
||||
description = ''
|
||||
Read-only computed last address of the bridge DHCP pool.
|
||||
'';
|
||||
};
|
||||
|
||||
isolateContainers = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
|
|
|
|||
Loading…
Reference in a new issue