refactor: nix/host-modules + nix/agent-modules layout, update doc paths
This commit is contained in:
parent
cb755b677c
commit
4a48ce5024
52 changed files with 48 additions and 44 deletions
26
nix/host-modules/default.nix
Normal file
26
nix/host-modules/default.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# The full hyperhive host stack, pulled together in one place — this
|
||||
# is what the flake exports as `nixosModules.default` (wrapped with
|
||||
# the package/source wiring; see flake.nix). One import covers
|
||||
# everything; `services.hyperhive.enable = true` turns the stack on.
|
||||
#
|
||||
# The forge is mandatory — hive-c0re mirrors every agent's applied
|
||||
# config repo into it and it's the canonical store for the meta flake
|
||||
# + `internal/*` repos, so there's no enable toggle; it deploys with
|
||||
# hyperhive itself. hive-matrix is opt-in (off by default). All
|
||||
# subsystems rely on `services.hyperhive.domain`, which is required
|
||||
# (asserted in hive-network.nix) whenever hyperhive is enabled.
|
||||
{
|
||||
imports = [
|
||||
./hyperhive.nix
|
||||
./hive-c0re
|
||||
./hive-ci.nix
|
||||
./hive-forge
|
||||
./hive-gateway
|
||||
./hive-matrix.nix
|
||||
./hive-network.nix
|
||||
./hive-priv.nix
|
||||
./hive-tls.nix
|
||||
./otel.nix
|
||||
./swarm.nix
|
||||
];
|
||||
}
|
||||
218
nix/host-modules/hive-c0re/default.nix
Normal file
218
nix/host-modules/hive-c0re/default.nix
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# The hive-c0re coordinator daemon (runs as the unprivileged
|
||||
# `hive-core` user), socket-activated at /run/hyperhive/host.sock.
|
||||
# Layout: ./options.nix (option declarations), ./theme.nix (stylix
|
||||
# frontend theming → `servedFrontend`), ./environment.nix (the daemon
|
||||
# unit's env attrset). The root privileged helper it delegates to is
|
||||
# its own module (../hive-priv.nix).
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.c0re;
|
||||
|
||||
# Privsep splits ownership across users, so git/libgit2's dubious-
|
||||
# ownership guard trips on legitimate cross-user reads: hive-priv (root)
|
||||
# fetches the hive-core-owned meta/applied repos via nix, and hive-c0re
|
||||
# (hive-core) fetches the agent-owned proposed-config repos. Both
|
||||
# processes are trusted and can already read the files; this gitconfig
|
||||
# only satisfies the ownership guard. libgit2 honours the literal `*`
|
||||
# (mid-path globs aren't supported, so per-agent repos can't be listed);
|
||||
# in practice these processes only ever touch hyperhive's own repos.
|
||||
safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" ''
|
||||
[safe]
|
||||
directory = *
|
||||
'';
|
||||
|
||||
# The `hive-c0re serve` config JSON. Keys are snake_case to match the
|
||||
# `ServeConfig` serde shape the daemon deserialises (the
|
||||
# container-injected HiveEnv fields, flattened, plus the hive-c0re-local
|
||||
# model_prices table); per-flag overrides still work for ad-hoc
|
||||
# invocations.
|
||||
#
|
||||
# Written to `/etc/hyperhive/serve.json` (managed by
|
||||
# `environment.etc`) rather than embedded as a store-path argument in
|
||||
# ExecStart. This keeps ExecStart byte-stable across deploys that only
|
||||
# change hyperhive module files (gateway, frontend, unrelated nix
|
||||
# modules) so systemd does NOT restart hive-c0re — and therefore does
|
||||
# NOT trigger a startup sweep that rebuilds every agent — unless the
|
||||
# c0re binary itself changes.
|
||||
serveConfigJson = builtins.toJSON {
|
||||
hyperhive_flake = cfg.hyperhiveFlake;
|
||||
hyperhive_docs_flake = cfg.hyperhiveDocs;
|
||||
nixpkgs_flake = cfg.nixpkgsFlake;
|
||||
dashboard_port = cfg.dashboardPort;
|
||||
operator_pronouns = cfg.operatorPronouns;
|
||||
context_window_tokens = cfg.contextWindowTokens;
|
||||
agent_cpu_quota = cfg.agentCpuQuota;
|
||||
agent_memory_max = cfg.agentMemoryMax;
|
||||
model_prices = cfg.modelPrices;
|
||||
build_slots = cfg.buildSlots;
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./options.nix
|
||||
./theme.nix
|
||||
];
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [
|
||||
cfg.package
|
||||
pkgs.git
|
||||
# XDG icons + .desktop entries so desktop environments can match
|
||||
# hyperhive processes to their icon (task managers, CPU monitors, etc.).
|
||||
cfg.xdgIcons
|
||||
];
|
||||
|
||||
# Serve config at a stable /etc path so hive-c0re's ExecStart
|
||||
# doesn't embed a volatile store-path argument. See serveConfigJson
|
||||
# above for the rationale.
|
||||
environment.etc."hyperhive/serve.json".text = serveConfigJson;
|
||||
|
||||
# Pull the per-container toplevels into the host system closure.
|
||||
# `system.extraDependencies` adds paths to the system build
|
||||
# without referencing them at runtime — nixos-rebuild fetches /
|
||||
# builds them, they end up in /nix/store, and the first
|
||||
# nixos-container update + start for an agent has nothing left to
|
||||
# do. Gated because the closure is sizeable and pinned to x86_64.
|
||||
system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [
|
||||
cfg.agentBaseToplevel
|
||||
cfg.managerToplevel
|
||||
];
|
||||
|
||||
# Unprivileged coordinator user. hive-c0re runs as this user;
|
||||
# privileged operations are delegated to hive-priv which runs as
|
||||
# root, socket-activated at /run/hive/priv.sock (./hive-priv.nix).
|
||||
users.users.hive-core = {
|
||||
isSystemUser = true;
|
||||
group = "hive-core";
|
||||
description = "hive-c0re coordinator daemon user";
|
||||
};
|
||||
users.groups.hive-core = { };
|
||||
|
||||
# The gateway nginx is always the sole external entry point (it runs
|
||||
# alongside hyperhive), so the per-agent web-port range stays closed on
|
||||
# the host firewall. See `docs/gateway.md::Firewall posture (host-level)`.
|
||||
|
||||
# NB: `services.hyperhive.domain` is required when hyperhive is
|
||||
# enabled — the canonical assertion lives in `hive-network.nix` (the
|
||||
# hive resolver is authoritative for `<domain>` and agents reach the
|
||||
# forge/matrix through the gateway by it). So the daemon environment
|
||||
# (./environment.nix) can treat it as non-null.
|
||||
systemd.services.hive-c0re = {
|
||||
description = "hyperhive coordinator daemon";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# Socket unit must start before the service so hive-c0re receives the
|
||||
# pre-bound fd via LISTEN_FDS (socket activation). Without this
|
||||
# dependency, nixos-rebuild switch activates hive-c0re.socket while
|
||||
# hive-c0re.service is already running (started by multi-user.target),
|
||||
# and systemd refuses with "Socket service already active". Adding
|
||||
# requires+after causes systemd to stop the service, start the socket,
|
||||
# then restart the service -- clean transition on every config apply.
|
||||
requires = [ "hive-c0re.socket" ];
|
||||
after = [ "hive-c0re.socket" ];
|
||||
path = [
|
||||
pkgs.git
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
environment = import ./environment.nix { inherit lib config pkgs; };
|
||||
serviceConfig = {
|
||||
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config /etc/hyperhive/serve.json";
|
||||
SyslogIdentifier = "hive-c0re";
|
||||
# Migrate hive-c0re's *own* state to the service user after an
|
||||
# upgrade from a root-run install (systemd's StateDirectory only
|
||||
# chowns the top-level dir, not pre-existing files inside it). The
|
||||
# `+` prefix runs as root despite User = hive-core; `-` tolerates
|
||||
# failure. coreutils ships `chown` but no `sh`, so invoke the
|
||||
# binaries directly rather than through a shell.
|
||||
#
|
||||
# CRITICAL: exclude the per-agent `agents/` subtree. Its contents
|
||||
# (each agent's `claude/` OAuth creds, `state/`, `harness/`,
|
||||
# `config/`) are owned by the per-agent / manager users, and each
|
||||
# container's `hive-agent-user-migrate` activation script chowns
|
||||
# them back to that user on boot. Blanket-chowning them to hive-core
|
||||
# makes every agent's `~/.claude` unreadable — logging them all out
|
||||
# with no way to log back in. So chown everything *except* agents/,
|
||||
# plus the `agents/` dir node itself (not its contents) so c0re can
|
||||
# still create new per-agent subdirs.
|
||||
ExecStartPre = [
|
||||
# Install the safe.directory gitconfig at $HOME/.gitconfig
|
||||
# (HOME = /var/lib/hyperhive) so c0re's `git fetch`/`rev-parse`
|
||||
# against the agent-owned proposed repos pass the ownership guard.
|
||||
# Placed before the chown below so it's chowned to hive-core too.
|
||||
"+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hyperhive/.gitconfig"
|
||||
"+-${pkgs.findutils}/bin/find /var/lib/hyperhive -mindepth 1 -maxdepth 1 -not -name agents -exec ${pkgs.coreutils}/bin/chown -R hive-core:hive-core {} +"
|
||||
"+-${pkgs.coreutils}/bin/chown hive-core:hive-core /var/lib/hyperhive/agents"
|
||||
];
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
User = "hive-core";
|
||||
Group = "hive-core";
|
||||
SupplementaryGroups = [ "systemd-journal" ];
|
||||
RuntimeDirectory = "hyperhive";
|
||||
RuntimeDirectoryMode = "0750";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
StateDirectory = "hyperhive";
|
||||
StateDirectoryMode = "0750";
|
||||
# Sandboxing. hive-c0re is unprivileged (runs as hive-core, never
|
||||
# setuid), makes HTTP requests to forge/matrix/Anthropic (keeps INET),
|
||||
# and delegates all privileged ops to hive-priv via a Unix socket.
|
||||
# These directives deny the subset of kernel capabilities it
|
||||
# provably doesn't need without restricting its network or
|
||||
# filesystem access (RestrictAddressFamilies deferred — needs a
|
||||
# watched deploy to verify no AF_UNIX/AF_INET gaps in socket paths).
|
||||
NoNewPrivileges = true; # already runs as unprivileged user
|
||||
PrivateTmp = true; # uses StateDirectory for tmpfiles, not /tmp
|
||||
ProtectHome = true; # HOME = /var/lib/hyperhive; no /home/* access needed
|
||||
# "strict" makes the entire filesystem read-only except for
|
||||
# StateDirectory (/var/lib/hyperhive) and RuntimeDirectory
|
||||
# (/run/hyperhive), which systemd keeps writable. No
|
||||
# ReadWritePaths needed beyond the managed directories because:
|
||||
# - nix is invoked directly (lifecycle, meta, flake_check), but
|
||||
# NIX_REMOTE=daemon routes all store writes through the host
|
||||
# daemon — hive-c0re never writes to /nix itself.
|
||||
# - flake.lock ops land in the meta worktree under StateDirectory
|
||||
# (kept writable by systemd).
|
||||
# - nix build worktrees live in PrivateTmp, not /tmp.
|
||||
# - /etc writes (bind-mount edits) go through hive-priv via the
|
||||
# privileged socket; /etc/hyperhive/serve.json is read-only.
|
||||
ProtectSystem = "strict";
|
||||
ProtectKernelTunables = true; # no sysctl writes
|
||||
ProtectKernelLogs = true; # reads logs via systemd-journal group, not /dev/kmsg
|
||||
ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly
|
||||
RestrictNamespaces = true; # namespace creation goes through hive-priv
|
||||
LockPersonality = true; # no personality changes needed
|
||||
RestrictRealtime = true; # no real-time scheduling
|
||||
};
|
||||
};
|
||||
|
||||
# Socket unit for the hive-c0re admin socket. systemd creates and holds
|
||||
# `/run/hyperhive/host.sock` before hive-c0re starts, then passes the fd
|
||||
# via LISTEN_FDS (socket activation). Benefits: `hivectl` can connect
|
||||
# the moment the socket unit is active — no racy retry window — and a
|
||||
# hive-c0re restart never drops the socket inode, so queued commands
|
||||
# drain cleanly.
|
||||
#
|
||||
# `hive-c0re serve` reads LISTEN_FDS via the `listenfd` crate and
|
||||
# accepts the fd in preference to its own `bind()` path. When invoked
|
||||
# directly (dev, CI, without the socket unit) LISTEN_FDS is absent and
|
||||
# the traditional bind path runs unchanged — no regression.
|
||||
systemd.sockets.hive-c0re = {
|
||||
description = "hive-c0re admin socket";
|
||||
wantedBy = [ "sockets.target" ];
|
||||
socketConfig = {
|
||||
# Must match the `--socket` arg passed to `hive-c0re serve`.
|
||||
ListenStream = "/run/hyperhive/host.sock";
|
||||
# 0660 root:root — `hivectl` is a host-only tool run as root.
|
||||
SocketMode = "0660";
|
||||
# Parent dir inherits the RuntimeDirectory mode (0750) set on the
|
||||
# service unit; DirectoryMode is only consulted when the dir is
|
||||
# absent at socket-unit activation.
|
||||
DirectoryMode = "0750";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
180
nix/host-modules/hive-c0re/environment.nix
Normal file
180
nix/host-modules/hive-c0re/environment.nix
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Environment of the hive-c0re daemon unit — a plain function file
|
||||
# (not a module) returning the env attrset, imported by ./default.nix.
|
||||
# Everything meta.rs forwards into agent containers or reads for the
|
||||
# meta-flake render is assembled here.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.c0re;
|
||||
in
|
||||
{
|
||||
# nix (the prebuild `nix build`, flake-check, and meta eval) writes
|
||||
# its fetcher/eval cache under $HOME/.cache. As a system user
|
||||
# hive-core has no home, so HOME defaults to the unwritable
|
||||
# /var/empty and Lix fails to initialise its cache. Point HOME at
|
||||
# the writable StateDirectory.
|
||||
HOME = "/var/lib/hyperhive";
|
||||
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
|
||||
# No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist
|
||||
# (see the hive-gateway module); this router is API-only.
|
||||
# Path to the base agent frontend dist. hive-c0re's
|
||||
# gateway_nginx.rs uses this to generate split location
|
||||
# blocks in agents.conf — static HTML/CSS/JS served from the
|
||||
# nix store directly; dynamic API paths still proxied to the
|
||||
# agent daemon. The nix store is shared across nspawn
|
||||
# containers, so this path is reachable from inside the
|
||||
# gateway container's nginx.
|
||||
HIVE_AGENT_FRONTEND_DIR = "${cfg.servedFrontend}/agent";
|
||||
# Path to the static runtime asset tree (branding + claude
|
||||
# prompts). `hive_sh4re::assets::*` reads paths underneath.
|
||||
# `forge.rs` reads the avatar PNGs from here on startup.
|
||||
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
|
||||
# Whether this hive runs ruthless — no root/manager agent at all
|
||||
# (`auto_update::ensure_root_agent`). Default false = root
|
||||
# auto-managed; true makes the sweep a no-op.
|
||||
HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless;
|
||||
}
|
||||
// {
|
||||
# Identity env vars threaded into c0re's own service env and
|
||||
# forwarded by meta.rs into every sub-agent's harness env —
|
||||
# full chain in docs/conventions.md::Hive identity. `domain` is
|
||||
# required (asserted in hive-network.nix), so it's always set.
|
||||
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
|
||||
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
|
||||
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
|
||||
}
|
||||
// lib.optionalAttrs (!config.services.hyperhive.github.enable) {
|
||||
# GitHub integration is on by default; only signal the OFF override to
|
||||
# meta.rs, which then injects `hyperhive.github.enable = false` into
|
||||
# every agent. See services.hyperhive.github.enable.
|
||||
HYPERHIVE_GITHUB_DISABLED = "1";
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.otel.enable (
|
||||
# Hive-wide OTEL config -> read by meta.rs::otel_config and
|
||||
# injected as build-time `hyperhive.otel.*` into every agent.
|
||||
# Endpoint presence is the enable signal on the meta side; the
|
||||
# optional fields are only emitted when set so absent values
|
||||
# don't render no-op env lines.
|
||||
let
|
||||
otel = config.services.hyperhive.otel;
|
||||
in
|
||||
{
|
||||
HYPERHIVE_OTEL_ENDPOINT = otel.endpoint;
|
||||
HYPERHIVE_OTEL_PROTOCOL = otel.protocol;
|
||||
}
|
||||
// lib.optionalAttrs (otel.extraResourceAttributes != "") {
|
||||
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
|
||||
}
|
||||
// lib.optionalAttrs (otel.headersCredential != null) {
|
||||
HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential;
|
||||
}
|
||||
// lib.optionalAttrs (otel.metricIntervalMs != null) {
|
||||
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
|
||||
}
|
||||
// lib.optionalAttrs otel.debug {
|
||||
HYPERHIVE_OTEL_DEBUG = "1";
|
||||
}
|
||||
)
|
||||
// {
|
||||
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
|
||||
# nginx proxies to forgejo. Used both for internal API calls in
|
||||
# hive-c0re (forge/mod.rs `forge_http_base()`) and forwarded to
|
||||
# agents via meta.rs for their forge-notify client. The forge is
|
||||
# mandatory, so this is unconditional (the whole env block is already
|
||||
# gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`.
|
||||
HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}";
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.matrix.enable {
|
||||
# In-cluster matrix homeserver URL for each agent's
|
||||
# hive-matrix-daemon — the gateway vhost (`matrix.<domain>`). The
|
||||
# gatewayHost null-guard falls back to loopback so a domain-less
|
||||
# config still evals. Forwarded to agents by meta.rs alongside
|
||||
# HIVE_FORGE_URL; shares the same env-forwarding ordering caveat
|
||||
# (value baked at config-generation time).
|
||||
HIVE_MATRIX_URL =
|
||||
if config.services.hyperhive.matrix.gatewayHost != null then
|
||||
"http://${config.services.hyperhive.matrix.gatewayHost}"
|
||||
else
|
||||
"http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}";
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
|
||||
# Availability flags read by the dashboard's `/api/state`.
|
||||
# Matrix GUI lives entirely on the gateway nginx (matrix tab
|
||||
# only shows when both flags are on). Gateway routing detail:
|
||||
# docs/gateway.md::Vhost map.
|
||||
HIVE_MATRIX_GUI_ENABLED = "1";
|
||||
}
|
||||
// {
|
||||
# The gateway always runs, so the dashboard always builds
|
||||
# same-origin `/agent/<name>/` links (never the direct
|
||||
# `<host>:<port>` TCP fallback). Kept as an env flag so the
|
||||
# dashboard doesn't need to learn the gateway is unconditional.
|
||||
HIVE_GATEWAY_ENABLED = "1";
|
||||
}
|
||||
// lib.optionalAttrs config.services.hyperhive.forge.behindGateway {
|
||||
# Public URL of the forge vhost served by hive-gateway. The
|
||||
# dashboard uses this to build browser-facing forge links
|
||||
# instead of hardcoding `<hostname>:3000`, which breaks when
|
||||
# the operator accesses the dashboard through the gateway
|
||||
# (forge sub-domain has no port; direct port URL would be
|
||||
# wrong). Absent when `behindGateway = false` — dashboard
|
||||
# falls back to `<hostname>:3000`.
|
||||
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
|
||||
}
|
||||
//
|
||||
lib.optionalAttrs
|
||||
(
|
||||
config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null
|
||||
)
|
||||
{
|
||||
# Browser-facing matrix GUI (fluffychat) URL — the gateway
|
||||
# vhost (`matrix.<domain>`). Surfaced via the daemon's `Urls`
|
||||
# request for `hivectl open matrix`. Absent when the GUI is off
|
||||
# or no gatewayHost is set (no browser-reachable matrix vhost).
|
||||
HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/";
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
|
||||
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
|
||||
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()
|
||||
# + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain
|
||||
# is the attrset key; cert_fingerprint is null for CA-trusted peers;
|
||||
# wireguard_address is omitted when not part of the mesh.
|
||||
HYPERHIVE_PEERS = builtins.toJSON (
|
||||
lib.mapAttrsToList (
|
||||
domain: p:
|
||||
{
|
||||
inherit domain;
|
||||
cert_fingerprint = p.certFingerprint;
|
||||
}
|
||||
// lib.optionalAttrs (p.wireguardAddress != null) {
|
||||
wireguard_address = p.wireguardAddress;
|
||||
}
|
||||
) config.services.hyperhive.swarm.peers
|
||||
);
|
||||
}
|
||||
//
|
||||
lib.optionalAttrs
|
||||
(lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers))
|
||||
{
|
||||
# Peer-hive root CA file paths (colon-joined), one per peer that
|
||||
# declares `swarm.peers.<domain>.caCert`. hive-c0re's meta-flake
|
||||
# renderer (meta.rs) embeds each next to every agent's flake and
|
||||
# adds it to `security.pki.certificateFiles`, so a peer CA is
|
||||
# trusted everywhere the hive's own internal CA (`hive-ca.pem`)
|
||||
# is — i.e. by every agent. The matrix container trusts the same
|
||||
# CAs separately for federation TLS. The `caCert` files are
|
||||
# copied into the nix store at build, so these are store paths —
|
||||
# nothing mutable lives on the host.
|
||||
HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" (
|
||||
lib.filter (c: c != null) (
|
||||
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
|
||||
)
|
||||
);
|
||||
}
|
||||
319
nix/host-modules/hive-c0re/options.nix
Normal file
319
nix/host-modules/hive-c0re/options.nix
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# Option declarations for `services.hyperhive.c0re.*` — the c0re
|
||||
# daemon's knobs plus the package/source options the flake's
|
||||
# `nixosModules.default` wires to its own outputs (they carry no
|
||||
# in-module defaults; see ../../../flake.nix). The read-only
|
||||
# `servedFrontend` option lives in ./theme.nix with the stylix wiring
|
||||
# that computes it.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.services.hyperhive.c0re = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = config.services.hyperhive.enable;
|
||||
defaultText = lib.literalExpression "config.services.hyperhive.enable";
|
||||
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
|
||||
};
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
|
||||
description = ''
|
||||
hyperhive workspace package. Provides `/bin/hive-c0re`
|
||||
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
|
||||
(operator-facing host CLI for ad-hoc administration). Wired to
|
||||
this flake's `packages.<system>.default` by
|
||||
`nixosModules.default` (via `lib.mkDefault`, so setting it here
|
||||
wins).
|
||||
'';
|
||||
};
|
||||
frontend = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
|
||||
description = ''
|
||||
Bundled frontend dist (see `nix/packages/frontend.nix`). Output
|
||||
has `dashboard/` and `agent/` subdirectories — hive-c0re serves
|
||||
`dashboard/` via `tower_http::ServeDir` from the path passed
|
||||
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
|
||||
the JSON contract (`/api/state`, the SSE streams, the action
|
||||
endpoints) is the source of truth for any replacement.
|
||||
'';
|
||||
};
|
||||
assets = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
|
||||
description = ''
|
||||
Bundled static runtime assets (see `nix/packages/assets.nix`): the
|
||||
project's branding family + the claude system-prompt template +
|
||||
claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`;
|
||||
passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR`
|
||||
(`hive_sh4re::assets::*` resolve paths underneath). Override to
|
||||
ship customised branding or prompts without rebuilding the
|
||||
rust derivation.
|
||||
'';
|
||||
};
|
||||
xdgIcons = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons";
|
||||
description = ''
|
||||
XDG icon set + .desktop entries for hyperhive processes (see
|
||||
`nix/packages/hive-xdg-icons.nix`), installed into the host
|
||||
system packages so desktop environments can match hyperhive
|
||||
processes to their icon.
|
||||
'';
|
||||
};
|
||||
hyperhiveFlake = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
defaultText = lib.literalMD "the hyperhive flake's own filtered source store path";
|
||||
description = ''
|
||||
URL of the hyperhive flake (no fragment). Inlined into each
|
||||
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
|
||||
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
|
||||
build the container. Wired by `nixosModules.default` to this
|
||||
flake's own filtered source — only override if you want agents
|
||||
tracking a different ref.
|
||||
'';
|
||||
};
|
||||
hyperhiveDocs = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
defaultText = lib.literalMD "the docs/ tree's own store path";
|
||||
description = ''
|
||||
URL of the narrow `docs/` source (no fragment). Inlined into the
|
||||
generated meta `flake.nix` at `inputs.hyperhive-docs.url` and
|
||||
threaded to each agent as `hyperhive.docs.source`, from which the
|
||||
harness resolves `$HIVE_DOCS_DIR`. Its own store path — separate
|
||||
from `hyperhiveFlake` — so a doc edit only re-locks this input
|
||||
instead of rebuilding every agent container.
|
||||
'';
|
||||
};
|
||||
agentBaseToplevel = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel";
|
||||
description = ''
|
||||
Pre-built agent-base container system closure, pulled into the
|
||||
host system closure when `preBuildAgentTemplates` is on. Wired
|
||||
by `nixosModules.default`; only evaluated when that option is
|
||||
enabled.
|
||||
'';
|
||||
};
|
||||
managerToplevel = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel";
|
||||
description = ''
|
||||
Pre-built manager (ruth) container system closure — see
|
||||
`agentBaseToplevel`.
|
||||
'';
|
||||
};
|
||||
nixpkgsFlake = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "path:${pkgs.path}";
|
||||
defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`";
|
||||
description = ''
|
||||
Store-path URL for the `nixpkgs` input in the generated meta
|
||||
flake. The meta flake declares this as a top-level input and
|
||||
wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so
|
||||
every agent container evaluates with this exact nixpkgs.
|
||||
|
||||
Defaults to `"path:''${pkgs.path}"` — the store path of the
|
||||
nixpkgs the host NixOS module was evaluated with. When the
|
||||
operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
|
||||
"nixpkgs"` in their host flake, `pkgs.path` resolves to the
|
||||
host's own nixpkgs, so agents transparently track the same
|
||||
channel as the host.
|
||||
|
||||
Override to pin agents to a specific nixpkgs version regardless
|
||||
of the host's channel.
|
||||
'';
|
||||
};
|
||||
dashboardPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 7000;
|
||||
description = "TCP port the hive-c0re dashboard listens on.";
|
||||
};
|
||||
operatorPronouns = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "she/her";
|
||||
example = "they/them";
|
||||
description = ''
|
||||
Operator pronouns, free text. Threaded into every agent
|
||||
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
|
||||
harness substitutes it into the agent / manager system
|
||||
prompt at boot so claude refers to the operator naturally
|
||||
in third person ("ask her", "tell them", etc.). Changes
|
||||
propagate to running agents on the next `↻ R3BU1LD` —
|
||||
forwards as a meta flake env-var bump, no per-agent
|
||||
approval needed.
|
||||
'';
|
||||
};
|
||||
preBuildAgentTemplates = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Pre-fetch the per-container system closures (agent-base +
|
||||
manager toplevels) into the host's /nix/store as part of this
|
||||
host's NixOS build, instead of letting the first agent spawn
|
||||
do all the work.
|
||||
|
||||
Enabling this adds roughly the full nixpkgs runtime closure +
|
||||
claude-code + the harness binary to your system closure size
|
||||
(low single-digit GB), but the first `nixos-container start`
|
||||
for any agent then completes in seconds instead of minutes
|
||||
because nothing's left to fetch.
|
||||
|
||||
Off by default because the toplevels are pinned to
|
||||
`x86_64-linux` (nixos-containers run native arch). Enabling
|
||||
on an aarch64 host would force nix to build the x86 closure
|
||||
via cross or a remote builder, which is rarely what you want.
|
||||
Flip to `true` on an x86_64 host when you care more about
|
||||
first-spawn latency than host store size — or just
|
||||
`nix build .#agent-base-toplevel` once manually to warm the
|
||||
store.
|
||||
'';
|
||||
};
|
||||
contextWindowTokens = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.int;
|
||||
default = {
|
||||
haiku = 200000;
|
||||
sonnet = 1000000;
|
||||
opus = 1000000;
|
||||
};
|
||||
example = {
|
||||
haiku = 150000;
|
||||
sonnet = 900000;
|
||||
};
|
||||
description = ''
|
||||
Per-model context-window sizes in tokens. Each key is a
|
||||
model-family short name matched case-insensitively as a
|
||||
substring of the active model name at runtime (e.g. `"sonnet"`
|
||||
matches `"claude-sonnet-4-5"`). The defaults cover the known
|
||||
Anthropic families; add entries for new models or override
|
||||
existing ones here to change the window for all agents at once.
|
||||
|
||||
Passed to `hive-c0re serve` as JSON and injected into every
|
||||
container's harness service environment as
|
||||
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`. Changes propagate
|
||||
on the next `↻ R3BU1LD` — no per-agent approval needed.
|
||||
'';
|
||||
};
|
||||
|
||||
modelPrices = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
input = lib.mkOption {
|
||||
type = lib.types.numbers.nonnegative;
|
||||
description = "USD per million input tokens.";
|
||||
};
|
||||
output = lib.mkOption {
|
||||
type = lib.types.numbers.nonnegative;
|
||||
description = "USD per million output tokens.";
|
||||
};
|
||||
cache_read = lib.mkOption {
|
||||
type = lib.types.numbers.nonnegative;
|
||||
description = "USD per million cache-read tokens.";
|
||||
};
|
||||
cache_write = lib.mkOption {
|
||||
type = lib.types.numbers.nonnegative;
|
||||
description = "USD per million cache-creation (write) tokens.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
# Current Anthropic list prices for the Claude 4.x family (Opus
|
||||
# 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL
|
||||
# price (the default through the Claude subscription the agents run
|
||||
# on). Keep in sync with `builtin_prices` in
|
||||
# hive-c0re/src/hive_stats.rs.
|
||||
default = {
|
||||
opus = {
|
||||
input = 5.0;
|
||||
output = 25.0;
|
||||
cache_read = 0.5;
|
||||
cache_write = 10.0;
|
||||
};
|
||||
sonnet = {
|
||||
input = 3.0;
|
||||
output = 15.0;
|
||||
cache_read = 0.3;
|
||||
cache_write = 6.0;
|
||||
};
|
||||
haiku = {
|
||||
input = 1.0;
|
||||
output = 5.0;
|
||||
cache_read = 0.1;
|
||||
cache_write = 2.0;
|
||||
};
|
||||
};
|
||||
example = {
|
||||
sonnet = {
|
||||
input = 3.0;
|
||||
output = 15.0;
|
||||
cache_read = 0.3;
|
||||
cache_write = 6.0;
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Per-model USD prices (per **million** tokens) used for the
|
||||
hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key
|
||||
is a model-family short name matched case-insensitively as a
|
||||
substring of the active model id at runtime (e.g. `"sonnet"`
|
||||
matches `"claude-sonnet-4-5"`); the longest matching key wins, so
|
||||
a specific entry beats a generic family name. Any model not
|
||||
covered by this table falls back to hive-c0re's built-in
|
||||
estimate.
|
||||
|
||||
The defaults track Anthropic list pricing at the time of
|
||||
writing — override them here to keep the estimate current
|
||||
without a code change. Passed to `hive-c0re serve` as JSON via
|
||||
`--model-prices`; read only by hive-c0re itself (not injected
|
||||
into containers). Changes apply on the next host rebuild.
|
||||
'';
|
||||
};
|
||||
|
||||
agentCpuQuota = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "200%";
|
||||
example = "400%";
|
||||
description = ''
|
||||
systemd `CPUQuota=` applied to every agent container via a
|
||||
`container@h-<name>.service.d/` drop-in written on each
|
||||
spawn/rebuild. Expressed as a percentage of one CPU core —
|
||||
`"200%"` allows each agent to use up to 2 cores. Bump this if
|
||||
agents are hitting CPU limits during builds or heavy tool use.
|
||||
|
||||
For a hive-wide cap across all containers, set
|
||||
`systemd.slices.machine.serviceConfig.CPUQuota` in your NixOS
|
||||
config (all nspawn containers live in `machine.slice`).
|
||||
'';
|
||||
};
|
||||
|
||||
agentMemoryMax = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "4G";
|
||||
example = "8G";
|
||||
description = ''
|
||||
systemd `MemoryMax=` applied to every agent container via the
|
||||
same drop-in as `agentCpuQuota`.
|
||||
'';
|
||||
};
|
||||
|
||||
buildSlots = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 1;
|
||||
example = 2;
|
||||
description = ''
|
||||
Number of nix-heavy job-queue nodes (container prebuilds,
|
||||
profile swaps, first-spawn creates, meta lock bumps) hive-c0re
|
||||
runs concurrently. The default of 1 serializes all heavy nix
|
||||
work; raise it on hosts with the cores/RAM to build several
|
||||
agent toplevels at once. Per-agent correctness is independent
|
||||
of this count — each agent's container-affecting operations are
|
||||
serialized by its lifecycle lease regardless.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
79
nix/host-modules/hive-c0re/theme.nix
Normal file
79
nix/host-modules/hive-c0re/theme.nix
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Stylix theme integration (zero-op auto-detect). When the operator's
|
||||
# host config has stylix enabled, generate a base16 `colors.css` from
|
||||
# its palette and overlay it onto the bundled frontend dist so the
|
||||
# dashboard re-themes with no operator action and no npm/esbuild
|
||||
# rebuild (a pure file-copy over the prebuilt dist). `colors.css` is
|
||||
# the entire swap contract — `theme.css` derives every semantic var
|
||||
# from the 16 base16 slots (see docs/web-ui/css-vars.md). The guarded
|
||||
# access makes this a clean no-op when stylix isn't imported into the
|
||||
# host config. Exposed as the read-only `c0re.servedFrontend` option.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.c0re;
|
||||
stylixThemeColors =
|
||||
if (config.stylix.enable or false) && ((config.lib.stylix or { }) ? colors) then
|
||||
config.lib.stylix.colors.withHashtag
|
||||
else
|
||||
null;
|
||||
themedColorsCss =
|
||||
c:
|
||||
pkgs.writeText "hyperhive-colors.css" ''
|
||||
:root {
|
||||
--base00: ${c.base00};
|
||||
--base01: ${c.base01};
|
||||
--base02: ${c.base02};
|
||||
--base03: ${c.base03};
|
||||
--base04: ${c.base04};
|
||||
--base05: ${c.base05};
|
||||
--base06: ${c.base06};
|
||||
--base07: ${c.base07};
|
||||
--base08: ${c.base08};
|
||||
--base09: ${c.base09};
|
||||
--base0A: ${c.base0A};
|
||||
--base0B: ${c.base0B};
|
||||
--base0C: ${c.base0C};
|
||||
--base0D: ${c.base0D};
|
||||
--base0E: ${c.base0E};
|
||||
--base0F: ${c.base0F};
|
||||
}
|
||||
'';
|
||||
# Overlay the generated colors.css onto both dist subtrees. Both the
|
||||
# dashboard (served by hive-c0re via HIVE_STATIC_DIR) and the agent UIs
|
||||
# (served by the gateway from HIVE_AGENT_FRONTEND_DIR — static files
|
||||
# straight from the store) read their colors.css from this host-side
|
||||
# tree, so swapping both re-themes both surfaces.
|
||||
#
|
||||
# Not covered here: an agent reached directly on its own harness web
|
||||
# server (no gateway) serves from its per-agent `mergedDist`, built in
|
||||
# the agent's own nixosSystem with no access to the host's stylix
|
||||
# colours — theming that path needs the base16 palette forwarded
|
||||
# host→agent, tracked separately.
|
||||
themedFrontend =
|
||||
c:
|
||||
pkgs.runCommand "hyperhive-frontend-themed" { } ''
|
||||
cp -r ${cfg.frontend} $out
|
||||
chmod -R u+w $out
|
||||
install -m644 ${themedColorsCss c} $out/dashboard/static/colors.css
|
||||
install -m644 ${themedColorsCss c} $out/agent/static/colors.css
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.c0re.servedFrontend = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend;
|
||||
defaultText = lib.literalExpression "<stylix-themed overlay of `frontend`>";
|
||||
description = ''
|
||||
Internal, read-only: `frontend` re-themed with the active stylix
|
||||
palette (or `frontend` verbatim when unthemed); has `dashboard/`
|
||||
and `agent/`. Exposed so the hive-gateway module can static-serve
|
||||
`dashboard/` as an nginx root instead of proxying to hive-c0re.
|
||||
'';
|
||||
};
|
||||
}
|
||||
546
nix/host-modules/hive-ci.nix
Normal file
546
nix/host-modules/hive-ci.nix
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.forge.ci;
|
||||
forgeCfg = config.services.hyperhive.forge;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
networkCfg = config.services.hyperhive.network;
|
||||
tlsCfg = config.services.hyperhive.tls;
|
||||
|
||||
# Self-signed TLS is the gateway default (no operator cert / ACME). When
|
||||
# active, forgejo's ROOT_URL is `https://forge.<domain>` and the leaf is
|
||||
# signed by the host hive CA — so the runner's Node-based actions (e.g.
|
||||
# `upload-artifact`, which POSTs to the ROOT_URL-derived artifact endpoint)
|
||||
# reject the chain, since Node trusts only its bundled CA bundle, not the
|
||||
# system store. Trust the hive CA explicitly via NODE_EXTRA_CA_CERTS below.
|
||||
# `gateway.useSelfSigned` is the gateway module's single source of truth
|
||||
# for the self-signed condition (no duplicated derivation here).
|
||||
useSelfSigned = gatewayCfg.useSelfSigned;
|
||||
caHostPath = "${tlsCfg.stateDir}/ca.pem";
|
||||
caContainerPath = "/run/hive-ca/ca.pem";
|
||||
|
||||
# hive-c0re writes its own admin token here on first forge startup.
|
||||
# The token has read:admin + write:admin scopes — sufficient to call
|
||||
# the runner registration-token API endpoint.
|
||||
# This path is HOST-ONLY. It is never bind-mounted into hive-ci.
|
||||
coreTokenPath = "/var/lib/hyperhive/forge-core-token";
|
||||
|
||||
# Container state root on the host. Non-ephemeral containers keep
|
||||
# their filesystem here across reboots. The prefetch service accesses
|
||||
# the runner's .runner file via this path to validate credentials
|
||||
# without entering the container.
|
||||
containerRoot = "/var/lib/nixos-containers/hive-ci";
|
||||
|
||||
# Host-side oneshot. Runs before `container@hive-ci.service`.
|
||||
#
|
||||
# The core token stays on the host. The container only ever sees the
|
||||
# TOKEN= env-file populated here, never the core token itself.
|
||||
#
|
||||
# Flow:
|
||||
# 1. If .runner exists: validate the runner ID against forge.
|
||||
# Waits up to 60s for forge-core-token (hive-c0re writes it after
|
||||
# forge container starts and admin is provisioned — this lags
|
||||
# hive-c0re.service becoming active on first boot).
|
||||
# 404 → purge .runner (re-registration needed).
|
||||
# 000 (forge unreachable) → keep credentials, write placeholder.
|
||||
# other non-200 → purge .runner.
|
||||
# Token absent after 60s → keep credentials (safe — the runner
|
||||
# holds valid creds; next boot will validate properly).
|
||||
# 2. If .runner absent or just purged: wait up to 60s for both
|
||||
# forge-core-token to appear AND forge API to respond, then
|
||||
# fetch a fresh registration token and write TOKEN=<real>.
|
||||
# Exits non-zero if both timeout — systemd logs the failure;
|
||||
# the placeholder from tmpfiles means the container still starts
|
||||
# but the runner will error. Operator restarts the service once
|
||||
# forge is healthy.
|
||||
prefetchScript = pkgs.writeShellScript "hive-ci-prefetch" ''
|
||||
set -euo pipefail
|
||||
TOKEN_FILE=/run/hive-ci/runner-token
|
||||
FORGE_URL="http://127.0.0.1:${toString forgeCfg.httpPort}"
|
||||
RUNNER_FILE="${containerRoot}/var/lib/gitea-runner/hive/.runner"
|
||||
|
||||
# Validate existing .runner credentials against forge. Purge if
|
||||
# the runner was deleted (404) or the file is malformed. Keep if
|
||||
# forge is unreachable (000) — transient outage shouldn't wipe creds.
|
||||
#
|
||||
# We wait up to 60s for forge-core-token to appear (hive-c0re writes
|
||||
# it after provisioning the forge admin, which requires the forge
|
||||
# container to start and become ready — this can lag behind
|
||||
# hive-c0re.service becoming "active" on first boot). The same loop
|
||||
# doubles as a wait for forge's API to become ready.
|
||||
if [ -f "$RUNNER_FILE" ]; then
|
||||
RUNNER_ID=$(${pkgs.jq}/bin/jq -r '.id // empty' "$RUNNER_FILE" 2>/dev/null || true)
|
||||
if [ -z "''${RUNNER_ID:-}" ] || [ "$RUNNER_ID" = "0" ]; then
|
||||
echo "hive-ci-prefetch: .runner malformed (no id), purging for re-registration" >&2
|
||||
rm -f "$RUNNER_FILE"
|
||||
else
|
||||
# Wait for the core token before validating (same timeout as below).
|
||||
CORE_TOKEN=""
|
||||
for i in $(seq 1 60); do
|
||||
if [ -f "${coreTokenPath}" ]; then
|
||||
CORE_TOKEN=$(cat ${coreTokenPath})
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "''${CORE_TOKEN:-}" ]; then
|
||||
echo "hive-ci-prefetch: core token absent after 60s, keeping existing .runner" >&2
|
||||
else
|
||||
HTTP=$(${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $CORE_TOKEN" \
|
||||
"$FORGE_URL/api/v1/admin/runners/$RUNNER_ID" || echo "000")
|
||||
if [ "$HTTP" = "404" ]; then
|
||||
echo "hive-ci-prefetch: runner $RUNNER_ID gone from forge, purging .runner" >&2
|
||||
rm -f "$RUNNER_FILE"
|
||||
elif [ "$HTTP" = "000" ]; then
|
||||
echo "hive-ci-prefetch: forge unreachable, keeping existing credentials" >&2
|
||||
elif [ "$HTTP" != "200" ]; then
|
||||
echo "hive-ci-prefetch: runner validation HTTP $HTTP, purging .runner" >&2
|
||||
rm -f "$RUNNER_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# .runner valid → write placeholder; gitea-actions-runner skips
|
||||
# re-registration when .runner exists (TOKEN value is irrelevant).
|
||||
if [ -f "$RUNNER_FILE" ]; then
|
||||
echo "TOKEN=placeholder" > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# First boot or stale creds purged — wait for forge-core-token, then
|
||||
# fetch a fresh registration token. Single retry loop covers both:
|
||||
# waiting for hive-c0re to write the token file AND for forge's API
|
||||
# to become responsive (they race on first boot).
|
||||
REG_TOKEN=""
|
||||
for i in $(seq 1 60); do
|
||||
if [ ! -f "${coreTokenPath}" ]; then
|
||||
echo "hive-ci-prefetch: waiting for core token (attempt $i/60)..." >&2
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
CORE_TOKEN=$(cat ${coreTokenPath})
|
||||
# Capture the HTTP status so a stale/invalid core token (401/403) is
|
||||
# distinguished from a transient forge hiccup. With the old bare
|
||||
# `curl -sf | jq`, a forge-core-token that's stale for the current
|
||||
# forge (e.g. after a forge rebuild) 401s and fails silently every
|
||||
# attempt for the full 60s loop, then exits with a misleading
|
||||
# "core token absent or forge unreachable" — masking the real cause.
|
||||
# Fail fast + loudly on 401/403 so the failure mode is legible and
|
||||
# the operator/hive-c0re knows to re-mint forge-core-token.
|
||||
RESP=$(${pkgs.curl}/bin/curl -s -w $'\n%{http_code}' \
|
||||
"$FORGE_URL/api/v1/admin/runners/registration-token" \
|
||||
-H "Authorization: token $CORE_TOKEN" || printf '\n000')
|
||||
HTTP=$(printf '%s' "$RESP" | tail -n1)
|
||||
BODY=$(printf '%s' "$RESP" | sed '$d')
|
||||
case "$HTTP" in
|
||||
2*)
|
||||
REG_TOKEN=$(printf '%s' "$BODY" | ${pkgs.jq}/bin/jq -r .token)
|
||||
if [ -n "''${REG_TOKEN:-}" ] && [ "$REG_TOKEN" != "null" ]; then break; fi
|
||||
echo "hive-ci-prefetch: 2xx but no token in response (attempt $i/60), retrying..." >&2
|
||||
;;
|
||||
401 | 403)
|
||||
echo "hive-ci-prefetch: forge rejected forge-core-token (HTTP $HTTP) — it is stale/invalid for the current forge. hive-c0re must re-mint it (delete /var/lib/hyperhive/forge-core-token, or rely on the validate-or-remint path). Failing fast instead of looping 60s." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "hive-ci-prefetch: registration-token fetch HTTP $HTTP (attempt $i/60), retrying..." >&2
|
||||
;;
|
||||
esac
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -z "''${REG_TOKEN:-}" ] || [ "$REG_TOKEN" = "null" ]; then
|
||||
echo "hive-ci-prefetch: failed to fetch runner registration token (core token absent or forge unreachable after 60s)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "TOKEN=$REG_TOKEN" > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Forgejo Actions runner in a `hive-ci` nixos-container.
|
||||
# Uses a private network namespace (bridge-connected, not host netns)
|
||||
# so CI build scripts cannot reach host-loopback services (dashboard,
|
||||
# forge internal port, etc.) — a key defence against prompt-injection
|
||||
# via PR nix builds. The runner reaches the forge via the
|
||||
# gateway at `http://${forgeCfg.domain}` (resolved to the bridge IP
|
||||
# via `networking.extraHosts`; gateway port 80 is always open on the
|
||||
# bridge; `addSSL = true` means HTTP is served alongside HTTPS without
|
||||
# a redirect). See docs/network.md.
|
||||
# Container is non-ephemeral: the runner's registered credentials
|
||||
# survive restarts (gitea-actions-runner writes them to its stateDir
|
||||
# on first registration and reuses them on every subsequent start).
|
||||
#
|
||||
# Credential isolation: the forge admin token (`forge-core-token`)
|
||||
# never enters the hive-ci container. A host-side oneshot service
|
||||
# (`hive-ci-prefetch.service`) performs all forge API calls before
|
||||
# the container starts and writes only the runner registration token
|
||||
# to `/run/hive-ci/runner-token`. The container bind-mounts this
|
||||
# file read-only and never has access to the wider admin token.
|
||||
#
|
||||
# Nix builds inside the container use the shared /nix/store (standard
|
||||
# nixos-container behaviour) with sandbox-fallback = true, because
|
||||
# nspawn containers can't create the user-namespaces that nix sandboxing
|
||||
# requires. See docs/gotchas.md.
|
||||
|
||||
options.services.hyperhive.forge.ci = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Run a Forgejo Actions runner in a `hive-ci` nixos-container.
|
||||
Grouped under `services.hyperhive.forge` because the runner is
|
||||
tightly coupled to the forge instance it registers against.
|
||||
Disabled by default; the internal forge it registers against is
|
||||
always present (mandatory), so enabling this is all that's needed.
|
||||
|
||||
On first start the container auto-registers against hive-forge using
|
||||
hive-c0re's admin token — no manual token provisioning needed.
|
||||
Runner credentials are persisted in the container's state dir and
|
||||
reused on every subsequent boot.
|
||||
'';
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hive-ci";
|
||||
example = "prod-hive";
|
||||
description = ''
|
||||
Runner name as shown in the Forgejo admin panel. Defaults to
|
||||
"hive-ci"; override when multiple hives share a Forgejo instance.
|
||||
'';
|
||||
};
|
||||
|
||||
concurrency = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 1;
|
||||
example = 4;
|
||||
description = ''
|
||||
Maximum number of workflow jobs the runner executes in parallel.
|
||||
Each job gets its own temporary working directory; multiple parallel
|
||||
jobs share the container's nix store and cargo registry cache.
|
||||
Higher values trade memory + CPU headroom for throughput.
|
||||
'';
|
||||
};
|
||||
|
||||
labels = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "hive-ci:host" ];
|
||||
example = [
|
||||
"hive-ci:host"
|
||||
"nix:host"
|
||||
];
|
||||
description = ''
|
||||
Runner labels in `<name>:<scheme>` format. The `host` scheme runs
|
||||
commands directly in the container (no docker/podman). Workflow
|
||||
files target this runner with `runs-on: [hive-ci]`.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.gitea-actions-runner;
|
||||
defaultText = lib.literalExpression "pkgs.gitea-actions-runner";
|
||||
description = "gitea-actions-runner package.";
|
||||
};
|
||||
|
||||
jobTimeout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "1h";
|
||||
example = "3h";
|
||||
description = ''
|
||||
Per-job wall-clock timeout the runner enforces (act_runner's
|
||||
`runner.timeout`). A job that exceeds it is killed, so a hung or
|
||||
runaway build is bounded instead of holding the runner's single
|
||||
slot indefinitely. Default `1h` comfortably covers a cold-cache
|
||||
nix build while still bounding a stuck job; raise it (e.g.
|
||||
`"3h"`) if you legitimately run jobs longer than that. Accepts a
|
||||
Go duration string (`30m`, `1h`, `2h30m`). Note: this is
|
||||
enforced by the runner process, so it only fires while that
|
||||
process is itself healthy.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# `forge.behindGateway = true` (the default) is required because the
|
||||
# CI container uses private networking and reaches the forge through
|
||||
# the gateway vhost. Without the gateway vhost there is no HTTP
|
||||
# listener for `forgeCfg.domain` on the bridge that the runner can
|
||||
# connect to.
|
||||
assertions = [
|
||||
{
|
||||
assertion = forgeCfg.behindGateway;
|
||||
message = ''
|
||||
services.hyperhive.forge.ci.enable requires
|
||||
services.hyperhive.forge.behindGateway = true.
|
||||
The CI container runs with a private network namespace and
|
||||
reaches the forge through the gateway vhost on the bridge IP.
|
||||
Set behindGateway = true (it defaults to true alongside
|
||||
services.hyperhive.enable).
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Create /run/hive-ci/ on the host and seed runner-token with a
|
||||
# placeholder. hive-ci-prefetch.service overwrites it with the real
|
||||
# token (or a fresh placeholder) before the container starts. The
|
||||
# placeholder ensures the EnvironmentFile is always present even if
|
||||
# the prefetch service hasn't run yet (e.g. tmpfiles-setup timing).
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /run/hive-ci 0700 root root -"
|
||||
"f /run/hive-ci/runner-token 0600 root root - TOKEN=placeholder"
|
||||
];
|
||||
|
||||
# Host-side oneshot: validates/refreshes runner credentials before
|
||||
# the container starts. The core admin token stays on the host and
|
||||
# is never bind-mounted into the container. Runs on every boot so
|
||||
# stale .runner credentials (runner deleted from forge) are detected
|
||||
# and the container re-registers on the next start.
|
||||
systemd.services.hive-ci-prefetch = {
|
||||
description = "Pre-fetch hive-ci runner registration token (host-side)";
|
||||
# Run before the container starts but after tmpfiles so the token
|
||||
# file directory exists. After hive-c0re so the forge token is
|
||||
# likely written (best-effort — the script handles the absent case).
|
||||
after = [
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"hive-c0re.service"
|
||||
];
|
||||
before = [ "container@hive-ci.service" ];
|
||||
wantedBy = [ "container@hive-ci.service" ];
|
||||
# partOf binds this oneshot's lifecycle to the container: when the
|
||||
# container is stopped or restarted, systemd propagates that to this
|
||||
# unit so it re-runs on the NEXT container start. Without this, the
|
||||
# RemainAfterExit=true oneshot stays "active (exited)" forever after
|
||||
# its first run — so a container restart skips it and the stale
|
||||
# runner-token file (a placeholder from a boot where the forge token
|
||||
# wasn't ready yet, or a registration token consumed/rotated since)
|
||||
# is never refreshed. The in-container register service then fails
|
||||
# with "runner registration token not found". partOf guarantees a
|
||||
# fresh token is fetched before every container (re)start.
|
||||
#
|
||||
# Unit name: a declarative `containers.<name>` is the host unit
|
||||
# `container@<name>.service` (the nspawn template), NOT
|
||||
# `nixos-container@…`. The earlier `nixos-container@hive-ci.service`
|
||||
# matched no real unit, so before/wantedBy/partOf were silent
|
||||
# no-ops — the partOf never bound, the oneshot stayed
|
||||
# `active (exited)`, and the token was never refreshed on restart.
|
||||
# Confirmed against the live `container@hive-matrix.service` unit
|
||||
# during the matrix-outage incident.
|
||||
partOf = [ "container@hive-ci.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = prefetchScript;
|
||||
# Pin the journal identity; ExecStart is a writeShellScript whose
|
||||
# store-path basename would otherwise be the journal identifier.
|
||||
SyslogIdentifier = "hive-ci-prefetch";
|
||||
};
|
||||
};
|
||||
|
||||
# Self-signed mode: the CA cert the bind-mount above sources is
|
||||
# generated by the host `hive-tls-ca` service. Order the container after
|
||||
# it so the bind source exists before nspawn sets the mount up (a
|
||||
# condition-skipped/late CA would otherwise fail the container start).
|
||||
systemd.services."container@hive-ci" = lib.mkIf useSelfSigned {
|
||||
after = [ "hive-tls-ca.service" ];
|
||||
requires = [ "hive-tls-ca.service" ];
|
||||
};
|
||||
|
||||
containers.hive-ci = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Private network namespace, attached to the hive bridge so the
|
||||
# runner reaches the forge via the gateway — and cannot reach
|
||||
# host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.).
|
||||
# Requires `forge.behindGateway = true` (asserted in the options
|
||||
# block above). See docs/network.md.
|
||||
privateNetwork = true;
|
||||
hostBridge = networkCfg.bridgeName;
|
||||
|
||||
bindMounts = {
|
||||
# Pre-filled by hive-ci-prefetch.service (host-side) before the
|
||||
# container starts. Read-only: the container reads TOKEN= from
|
||||
# here; the core admin token never enters the container.
|
||||
"/run/hive-ci/runner-token" = {
|
||||
hostPath = "/run/hive-ci/runner-token";
|
||||
isReadOnly = true;
|
||||
};
|
||||
}
|
||||
# Self-signed mode: bind ONLY the public hive CA cert (never the
|
||||
# `hive-tls` state dir — it holds the CA + leaf private keys) so the
|
||||
# runner's Node actions can trust the gateway/forge self-signed leaf
|
||||
# (see NODE_EXTRA_CA_CERTS in the container config). Source generated
|
||||
# by the host `hive-tls-ca` service; the container@hive-ci ordering
|
||||
# below guarantees it exists before this mount is set up.
|
||||
// lib.optionalAttrs useSelfSigned {
|
||||
${caContainerPath} = {
|
||||
hostPath = caHostPath;
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
|
||||
config =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
# Point the forge domain at the bridge IP so the runner can
|
||||
# reach the forge through the gateway — both for registration /
|
||||
# polling (runner URL below) and for artifact uploads (the
|
||||
# Forgejo Actions artifact API uses ROOT_URL, i.e. the public
|
||||
# forge domain, not a localhost URL). The gateway vhost for
|
||||
# `forgeCfg.domain` proxies all `/` → forge; `addSSL = true`
|
||||
# means HTTP:80 is served without redirect alongside HTTPS:443.
|
||||
# Ports 80 and 443 are always open on the bridge firewall (see
|
||||
# hive-network.nix). No DNS lookup needed — /etc/hosts wins.
|
||||
networking.extraHosts = "${networkCfg.bridgeIp} ${forgeCfg.domain}";
|
||||
# DNS: use the hive resolver on the bridge IP (dnsmasq in
|
||||
# hive-gateway) for external lookups (git checkout, crate
|
||||
# registries, etc.). The bridge→loopback DROP rule does not
|
||||
# affect traffic destined for the bridge IP itself.
|
||||
networking.nameservers = [ networkCfg.bridgeIp ];
|
||||
# Bridge-attached via privateNetwork=true + hostBridge. The
|
||||
# gateway's dnsmasq serves a DHCP pool covering all usable bridge
|
||||
# addresses (see dhcp-range in hive-gateway.nix) — agents and
|
||||
# service containers alike receive IPs dynamically.
|
||||
networking.interfaces.eth0.useDHCP = true;
|
||||
|
||||
# nspawn containers can't create user-namespaces, so nix
|
||||
# sandboxing always fails. Fall back to unsandboxed builds.
|
||||
# Moot once every nix invocation in the container routes
|
||||
# through the host daemon (the daemon governs sandboxing).
|
||||
# See docs/gotchas.md and harness-base.nix.
|
||||
nix.settings.sandbox-fallback = lib.mkForce true;
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
|
||||
# package is top-level on gitea-actions-runner, not per-instance.
|
||||
services.gitea-actions-runner.package = cfg.package;
|
||||
|
||||
services.gitea-actions-runner.instances.hive = {
|
||||
enable = true;
|
||||
name = cfg.name;
|
||||
# Route through the gateway (bridge IP, port 80) so the
|
||||
# runner never touches host-loopback. The forge domain
|
||||
# resolves to the bridge IP via networking.extraHosts above;
|
||||
# the gateway vhost `forgeCfg.domain` proxies to the forge
|
||||
# on HTTP:80 (addSSL=true, no HTTP→HTTPS redirect).
|
||||
url = "http://${forgeCfg.domain}";
|
||||
# EnvironmentFile providing TOKEN= — pre-filled by the
|
||||
# host-side hive-ci-prefetch.service before the container
|
||||
# starts; bind-mounted read-only from /run/hive-ci/runner-token
|
||||
# on the host.
|
||||
tokenFile = "/run/hive-ci/runner-token";
|
||||
labels = cfg.labels;
|
||||
settings = {
|
||||
runner.capacity = cfg.concurrency;
|
||||
# Per-job wall-clock cap — see the `jobTimeout` option.
|
||||
runner.timeout = cfg.jobTimeout;
|
||||
};
|
||||
};
|
||||
|
||||
# No tmpfiles rule: /run/hive-ci/runner-token is bind-mounted
|
||||
# read-only from the host (pre-filled before container start).
|
||||
# nspawn creates the /run/hive-ci/ mount-point directory
|
||||
# automatically before launching the container's init.
|
||||
|
||||
# No hive-ci-register.service inside the container: all forge
|
||||
# API calls (runner validation, token fetch) moved to the
|
||||
# host-side hive-ci-prefetch.service. The core admin token
|
||||
# never enters this container.
|
||||
|
||||
# git is already in the gitea-actions-runner service PATH (the
|
||||
# nixpkgs module builds it from the package's runtime deps).
|
||||
# nix is required for `nix flake check` / `nix build` workflow
|
||||
# steps — it's not included by the upstream module.
|
||||
# Use the `path` service attribute (generates ExecSearchPath=)
|
||||
# to prepend nix's bin dir to PATH without touching the
|
||||
# environment.PATH the nixpkgs module sets — overriding that
|
||||
# would lose git, curl, nodejs, and other runner deps.
|
||||
environment.systemPackages = [
|
||||
pkgs.git
|
||||
pkgs.nix
|
||||
];
|
||||
|
||||
systemd.services."gitea-runner-hive" = {
|
||||
path = [ pkgs.nix ];
|
||||
# Trust the hive CA in Node-based actions. With self-signed TLS,
|
||||
# forgejo's ROOT_URL is `https://forge.<domain>` (CA-signed leaf),
|
||||
# so actions like `upload-artifact` — whose Node HTTP client uses
|
||||
# Node's *bundled* CA bundle, not the system store — reject the
|
||||
# chain with "unable to verify the first certificate". Pointing
|
||||
# NODE_EXTRA_CA_CERTS at the bind-mounted CA adds it to Node's
|
||||
# roots for every action, hive-wide. Inherited by the job
|
||||
# processes the runner spawns (host execution mode). Only set in
|
||||
# self-signed mode; with an operator cert / ACME the public CA
|
||||
# already validates and the bind-mount is absent.
|
||||
environment = lib.mkIf useSelfSigned {
|
||||
NODE_EXTRA_CA_CERTS = caContainerPath;
|
||||
};
|
||||
# Gate runner start (and therefore job registration/claiming) on
|
||||
# the in-container nix daemon being reachable. After a hive-ci
|
||||
# restart the runner re-registers and immediately claims any
|
||||
# queued jobs — which can beat the nix daemon coming up: its
|
||||
# `nix-daemon.socket` carries
|
||||
# `ConditionPathIsReadWrite=/nix/var/nix/daemon-socket` and is
|
||||
# skipped until /nix/var is read-write, so the first
|
||||
# nix-dependent build dispatches into a cold daemon and
|
||||
# hangs/retries (observed: a 55m48s `nix flake check` vs the
|
||||
# normal ~30s, a build-offload stall, not a code failure).
|
||||
#
|
||||
# Ordering `after`/`wants` the socket unit does NOT fix this — a
|
||||
# condition-skipped unit satisfies systemd ordering immediately,
|
||||
# so the runner would still start before the daemon is live.
|
||||
# Instead block in ExecStartPre by polling the daemon until it
|
||||
# actually answers; this is topology-agnostic (works whether the
|
||||
# daemon is in-container or a shared host socket). `mkBefore` so
|
||||
# this runs ahead of any pre-steps the upstream module adds.
|
||||
serviceConfig.ExecStartPre = lib.mkBefore [
|
||||
(pkgs.writeShellScript "wait-nix-daemon" ''
|
||||
# Up to ~180s; the daemon is normally up within seconds, this
|
||||
# only bites in the post-restart cold window. Non-fatal shape:
|
||||
# if it never comes up the unit fails cleanly (recoverable)
|
||||
# rather than the runner claiming jobs into a dead daemon.
|
||||
#
|
||||
# `--store daemon` is load-bearing. A bare `nix store ping`
|
||||
# uses the `auto` store, which — when run as root with the
|
||||
# daemon socket absent — silently resolves to a LOCAL store
|
||||
# (root can write /nix/store directly) and pings successfully.
|
||||
# systemd service units don't source the profile that sets
|
||||
# `NIX_REMOTE=daemon`, so this is the real environment here.
|
||||
# At cold boot the daemon socket IS absent: nix-daemon.socket
|
||||
# carries `ConditionPathIsReadWrite=/nix/var/nix/daemon-socket`
|
||||
# and is condition-skipped until /nix/var goes read-write. So
|
||||
# a bare ping would pass against the local store while the
|
||||
# daemon is still down — defeating the gate's whole purpose
|
||||
# (the runner would start and claim jobs the daemon can't yet
|
||||
# service). Pinning `--store daemon` makes the poll verify the
|
||||
# actual daemon socket, so the gate honours its contract and
|
||||
# waits until the daemon — not a local fallback — answers.
|
||||
for _ in $(seq 1 90); do
|
||||
if ${pkgs.nix}/bin/nix store ping --store daemon >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "nix daemon not reachable after 180s" >&2
|
||||
exit 1
|
||||
'')
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
516
nix/host-modules/hive-forge/default.nix
Normal file
516
nix/host-modules/hive-forge/default.nix
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.forge;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
hyperhiveDomain = config.services.hyperhive.domain;
|
||||
|
||||
# ROOT_URL forgejo advertises in clone links + outbound URLs. When
|
||||
# served behind the gateway, `cfg.domain` doubles as both the
|
||||
# forgejo `DOMAIN` setting AND the gateway vhost server-name, so
|
||||
# ROOT_URL just uses it directly. The gateway always terminates TLS
|
||||
# (self-signed is the implicit floor when neither `tls.certDir` nor
|
||||
# ACME is configured), so behind the gateway the forge is always
|
||||
# advertised over `https` on `httpsPort` — the canonical 443 elides
|
||||
# the port suffix. When direct (`behindGateway = false`), keep the
|
||||
# host:httpPort shape so direct browser access still produces correct
|
||||
# links. Operators can still override via `cfg.rootUrl` for bespoke
|
||||
# shapes.
|
||||
defaultRootUrl =
|
||||
if cfg.behindGateway then
|
||||
let
|
||||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||||
in
|
||||
"https://${cfg.domain}${portSuffix}/"
|
||||
else
|
||||
"http://${cfg.domain}:${toString cfg.httpPort}/";
|
||||
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
||||
|
||||
# When CI is enabled, the runner needs `actions/checkout` resolvable
|
||||
# without external DNS (hive-ci shares the host netns, so a host-resolver
|
||||
# blip otherwise reds every `actions/checkout@vN` fetch from
|
||||
# data.forgejo.org). Auto-append a pull-mirror of it and point
|
||||
# forgejo's DEFAULT_ACTIONS_URL at this instance so `uses:` resolves local.
|
||||
ciEnabled = config.services.hyperhive.forge.ci.enable;
|
||||
actionCheckoutMirror = {
|
||||
upstream = "https://github.com/actions/checkout";
|
||||
dest = "actions/checkout";
|
||||
};
|
||||
# Auto-append the actions/checkout mirror only when CI is on AND the
|
||||
# operator hasn't already declared that dest themselves (else CI-on +
|
||||
# an explicit `actions/checkout` entry would duplicate it).
|
||||
effectiveMirrors =
|
||||
cfg.mirrors
|
||||
++ lib.optional (
|
||||
ciEnabled && !(lib.any (m: m.dest == actionCheckoutMirror.dest) cfg.mirrors)
|
||||
) actionCheckoutMirror;
|
||||
in
|
||||
{
|
||||
# Private Forgejo in a `hive-forge` nixos-container, shared host
|
||||
# netns. Agents reach it at `forge.<domain>` via the gateway. State
|
||||
# at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||||
# restart. See `docs/gateway.md::hive-forge container shape`.
|
||||
|
||||
# The internal forge is mandatory — it's the canonical store for the
|
||||
# meta flake + every agent's config repo (and the `internal/*` repos),
|
||||
# so there is no enable/disable toggle. It deploys whenever hyperhive
|
||||
# itself is enabled (`services.hyperhive.enable`).
|
||||
options.services.hyperhive.forge = {
|
||||
httpPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3000;
|
||||
description = ''
|
||||
TCP port the forge serves HTTP on. Default 3000 sits outside
|
||||
hyperhive's claimed ranges (dashboard 7000, every agent in
|
||||
8100..8999 via FNV-1a hash). Change this if you already have
|
||||
another forgejo bound to 3000.
|
||||
'';
|
||||
};
|
||||
|
||||
sshPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 2222;
|
||||
description = ''
|
||||
TCP port the forge's built-in SSH server listens on. Kept off
|
||||
22 so it doesn't clash with the host's openssh. Agents push
|
||||
with `ssh -p <sshPort> git@<domain>:<owner>/<repo>.git`.
|
||||
'';
|
||||
};
|
||||
|
||||
domain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "forge.${hyperhiveDomain}";
|
||||
defaultText = lib.literalExpression ''"forge.''${services.hyperhive.domain}"'';
|
||||
example = "git.example.com";
|
||||
description = ''
|
||||
Public hostname for the forge. Doubles as both the forgejo
|
||||
`DOMAIN` setting (clone URLs forgejo advertises) AND the
|
||||
gateway vhost server-name when `behindGateway = true`
|
||||
(sub-domain routing — see `docs/gateway.md`).
|
||||
|
||||
Defaults to `forge.''${services.hyperhive.domain}` (idiomatic
|
||||
sub-domain shape — `forge` labelled under the hive's bare
|
||||
domain). `services.hyperhive.domain` is required, so there's
|
||||
always a domain to derive from.
|
||||
|
||||
Set to a full hostname (`git.example.com`,
|
||||
`forge.internal.lan`, etc.) for a bespoke vhost shape — the
|
||||
full domain goes here, no separate sub-domain-label option.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.forgejo;
|
||||
defaultText = lib.literalExpression "pkgs.forgejo";
|
||||
description = ''
|
||||
Forgejo package to run inside the container. Defaults to
|
||||
`pkgs.forgejo` (the latest release line) rather than the
|
||||
nixpkgs-module default of `pkgs.forgejo-lts`, because LTS
|
||||
lags far behind on schema and the DB easily ends up "newer
|
||||
than the binary" if the operator ever ran a non-LTS forgejo
|
||||
against the same state dir. Override to `pkgs.forgejo-lts`
|
||||
if you actively want the slower release train.
|
||||
'';
|
||||
};
|
||||
|
||||
behindGateway = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = config.services.hyperhive.enable;
|
||||
defaultText = lib.literalExpression "config.services.hyperhive.enable";
|
||||
description = ''
|
||||
Serve forgejo through the hive-gateway nginx as a sub-domain
|
||||
vhost (`server_name = cfg.domain`) instead of directly on
|
||||
`httpPort` (sub-domain routing — see `docs/gateway.md`).
|
||||
|
||||
When `true`:
|
||||
- The gateway adds a `server { server_name = ''${cfg.domain}; }`
|
||||
block that proxies all `/` → `http://127.0.0.1:''${httpPort}/`.
|
||||
- Forgejo's `ROOT_URL` flips to `http(s)://''${cfg.domain}/`
|
||||
(sub-domain root, no port suffix when gateway is on 80).
|
||||
- `gateway.localHostsEntry = true` extends `/etc/hosts` to
|
||||
include `cfg.domain → 127.0.0.1` for local dev.
|
||||
|
||||
Defaults to `services.hyperhive.enable` (the gateway always runs
|
||||
alongside hyperhive, so forge auto-routes through it). Set `false`
|
||||
explicitly to keep forge on the direct port even though the
|
||||
gateway is running (e.g. an external git client that doesn't
|
||||
traverse the gateway).
|
||||
|
||||
Sub-domain routing is the preferred shape for forge + matrix
|
||||
(both are external standard apps with sub-domain-native config
|
||||
defaults). Per-agent UIs stay on sub-path (`/agent/<name>/`)
|
||||
because they're hyperhive-internal + already base-path-aware.
|
||||
'';
|
||||
};
|
||||
|
||||
rootUrl = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://forge.example.com/";
|
||||
description = ''
|
||||
Override the auto-derived forgejo `ROOT_URL`. When `null`
|
||||
(default), `ROOT_URL` is derived from `cfg.domain` + gateway
|
||||
state, including the scheme:
|
||||
|
||||
- `behindGateway = true` → `https://''${cfg.domain}/`. The gateway
|
||||
always terminates TLS (self-signed is the implicit floor when no
|
||||
`gateway.tls.certDir` / ACME is set), so the forge is always
|
||||
advertised over https. A non-canonical `gateway.httpsPort` is
|
||||
appended as `:<port>`.
|
||||
- `behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/`
|
||||
|
||||
The TLS scheme is derived automatically now, so you only need to
|
||||
set this for a genuinely bespoke shape (e.g. an external reverse
|
||||
proxy on a different host/path). Must end with `/` per forgejo's
|
||||
`ROOT_URL` contract.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Open `httpPort` + `sshPort` in the host firewall. Off by
|
||||
default (secure-by-default): agent containers reach the forge
|
||||
at `forge.<domain>` via the gateway (not directly), and the
|
||||
host reaches it on loopback — so the firewall opens only
|
||||
matter for access from outside the host. Flip to `true` when
|
||||
you want the operator's browser or external git clients to
|
||||
hit the forge directly.
|
||||
|
||||
**Breaking change**: this used to default to `true`. If you
|
||||
relied on the old default for external reach, add
|
||||
`services.hyperhive.forge.openFirewall = true;` to your host
|
||||
config before rebuilding.
|
||||
'';
|
||||
};
|
||||
|
||||
mirrors = lib.mkOption {
|
||||
type = lib.types.listOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
upstream = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "https://github.com/actions/checkout";
|
||||
description = "Upstream clone URL to mirror from.";
|
||||
};
|
||||
dest = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "actions/checkout";
|
||||
description = ''
|
||||
Local `<owner>/<repo>` the pull-mirror is created at. The
|
||||
`<owner>` org is auto-created if missing. Keep mirror dests
|
||||
in their own orgs (e.g. `actions/*`) — separate from the
|
||||
hive-c0re-managed namespaces (config/shared/agents/core) so
|
||||
the seed never collides with core's own provisioning.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
example = lib.literalExpression ''
|
||||
[ { upstream = "https://github.com/actions/checkout"; dest = "actions/checkout"; } ]
|
||||
'';
|
||||
description = ''
|
||||
General-purpose Forgejo **pull-mirrors** to auto-seed on the local
|
||||
forge. Each entry is created as a real Forgejo pull-mirror (it
|
||||
re-syncs from `upstream` out-of-band), not a one-off pushed clone —
|
||||
so a host-resolver blip leaves a *stale* mirror, never a hard
|
||||
failure on whatever reads it.
|
||||
|
||||
When `services.hyperhive.forge.ci.enable` is set, an
|
||||
`actions/checkout` mirror is auto-appended to this list and
|
||||
forgejo's `DEFAULT_ACTIONS_URL` is pointed at this instance, so CI
|
||||
`uses: actions/checkout@vN` steps resolve entirely on loopback with
|
||||
no external DNS on the critical path (the seed/re-sync needs
|
||||
external DNS, but that's off the CI path).
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.services.hyperhive.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl;
|
||||
message = ''
|
||||
services.hyperhive.forge.rootUrl must end with "/". forgejo's
|
||||
ROOT_URL contract requires a trailing slash for correct
|
||||
relative-link generation; without it forgejo emits URLs like
|
||||
`https://forge.example.com.user.id` instead of
|
||||
`https://forge.example.com/user.id`. Got: ${toString cfg.rootUrl}
|
||||
'';
|
||||
}
|
||||
{
|
||||
# `cfg.domain` can't be empty — would render `.<hive>` shaped
|
||||
# garbage as both server_name (nginx wildcard catch-all) and
|
||||
# /etc/hosts entry (invalid). The default derives a non-empty
|
||||
# `forge.<domain>`, but an operator-set empty string should fail
|
||||
# loud.
|
||||
assertion = cfg.domain != "";
|
||||
message = ''
|
||||
services.hyperhive.forge.domain = "" is rejected. The
|
||||
rendered URLs would be invalid (nginx wildcard catch-all
|
||||
for an empty server_name, /etc/hosts rejects empty entries).
|
||||
Either leave at default (auto-derives to
|
||||
"forge.<services.hyperhive.domain>"), or set a non-empty
|
||||
hostname like "forge.example.com" or "git.internal".
|
||||
'';
|
||||
}
|
||||
{
|
||||
# Each mirror dest must be exactly `<owner>/<repo>` — the seed
|
||||
# splits on the single slash to create the org + repo.
|
||||
assertion = lib.all (m: lib.length (lib.splitString "/" m.dest) == 2) effectiveMirrors;
|
||||
message = ''
|
||||
Every services.hyperhive.forge.mirrors[].dest must be exactly
|
||||
"<owner>/<repo>" (one slash). Got: ${lib.concatMapStringsSep ", " (m: m.dest) effectiveMirrors}
|
||||
'';
|
||||
}
|
||||
{
|
||||
# Keep mirror orgs out of the hive-c0re-managed namespaces
|
||||
# (config/shared/agents/core) so the seed never races / collides
|
||||
# with hive-c0re's own startup provisioning of those orgs.
|
||||
assertion = lib.all (
|
||||
m:
|
||||
!(lib.elem (builtins.elemAt (lib.splitString "/" m.dest) 0) [
|
||||
"config"
|
||||
"shared"
|
||||
"agents"
|
||||
"core"
|
||||
])
|
||||
) effectiveMirrors;
|
||||
message = ''
|
||||
services.hyperhive.forge.mirrors[].dest must not place a mirror
|
||||
in a hive-c0re-managed org (config / shared / agents / core) —
|
||||
those are provisioned by hive-c0re and a mirror there would
|
||||
collide. Use a dedicated org (e.g. "actions/checkout").
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
containers.hive-forge = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Share host netns — forgejo's HTTP / SSH listeners then look
|
||||
# exactly like a host-side service, no port forwarding dance,
|
||||
# and agent containers (which also share host netns) reach it
|
||||
# via plain `localhost`.
|
||||
privateNetwork = false;
|
||||
config =
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
# Build a custom static-root that is the standard forgejo data
|
||||
# output with our theme CSS added. Using STATIC_ROOT_PATH instead
|
||||
# of tmpfiles / bind-mounts means the theme is always present in
|
||||
# the nix store — no separate hive-forge container rebuild needed,
|
||||
# and no persistent-state directory involved.
|
||||
staticRootWithTheme = pkgs.runCommand "forgejo-static-with-theme" { } ''
|
||||
cp -r --no-preserve=mode,ownership ${cfg.package.data}/. $out/
|
||||
mkdir -p $out/public/assets/css
|
||||
cp ${./theme-catppuccin-vibec0re.css} \
|
||||
$out/public/assets/css/theme-catppuccin-vibec0re.css
|
||||
# Replace the default Forgejo logo + favicon with the hyperhive
|
||||
# mark. Files in public/assets/img/ are served before built-ins.
|
||||
mkdir -p $out/public/assets/img
|
||||
cp ${../../../branding/hyperhive.svg} $out/public/assets/img/logo.svg
|
||||
cp ${../../../branding/hyperhive.svg} $out/public/assets/img/favicon.svg
|
||||
cp ${../../../branding/hyperhive.png} $out/public/assets/img/logo.png
|
||||
cp ${../../../branding/hyperhive.png} $out/public/assets/img/favicon.png
|
||||
cp ${../../../branding/hyperhive.png} $out/public/assets/img/avatar_default.png
|
||||
'';
|
||||
in
|
||||
{
|
||||
system.stateVersion = "25.11";
|
||||
services.forgejo = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
database.type = "sqlite3";
|
||||
lfs.enable = true;
|
||||
settings = {
|
||||
DEFAULT.APP_NAME = "HyperHive";
|
||||
server = {
|
||||
DOMAIN = cfg.domain;
|
||||
ROOT_URL = effectiveRootUrl;
|
||||
HTTP_PORT = cfg.httpPort;
|
||||
START_SSH_SERVER = true;
|
||||
SSH_PORT = cfg.sshPort;
|
||||
SSH_LISTEN_PORT = cfg.sshPort;
|
||||
BUILTIN_SSH_SERVER_USER = "git";
|
||||
DISABLE_SSH = false;
|
||||
# Point forgejo at our extended static root that includes
|
||||
# the custom theme CSS baked straight into the nix store.
|
||||
STATIC_ROOT_PATH = staticRootWithTheme;
|
||||
};
|
||||
# Registration off — operator seeds agent users via
|
||||
# `nixos-container run hive-forge -- forgejo admin
|
||||
# user create …`.
|
||||
service = {
|
||||
DISABLE_REGISTRATION = true;
|
||||
REQUIRE_SIGNIN_VIEW = false;
|
||||
};
|
||||
repository = {
|
||||
DEFAULT_BRANCH = "main";
|
||||
DEFAULT_PRIVATE = "private";
|
||||
};
|
||||
# Repo migrations / pull-mirrors fetch from the source
|
||||
# URL *inside* Forgejo. hyperhive code is synced from
|
||||
# `localhost` (and the host LAN), which Forgejo's
|
||||
# migration guard blocks by default ("cannot import from
|
||||
# disallowed hosts"). Allow loopback + RFC-1918 sources
|
||||
# so an in-hive mirror of the hyperhive repo works.
|
||||
migrations.ALLOW_LOCALNETWORKS = true;
|
||||
log.LEVEL = "Warn";
|
||||
ui = {
|
||||
DEFAULT_THEME = "catppuccin-vibec0re";
|
||||
THEMES = "catppuccin-vibec0re,forgejo-auto,forgejo-light,forgejo-dark,gitea-auto,gitea-light,gitea-dark";
|
||||
};
|
||||
# Point forgejo at the GPG key generated by the
|
||||
# forgejo-gpg-init service below. SIGNING_KEY = "default"
|
||||
# resolves via the forgejo process's git config
|
||||
# (`user.signingkey`) — which forgejo-gpg-init sets to the
|
||||
# generated key — not by scanning GNUPGHOME. GNUPGHOME is
|
||||
# the keyring forgejo signs from; must be absolute +
|
||||
# writeable by the forgejo user.
|
||||
"repository.signing" = {
|
||||
SIGNING_KEY = "default";
|
||||
GNUPGHOME = "/var/lib/forgejo/.gnupg";
|
||||
};
|
||||
# Enable Forgejo Actions so the runner registration token
|
||||
# API endpoint is available. Without this the endpoint
|
||||
# returns "runner registration token not found" regardless
|
||||
# of token scopes. Required by `hive-ci-register.service`
|
||||
# in the hive-ci container.
|
||||
actions.ENABLED = true;
|
||||
# When CI is enabled, resolve `uses: <org>/<action>@vN` from
|
||||
# THIS instance (the seeded `actions/checkout` pull-mirror)
|
||||
# instead of the upstream default `data.forgejo.org` — keeps
|
||||
# the checkout step on loopback, immune to a host-resolver
|
||||
# blip. `self` = forgejo expands actions against its
|
||||
# own ROOT_URL.
|
||||
actions.DEFAULT_ACTIONS_URL = lib.mkIf ciEnabled "self";
|
||||
# F3 (federation) computes its data dir relative to the
|
||||
# forgejo binary, which lands in the read-only nix
|
||||
# store and crashes anything that touches the F3
|
||||
# subsystem — including `forgejo admin user create`,
|
||||
# which init-ses F3 even when ENABLED=false. Pin the
|
||||
# path absolute alongside the disable so the init
|
||||
# resolution succeeds before the flag is checked.
|
||||
"F3" = {
|
||||
ENABLED = false;
|
||||
PATH = "/var/lib/forgejo/data/f3";
|
||||
};
|
||||
};
|
||||
};
|
||||
environment.systemPackages = [
|
||||
pkgs.forgejo
|
||||
pkgs.gnupg
|
||||
];
|
||||
|
||||
# Forgejo's local Actions-artifact storage defaults to
|
||||
# `{APP_DATA_PATH}/actions_artifacts` (=
|
||||
# `/var/lib/forgejo/data/actions_artifacts`), but Forgejo does not
|
||||
# pre-create that directory. The artifact endpoint ingests the
|
||||
# chunked upload, then the merge-chunks step does an `lstat` on a
|
||||
# tmp dir under it and fails:
|
||||
# Error merge chunks: lstat
|
||||
# /var/lib/forgejo/data/actions_artifacts/tmpNNN: no such file or
|
||||
# directory
|
||||
# so every `upload-artifact` step dies after the build succeeds.
|
||||
# Pre-create the dir (forgejo-owned) so uploads actually persist.
|
||||
# `actions.ENABLED = true` registers the endpoints; this gives them
|
||||
# somewhere to write.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/forgejo/data 0750 forgejo forgejo - -"
|
||||
"d /var/lib/forgejo/data/actions_artifacts 0750 forgejo forgejo - -"
|
||||
];
|
||||
|
||||
# Ensure Forgejo has a usable GPG signing key so UI merges / CRUD
|
||||
# commits are signed instead of erroring "does not have a signing
|
||||
# key". This service (a) generates a key in forgejo's persistent
|
||||
# keyring iff one isn't already present — keyed on the actual
|
||||
# secret key, NOT a stamp file, so a partial state wipe that loses
|
||||
# the key still regenerates it — and (b) points the forgejo user's
|
||||
# git config at it (`user.signingkey` + commit/tag gpgsign), which
|
||||
# is how `SIGNING_KEY = "default"` actually resolves. Runs as the
|
||||
# forgejo user before forgejo on each start; idempotent (the keygen
|
||||
# is guarded, the git-config is a cheap re-set).
|
||||
systemd.services.forgejo-gpg-init = {
|
||||
description = "ensure Forgejo's GPG signing key + git signing config";
|
||||
# Start before forgejo so the key + signing config are ready when
|
||||
# forgejo reads repository.signing on startup.
|
||||
wantedBy = [ "forgejo.service" ];
|
||||
before = [ "forgejo.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "forgejo";
|
||||
Group = "forgejo";
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "forgejo-gpg-init";
|
||||
};
|
||||
# GNUPGHOME = the keyring forgejo signs from; HOME so
|
||||
# `git config --global` lands where the forgejo process reads it.
|
||||
environment = {
|
||||
GNUPGHOME = "/var/lib/forgejo/.gnupg";
|
||||
HOME = "/var/lib/forgejo";
|
||||
};
|
||||
path = [
|
||||
pkgs.gnupg
|
||||
pkgs.git
|
||||
pkgs.gnugrep
|
||||
pkgs.gawk
|
||||
pkgs.coreutils
|
||||
];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
mkdir -p "$GNUPGHOME"
|
||||
chmod 700 "$GNUPGHOME"
|
||||
|
||||
# Generate only if no secret key is present (key-based guard,
|
||||
# not a stamp — a stamp can outlive the key after a state wipe
|
||||
# and wrongly suppress regeneration).
|
||||
if ! gpg --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec:'; then
|
||||
printf '%s\n' \
|
||||
'%no-protection' \
|
||||
'Key-Type: RSA' \
|
||||
'Key-Length: 4096' \
|
||||
'Name-Real: HyperHive Forge' \
|
||||
'Name-Email: forgejo@hive' \
|
||||
'Expire-Date: 0' \
|
||||
| gpg --batch --gen-key
|
||||
fi
|
||||
|
||||
# Point git (hence Forgejo's SIGNING_KEY="default") at the key.
|
||||
KEYID=$(gpg --list-secret-keys --keyid-format long --with-colons \
|
||||
| awk -F: '/^sec:/ { print $5; exit }')
|
||||
if [ -n "$KEYID" ]; then
|
||||
git config --global user.signingkey "$KEYID"
|
||||
git config --global commit.gpgsign true
|
||||
git config --global tag.gpgsign true
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||
allowedTCPPorts = [
|
||||
cfg.httpPort
|
||||
cfg.sshPort
|
||||
];
|
||||
};
|
||||
|
||||
# Forward the declared pull-mirrors to hive-c0re, which seeds them in
|
||||
# its forge provisioning sweep (`forge.rs::ensure_mirrors`, alongside
|
||||
# the SEEDED_ORGS ensure). c0re already holds the core admin token and
|
||||
# ensures the orgs there, so the seeding lives in one place rather than
|
||||
# a parallel host-side unit. JSON-encoded list of { upstream, dest };
|
||||
# `[]` when nothing to seed (c0re no-ops).
|
||||
systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS = builtins.toJSON effectiveMirrors;
|
||||
};
|
||||
}
|
||||
304
nix/host-modules/hive-forge/theme-catppuccin-vibec0re.css
Normal file
304
nix/host-modules/hive-forge/theme-catppuccin-vibec0re.css
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/* Catppuccin Mocha × vibec0re — hyperhive forge theme
|
||||
Palette: https://catppuccin.com/palette (Mocha)
|
||||
Primary accent: Mauve #cba6f7 (mirrors hive-c0re dashboard --purple)
|
||||
Syntax highlighting: Catppuccin Mocha chroma palette
|
||||
*/
|
||||
|
||||
/* ── chroma syntax highlighting ──────────────────────────────────────── */
|
||||
.chroma .bp{color:#89b4fa}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm{color:#6c7086;font-style:italic}.chroma .cp{color:#a6e3a1}.chroma .cpf{color:#89b4fa}.chroma .cs{color:#cba6f7}.chroma .dl{color:#89b4fa}.chroma .gd{color:#f38ba8;background-color:#3b2335}.chroma .ge{color:#cdd6f4;font-style:italic}.chroma .gh{color:#fab387;font-weight:bold}.chroma .gi{color:#a6e3a1;background-color:#1e3320}.chroma .go{color:#6c7086}.chroma .gp{color:#cdd6f4}.chroma .gr{color:#f38ba8}.chroma .gs{color:#cdd6f4;font-weight:bold}.chroma .gt{color:#fab387}.chroma .gu{color:#a6e3a1;font-weight:bold}.chroma .il{color:#fab387}.chroma .k{color:#cba6f7}.chroma .kc{color:#89b4fa}.chroma .kd{color:#cba6f7}.chroma .kn{color:#94e2d5}.chroma .kp{color:#a6e3a1}.chroma .kr{color:#cba6f7}.chroma .kt{color:#89dceb}.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#fab387}.chroma .n{color:#cdd6f4}.chroma .na,.chroma .nb{color:#89b4fa}.chroma .nc{color:#f9e2af}.chroma .nd{color:#a6e3a1}.chroma .ne{color:#fab387}.chroma .nf,.chroma .ni{color:#89b4fa}.chroma .nl{color:#cba6f7}.chroma .nn{color:#cdd6f4}.chroma .no{color:#fab387}.chroma .nt{color:#f38ba8}.chroma .nv{color:#cdd6f4}.chroma .nx{color:#cdd6f4}.chroma .o{color:#89dceb}.chroma .ow{color:#a6e3a1}.chroma .p{color:#bac2de}.chroma .s,.chroma .s1,.chroma .s2{color:#a6e3a1}.chroma .sa{color:#fab387}.chroma .sb{color:#a6e3a1}.chroma .sc{color:#a6e3a1}.chroma .sd{color:#6c7086;font-style:italic}.chroma .se{color:#f38ba8}.chroma .sh{color:#a6e3a1}.chroma .si{color:#94e2d5}.chroma .sr{color:#cba6f7}.chroma .ss{color:#f38ba8}.chroma .sx{color:#fab387}.chroma .vc,.chroma .vg,.chroma .vi{color:#89b4fa}.chroma .w{color:#585b70}
|
||||
|
||||
/* ── dark-mode image visibility (same as gitea-dark) ─────────────────── */
|
||||
.markup [src$="#gh-light-mode-only"],.markup [src$="#light-mode-only"],.markup [href$="#gh-light-mode-only"],.markup [href$="#light-mode-only"]{display:none}
|
||||
.markup [src$="#gh-dark-mode-only"],.markup [src$="#dark-mode-only"],.markup [href$="#gh-dark-mode-only"],.markup [href$="#dark-mode-only"]{display:unset}
|
||||
|
||||
/* ── Catppuccin Mocha palette → Forgejo CSS vars ─────────────────────── */
|
||||
:root {
|
||||
--is-dark-theme: true;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Primary: Mauve #cba6f7 — matches hive-c0re dashboard --purple */
|
||||
--color-primary: #cba6f7;
|
||||
--color-primary-contrast: #1e1e2e;
|
||||
--color-primary-dark-1: #d0aff8;
|
||||
--color-primary-dark-2: #d5b8f9;
|
||||
--color-primary-dark-3: #dac2fa;
|
||||
--color-primary-dark-4: #dfcbfb;
|
||||
--color-primary-dark-5: #ead9fc;
|
||||
--color-primary-dark-6: #f4effe;
|
||||
--color-primary-dark-7: #faf7ff;
|
||||
--color-primary-light-1: #b895e0;
|
||||
--color-primary-light-2: #a580c7;
|
||||
--color-primary-light-3: #9470b0;
|
||||
--color-primary-light-4: #7d5b9a;
|
||||
--color-primary-light-5: #4d3866;
|
||||
--color-primary-light-6: #2a1e42;
|
||||
--color-primary-light-7: #110d1e;
|
||||
--color-primary-alpha-10: #cba6f719;
|
||||
--color-primary-alpha-20: #cba6f733;
|
||||
--color-primary-alpha-30: #cba6f74b;
|
||||
--color-primary-alpha-40: #cba6f766;
|
||||
--color-primary-alpha-50: #cba6f780;
|
||||
--color-primary-alpha-60: #cba6f799;
|
||||
--color-primary-alpha-70: #cba6f7b3;
|
||||
--color-primary-alpha-80: #cba6f7cc;
|
||||
--color-primary-alpha-90: #cba6f7e1;
|
||||
--color-primary-hover: var(--color-primary-dark-1);
|
||||
--color-primary-active: var(--color-primary-dark-2);
|
||||
|
||||
/* Secondary: Surface1 #45475a */
|
||||
--color-secondary: #45475a;
|
||||
--color-secondary-dark-1: #4e5069;
|
||||
--color-secondary-dark-2: #585b70;
|
||||
--color-secondary-dark-3: #6c7086;
|
||||
--color-secondary-dark-4: #7f849c;
|
||||
--color-secondary-dark-5: #9399b2;
|
||||
--color-secondary-dark-6: #a6adc8;
|
||||
--color-secondary-dark-7: #bac2de;
|
||||
--color-secondary-dark-8: #cdd6f4;
|
||||
--color-secondary-dark-9: #d3dcf6;
|
||||
--color-secondary-dark-10: #d8e1f8;
|
||||
--color-secondary-dark-11: #dde5f9;
|
||||
--color-secondary-dark-12: #e2eafa;
|
||||
--color-secondary-dark-13: #e7eefb;
|
||||
--color-secondary-light-1: #313244;
|
||||
--color-secondary-light-2: #292a3a;
|
||||
--color-secondary-light-3: #1e1e2e;
|
||||
--color-secondary-light-4: #181825;
|
||||
--color-secondary-alpha-10: #45475a19;
|
||||
--color-secondary-alpha-20: #45475a33;
|
||||
--color-secondary-alpha-30: #45475a4b;
|
||||
--color-secondary-alpha-40: #45475a66;
|
||||
--color-secondary-alpha-50: #45475a80;
|
||||
--color-secondary-alpha-60: #45475a99;
|
||||
--color-secondary-alpha-70: #45475ab3;
|
||||
--color-secondary-alpha-80: #45475acc;
|
||||
--color-secondary-alpha-90: #45475ae1;
|
||||
--color-secondary-hover: var(--color-secondary-dark-3);
|
||||
--color-secondary-active: var(--color-secondary-dark-2);
|
||||
|
||||
/* Terminal / console: Crust/Mantle tones */
|
||||
--color-console-fg: #cdd6f4;
|
||||
--color-console-fg-subtle: #a6adc8;
|
||||
--color-console-bg: #11111b;
|
||||
--color-console-border: #313244;
|
||||
--color-console-hover-bg: #1e1e2e;
|
||||
--color-console-active-bg: #313244;
|
||||
--color-console-menu-bg: #181825;
|
||||
--color-console-menu-border: #45475a;
|
||||
|
||||
/* Named accent colours → Catppuccin equivalents */
|
||||
--color-red: #f38ba8;
|
||||
--color-orange: #fab387;
|
||||
--color-yellow: #f9e2af;
|
||||
--color-olive: #a6e3a1;
|
||||
--color-green: #a6e3a1;
|
||||
--color-teal: #94e2d5;
|
||||
--color-blue: #89b4fa;
|
||||
--color-violet: #b4befe;
|
||||
--color-purple: #cba6f7;
|
||||
--color-pink: #f5c2e7;
|
||||
--color-brown: #fab387;
|
||||
--color-black: #11111b;
|
||||
--color-red-light: #eba0ac;
|
||||
--color-orange-light: #fab387;
|
||||
--color-yellow-light: #f9e2af;
|
||||
--color-olive-light: #a6e3a1;
|
||||
--color-green-light: #a6e3a1;
|
||||
--color-teal-light: #94e2d5;
|
||||
--color-blue-light: #89b4fa;
|
||||
--color-violet-light: #b4befe;
|
||||
--color-purple-light: #cba6f7;
|
||||
--color-pink-light: #f5c2e7;
|
||||
--color-brown-light: #fab387;
|
||||
--color-black-light: #313244;
|
||||
--color-red-dark-1: #f38ba8;
|
||||
--color-orange-dark-1:#fab387;
|
||||
--color-yellow-dark-1:#f9e2af;
|
||||
--color-olive-dark-1: #a6e3a1;
|
||||
--color-green-dark-1: #a6e3a1;
|
||||
--color-teal-dark-1: #94e2d5;
|
||||
--color-blue-dark-1: #74c7ec;
|
||||
--color-violet-dark-1:#b4befe;
|
||||
--color-purple-dark-1:#cba6f7;
|
||||
--color-pink-dark-1: #f5c2e7;
|
||||
--color-brown-dark-1: #fab387;
|
||||
--color-black-dark-1: #1e1e2e;
|
||||
--color-red-dark-2: #eb8da4;
|
||||
--color-orange-dark-2:#f5aa80;
|
||||
--color-yellow-dark-2:#f4daa8;
|
||||
--color-olive-dark-2: #9fd99b;
|
||||
--color-green-dark-2: #9fd99b;
|
||||
--color-teal-dark-2: #8dd9cd;
|
||||
--color-blue-dark-2: #6cbfe6;
|
||||
--color-violet-dark-2:#aab4f8;
|
||||
--color-purple-dark-2:#c29ef2;
|
||||
--color-pink-dark-2: #f0b9e2;
|
||||
--color-brown-dark-2: #f0a378;
|
||||
--color-black-dark-2: #181825;
|
||||
|
||||
/* ANSI terminal colours */
|
||||
--color-ansi-black: #11111b;
|
||||
--color-ansi-red: #f38ba8;
|
||||
--color-ansi-green: #a6e3a1;
|
||||
--color-ansi-yellow: #f9e2af;
|
||||
--color-ansi-blue: #89b4fa;
|
||||
--color-ansi-magenta: #f5c2e7;
|
||||
--color-ansi-cyan: #94e2d5;
|
||||
--color-ansi-white: var(--color-console-fg-subtle);
|
||||
--color-ansi-bright-black: #45475a;
|
||||
--color-ansi-bright-red: #f38ba8;
|
||||
--color-ansi-bright-green: #a6e3a1;
|
||||
--color-ansi-bright-yellow: #f9e2af;
|
||||
--color-ansi-bright-blue: #89b4fa;
|
||||
--color-ansi-bright-magenta: #f5c2e7;
|
||||
--color-ansi-bright-cyan: #94e2d5;
|
||||
--color-ansi-bright-white: var(--color-console-fg);
|
||||
|
||||
--color-grey: #45475a;
|
||||
--color-grey-light: #7f849c;
|
||||
--color-gold: #f9e2af;
|
||||
--color-white: #cdd6f4;
|
||||
|
||||
/* Diff colours */
|
||||
--color-diff-removed-word-bg: #4b1c2c;
|
||||
--color-diff-added-word-bg: #1c3a2a;
|
||||
--color-diff-removed-row-bg: #3b1525;
|
||||
--color-diff-moved-row-bg: #3a3520;
|
||||
--color-diff-added-row-bg: #1a2e24;
|
||||
--color-diff-removed-row-border:#6b3044;
|
||||
--color-diff-moved-row-border: #b0a850;
|
||||
--color-diff-added-row-border: #2d5040;
|
||||
--color-diff-inactive: #181825;
|
||||
|
||||
/* Feedback colours */
|
||||
--color-error-border: #f38ba8;
|
||||
--color-error-bg: #3b1525;
|
||||
--color-error-bg-active: #4d1e30;
|
||||
--color-error-bg-hover: #44192b;
|
||||
--color-error-text: #f38ba8;
|
||||
--color-success-border: #a6e3a1;
|
||||
--color-success-bg: #1a2e24;
|
||||
--color-success-text: #a6e3a1;
|
||||
--color-warning-border: #f9e2af;
|
||||
--color-warning-bg: #2e2a1a;
|
||||
--color-warning-text: #f9e2af;
|
||||
--color-info-border: #89b4fa;
|
||||
--color-info-bg: #1a2040;
|
||||
--color-info-text: #89b4fa;
|
||||
|
||||
/* Badge colours */
|
||||
--color-red-badge: #f38ba8;
|
||||
--color-red-badge-bg: #f38ba81a;
|
||||
--color-red-badge-hover-bg: #f38ba84d;
|
||||
--color-green-badge: #a6e3a1;
|
||||
--color-green-badge-bg: #a6e3a11a;
|
||||
--color-green-badge-hover-bg: #a6e3a14d;
|
||||
--color-yellow-badge: #f9e2af;
|
||||
--color-yellow-badge-bg: #f9e2af1a;
|
||||
--color-yellow-badge-hover-bg:#f9e2af4d;
|
||||
--color-orange-badge: #fab387;
|
||||
--color-orange-badge-bg: #fab3871a;
|
||||
--color-orange-badge-hover-bg:#fab3874d;
|
||||
|
||||
/* Layout */
|
||||
--color-body: #1e1e2e;
|
||||
--color-box-header: #181825;
|
||||
--color-box-body: #11111b;
|
||||
--color-box-body-highlight: #1e1e2e;
|
||||
--color-text-dark: #cdd6f4;
|
||||
--color-text: #cdd6f4;
|
||||
--color-text-light: #bac2de;
|
||||
--color-text-light-1: #a6adc8;
|
||||
--color-text-light-2: #9399b2;
|
||||
--color-text-light-3: #7f849c;
|
||||
--color-footer: var(--color-nav-bg);
|
||||
--color-timeline: #45475a;
|
||||
--color-input-text: var(--color-text-dark);
|
||||
--color-input-background: #11111b;
|
||||
--color-input-toggle-background: #313244;
|
||||
--color-input-border: var(--color-secondary);
|
||||
--color-input-border-hover: var(--color-secondary-dark-1);
|
||||
--color-light: #cba6f71a;
|
||||
--color-light-mimic-enabled: rgba(0,0,0,calc(40/255*222/255/var(--opacity-disabled)));
|
||||
--color-light-border: #cba6f726;
|
||||
--color-hover: #cba6f714;
|
||||
--color-active: #313244;
|
||||
--color-menu: #181825;
|
||||
--color-card: #181825;
|
||||
--fancy-card-bg: #11111b;
|
||||
--fancy-card-border: #45475a;
|
||||
--color-markup-table-row: #cba6f70d;
|
||||
--color-markup-code-block: #cba6f710;
|
||||
--color-markup-code-inline:#cba6f724;
|
||||
--color-button: #181825;
|
||||
--color-code-bg: #11111b;
|
||||
--color-shadow: #11111b80;
|
||||
--color-secondary-bg: #313244;
|
||||
--color-expand-button: #292a3a;
|
||||
--color-placeholder-text: var(--color-text-light-3);
|
||||
--color-editor-line-highlight: var(--color-primary-light-5);
|
||||
--color-project-column-bg: var(--color-secondary-light-2);
|
||||
--color-caret: var(--color-text);
|
||||
--color-reaction-bg: #cba6f710;
|
||||
--color-reaction-hover-bg: var(--color-primary-light-4);
|
||||
--color-reaction-active-bg: var(--color-primary-light-5);
|
||||
--color-tooltip-text: #cdd6f4;
|
||||
--color-tooltip-bg: #11111bee;
|
||||
|
||||
/* Navigation */
|
||||
--color-nav-bg: #181825;
|
||||
--color-nav-hover-bg: var(--color-secondary-light-1);
|
||||
--color-secondary-nav-bg: #1e1e2e;
|
||||
|
||||
/* Labels */
|
||||
--color-label-text: var(--color-text);
|
||||
--color-label-bg: #7f849c40;
|
||||
--color-label-hover-bg: #7f849c99;
|
||||
--color-label-active-bg: #7f849cff;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: var(--color-primary-light-1);
|
||||
--color-small-accent: var(--color-primary-light-5);
|
||||
|
||||
/* Highlight / selection */
|
||||
--color-highlight-fg: #cba6f7;
|
||||
--color-highlight-bg: #4d38663a;
|
||||
--color-overlay-backdrop: #11111bc0;
|
||||
--color-selection-bg: var(--color-primary-light-1);
|
||||
--color-selection-fg: #1e1e2e;
|
||||
|
||||
/* Misc */
|
||||
--checkerboard-color-1: #313244;
|
||||
--checkerboard-color-2: #1e1e2e;
|
||||
--color-indicator-offline: #6c7086;
|
||||
--color-indicator-offline-20: #6c70861a;
|
||||
--color-indicator-idle: #a6e3a1;
|
||||
--color-indicator-idle-20: #a6e3a11a;
|
||||
--color-indicator-active: #89b4fa;
|
||||
--color-indicator-active-20: #89b4fa33;
|
||||
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── vibec0re: glow accents on interactive elements ──────────────────── */
|
||||
.ui.primary.button,
|
||||
a.ui.primary.button,
|
||||
.ui.primary.buttons .button {
|
||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.6);
|
||||
box-shadow: 0 0 12px -2px rgba(203, 166, 247, 0.35);
|
||||
}
|
||||
.ui.primary.button:hover,
|
||||
.ui.primary.buttons .button:hover {
|
||||
box-shadow: 0 0 18px -2px rgba(203, 166, 247, 0.55);
|
||||
}
|
||||
#navbar .item.active,
|
||||
#navbar .item:hover {
|
||||
text-shadow: 0 0 6px rgba(203, 166, 247, 0.45);
|
||||
}
|
||||
.repository .file-view .lines-num {
|
||||
background: #181825;
|
||||
border-color: #313244;
|
||||
}
|
||||
a:not(.ui.button):not(.item) {
|
||||
text-shadow: 0 0 3px rgba(137, 180, 250, 0.25);
|
||||
}
|
||||
281
nix/host-modules/hive-gateway/default.nix
Normal file
281
nix/host-modules/hive-gateway/default.nix
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# 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.
|
||||
# `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.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# 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 = 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/host-modules/hive-gateway/dnsmasq.nix
Normal file
59
nix/host-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/host-modules/hive-gateway/error-pages.nix
Normal file
71
nix/host-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>
|
||||
'';
|
||||
};
|
||||
}
|
||||
299
nix/host-modules/hive-gateway/options.nix
Normal file
299
nix/host-modules/hive-gateway/options.nix
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# 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
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "gateway" "selfSignedTls" ] ''
|
||||
Self-signed TLS is the implicit default whenever neither
|
||||
tls.certDir nor tls.acme is configured, and there is no http-only
|
||||
mode. Remove the setting; configure `tls.certDir` or `tls.acme`
|
||||
to override the self-signed default.
|
||||
'')
|
||||
];
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
347
nix/host-modules/hive-gateway/vhosts.nix
Normal file
347
nix/host-modules/hive-gateway/vhosts.nix
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# 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. Listen addresses every vhost shares — plain http
|
||||
# on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md`
|
||||
# ("TLS modes").
|
||||
vhostListen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = cfg.port;
|
||||
}
|
||||
{
|
||||
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
|
||||
{
|
||||
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):
|
||||
# always `https://<host>` (matrix-spec compliance) — the canonical
|
||||
# 443 elides the port. See `docs/gateway.md` ("Self-signed TLS").
|
||||
publicScheme = "https";
|
||||
publicPort = cfg.httpsPort;
|
||||
publicPortSuffix = if publicPort == 443 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;
|
||||
}
|
||||
456
nix/host-modules/hive-matrix.nix
Normal file
456
nix/host-modules/hive-matrix.nix
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.matrix;
|
||||
networkCfg = config.services.hyperhive.network;
|
||||
hyperhiveDomain = config.services.hyperhive.domain;
|
||||
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
|
||||
|
||||
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
|
||||
# skips the dart web-worker compile + the emscripten native_imaging
|
||||
# build. Two derivations below cover both. Full rationale (why
|
||||
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
|
||||
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
|
||||
# fluffychat-web build fixes.
|
||||
|
||||
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
|
||||
pname = "fluffychat-web-imaging";
|
||||
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
|
||||
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
emscripten
|
||||
cmake
|
||||
gnumake
|
||||
jq
|
||||
];
|
||||
|
||||
# cmake runs inside js/Makefile via `emcmake cmake`; the default
|
||||
# configurePhase would invoke cmake at the package root (no
|
||||
# CMakeLists) and fail.
|
||||
dontConfigure = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
# emscripten on-demand sysroot build needs writable HOME + cache.
|
||||
export HOME=$TMPDIR
|
||||
export EM_CACHE=$TMPDIR/.emscriptencache
|
||||
mkdir -p $EM_CACHE
|
||||
# `make -C js` keeps pwd at source root for the installPhase.
|
||||
make -C js Imaging.js Imaging.wasm
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
install -m 644 js/Imaging.js $out/Imaging.js
|
||||
install -m 644 js/Imaging.wasm $out/Imaging.wasm
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web";
|
||||
homepage = "https://pub.dev/packages/native_imaging";
|
||||
license = licenses.agpl3Plus;
|
||||
};
|
||||
};
|
||||
|
||||
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
|
||||
# dart from the flutter341 closure (already pulled, no incremental
|
||||
# cost) to compile the web-worker entry point.
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
|
||||
|
||||
postInstall = (old.postInstall or "") + ''
|
||||
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
|
||||
# package_config walk-up hits buildFlutterApplication's
|
||||
# pub-get output `.dart_tool/`.
|
||||
${pkgs.flutter341.dart}/bin/dart compile js \
|
||||
-o $out/native_executor.js \
|
||||
web/native_executor.dart
|
||||
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
|
||||
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
|
||||
'';
|
||||
});
|
||||
in
|
||||
{
|
||||
# Private matrix-tuwunel homeserver wrapped in a nixos-container,
|
||||
# optional fluffychat-web client at matrix.<hive>/. Container shape,
|
||||
# serverName vs gatewayHost split, provisioning flow (registration
|
||||
# token + LoadCredential), assertion rationale, initial rollout
|
||||
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
|
||||
# knobs: docs/gateway.md.
|
||||
|
||||
options.services.hyperhive.matrix = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Run hive-matrix — a private matrix-tuwunel homeserver (in a
|
||||
nixos-container) for hyperhive agents. Off by default while
|
||||
the integration phases in; flip to `true` once the operator
|
||||
has set `services.hyperhive.domain` and is ready to onboard agents.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.matrix-tuwunel;
|
||||
defaultText = lib.literalExpression "pkgs.matrix-tuwunel";
|
||||
description = ''
|
||||
matrix-tuwunel package to run inside the container. Defaults
|
||||
to nixpkgs's `pkgs.matrix-tuwunel`. Override to pin a
|
||||
specific upstream if you need an unreleased feature.
|
||||
'';
|
||||
};
|
||||
|
||||
serverName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "chat.example.org";
|
||||
description = ''
|
||||
Matrix `server_name` — the host part of every user ID
|
||||
(`@argus:<server_name>`) and room ID minted on this
|
||||
homeserver. CRITICAL: must be stable from day one because
|
||||
it's embedded irrevocably in the identifiers. Defaults to
|
||||
`services.hyperhive.domain` (the bare hive domain). Combined
|
||||
with the `.well-known/matrix/{client,server}` routes the
|
||||
hive-gateway serves at that domain, clients auto-discover the
|
||||
actual matrix endpoint without needing a subdomain. Override
|
||||
here only if you need a different server_name shape (e.g.
|
||||
`matrix.<domain>` if you want the subdomain split, or
|
||||
`chat.example.org` for a bespoke hostname).
|
||||
|
||||
**Breaking change**: this used to default to
|
||||
`matrix.''${services.hyperhive.domain}`. matrix IDs embed
|
||||
the server_name irrevocably, so existing homeservers must
|
||||
set `services.hyperhive.matrix.serverName = "matrix.''${services.hyperhive.domain}";`
|
||||
explicitly to preserve their existing user / room IDs
|
||||
before rebuilding.
|
||||
'';
|
||||
};
|
||||
|
||||
httpPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8008;
|
||||
description = ''
|
||||
TCP port tuwunel serves the matrix client-server API on.
|
||||
Default 8008 is the matrix-spec well-known port. Sits
|
||||
outside hyperhive's claimed ranges (dashboard 7000, every
|
||||
agent in 8100..8999 via FNV-1a hash). Federation listens on
|
||||
`federationPort` separately.
|
||||
'';
|
||||
};
|
||||
|
||||
gatewayHost = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = "matrix.${hyperhiveDomain}";
|
||||
defaultText = lib.literalExpression ''"matrix.''${services.hyperhive.domain}"'';
|
||||
example = "matrix.example.com";
|
||||
description = ''
|
||||
Public hostname for the matrix homeserver behind the gateway.
|
||||
Defaults to `matrix.''${services.hyperhive.domain}` (sub-domain
|
||||
shape — see `docs/gateway.md`). Set to `null` to skip the
|
||||
gateway vhost (tuwunel stays direct on `httpPort`). See
|
||||
`docs/gateway.md` for the vhost map + matrix discovery flow,
|
||||
and the federation port-8448 caveat at the bottom of that doc.
|
||||
|
||||
Note: `gatewayHost` is the API listener hostname (where nginx
|
||||
proxies `/_matrix/*`); `serverName` is the matrix-identifier
|
||||
domain embedded irrevocably in user/room IDs (default = bare
|
||||
hive-domain). The two are distinct.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Open `httpPort` in the host firewall. Off by default
|
||||
(secure-by-default): the host reaches the homeserver on
|
||||
loopback, and agent containers reach it at `matrix.<domain>`
|
||||
via the gateway — so the firewall open only matters for
|
||||
access from outside the host. Flip to `true` when announcing
|
||||
the homeserver to other hives or when an external matrix
|
||||
client needs to reach the client-server API directly.
|
||||
|
||||
**Breaking change**: this used to default to `true`. If you
|
||||
relied on the old default for external reach, add
|
||||
`services.hyperhive.matrix.openFirewall = true;` to your host
|
||||
config before rebuilding.
|
||||
|
||||
Note: federation (the matrix-spec well-known port 8448) is
|
||||
intentionally not opened here. tuwunel serves the federation
|
||||
API on the same `httpPort` as the client-server API by
|
||||
default; reaching it on 8448 requires either binding tuwunel
|
||||
to that port explicitly OR a reverse-proxy + `.well-known/
|
||||
matrix/server` delegation, neither of which lives in this
|
||||
module. Add that proxy config alongside whatever serves your
|
||||
dashboard or forge on 443.
|
||||
'';
|
||||
};
|
||||
|
||||
trustedServers = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "matrix.org" ];
|
||||
description = ''
|
||||
List of trusted matrix servers (homeservers whose signing
|
||||
keys this server will fetch identity-server-style). Empty
|
||||
by default — federation is enabled at the protocol level
|
||||
but no peer is trusted until listed here, so the homeserver
|
||||
is effectively closed until the operator declares hive
|
||||
peers explicitly.
|
||||
'';
|
||||
};
|
||||
|
||||
maxRequestSize = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 20000000;
|
||||
description = ''
|
||||
Maximum size in bytes of a single matrix client request body.
|
||||
Default 20 MB matches the matrix-spec recommendation for
|
||||
media uploads + the upstream tuwunel default.
|
||||
'';
|
||||
};
|
||||
|
||||
registrationTokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/hyperhive/matrix-register-token";
|
||||
description = ''
|
||||
Host path to a file containing the matrix registration token
|
||||
tuwunel reads to authorise new-account creation. The token is
|
||||
generated automatically by `hive-c0re` on first boot (32-byte
|
||||
random hex, mode 0600) and is bind-mounted read-only into the
|
||||
tuwunel container at the same path. Agents never see this
|
||||
token — hive-c0re uses it to provision per-agent accounts
|
||||
and the agent only receives the resulting `access_token`.
|
||||
Override only when integrating with externally-managed
|
||||
registration tokens.
|
||||
'';
|
||||
};
|
||||
|
||||
allowEncryption = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Server-side switch for matrix end-to-end encryption — sets
|
||||
tuwunel's `allow_encryption`. Off by default: on the hive-internal
|
||||
homeserver the operator already controls the transport, so server
|
||||
E2EE adds key-management overhead (cross-signing, device
|
||||
verification, undecryptable-message recovery) without a clear
|
||||
threat-model win for the common single-hive case. Turn on when
|
||||
agents join encrypted rooms on external / federated homeservers,
|
||||
or when the operator wants message contents opaque to the
|
||||
homeserver admin. Independent of the agent matrix client, which
|
||||
always supports decryption so it can read encrypted rooms it is
|
||||
invited to regardless of this flag; this option only governs
|
||||
whether THIS homeserver permits room encryption.
|
||||
'';
|
||||
};
|
||||
|
||||
gui = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.enable;
|
||||
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
|
||||
description = ''
|
||||
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
|
||||
Requires `matrix.gatewayHost != null` (default `matrix.<hive>`
|
||||
when hive-domain set); the gateway itself always runs. When
|
||||
off, the dashboard's `M4TR1X →` tab is hidden. See
|
||||
`docs/gateway.md` for the discovery flow that lets clients
|
||||
auto-find the sub-domain.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = fluffychat-web-fixed;
|
||||
defaultText = lib.literalMD ''
|
||||
`pkgs.fluffychat-web` with a `postInstall` patch that adds
|
||||
the three files `flutter341.buildFlutterApplication` skips.
|
||||
'';
|
||||
description = ''
|
||||
Static web client dist served at `matrix.<hive>/`. Override
|
||||
to swap fluffychat for hydrogen-web, cinny, element-web, or
|
||||
an out-of-tree dist — any replacement is mounted at the
|
||||
sub-domain root with the upstream-default `<base href "/">`,
|
||||
no sub-path gymnastics needed.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# `serverName` is irrevocably embedded in user/room IDs; it derives
|
||||
# from `services.hyperhive.domain` (required, asserted in
|
||||
# hive-network.nix) when not set explicitly, so no separate
|
||||
# domain/serverName assertion is needed here. gatewayHost may not be
|
||||
# "" (same footgun as forge.domain — nginx rejects an empty
|
||||
# server_name). docs/matrix.md::Assertion rationale.
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
|
||||
message = ''
|
||||
services.hyperhive.matrix.gatewayHost = "" is rejected. The
|
||||
rendered URLs would be invalid (nginx wildcard catch-all for
|
||||
an empty server_name, /etc/hosts rejects empty entries).
|
||||
Use `null` to disable the gateway vhost entirely (tuwunel
|
||||
stays direct on httpPort), or set a non-empty hostname like
|
||||
"matrix.example.com" or "homeserver.internal".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Activation-time token generation — without this the bind-mount
|
||||
# would hand tuwunel an empty file on first boot and break every
|
||||
# registration until restart. Idempotent;
|
||||
# docs/matrix.md::Provisioning flow.
|
||||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
||||
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
|
||||
if [ ! -s "$tokenFile" ]; then
|
||||
mkdir -p "$(dirname "$tokenFile")"
|
||||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
|
||||
echo >> "$tokenFile"
|
||||
echo "hive-matrix: generated registration token at $tokenFile"
|
||||
fi
|
||||
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
||||
chmod 0600 "$tokenFile"
|
||||
'';
|
||||
|
||||
containers.hive-matrix = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||||
privateNetwork = false;
|
||||
# Read-only bind of the host-managed registration token; tuwunel
|
||||
# reads it via systemd LoadCredential below (not directly).
|
||||
bindMounts.${cfg.registrationTokenFile} = {
|
||||
hostPath = cfg.registrationTokenFile;
|
||||
isReadOnly = true;
|
||||
};
|
||||
config =
|
||||
{ ... }:
|
||||
{
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
# Peer-hive root CAs (`swarm.peers.<domain>.caCert`) added to THIS
|
||||
# container's trust bundle so tuwunel validates *federation* TLS
|
||||
# from a self-signed peer hive (it checks the peer's federation
|
||||
# cert against its trust bundle). Peer CAs are trusted everywhere
|
||||
# the hive's own internal CA is — agents get them via the
|
||||
# meta-flake renderer (`HIVE_PEER_CA_PATHS` → each agent's
|
||||
# `security.pki.certificateFiles`); this block is the matrix
|
||||
# container's copy, since the host `security.pki` store doesn't
|
||||
# cross the container boundary. They are never installed in the
|
||||
# HOST trust store. Null entries (CA-bundle / fingerprint-pinned
|
||||
# peers) drop out.
|
||||
security.pki.certificateFiles = lib.filter (c: c != null) (
|
||||
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
|
||||
);
|
||||
|
||||
# tuwunel hard-fails to boot if `/etc/resolv.conf` has no
|
||||
# `nameserver` line (`Failed to configure DNS resolver ... no
|
||||
# nameservers found in config` → exit 1). This declarative
|
||||
# nixos-container comes up with an EMPTY resolv.conf even with
|
||||
# `networking.nameservers` set: the nixos-container default
|
||||
# `useHostResolvConf = true` puts in-container resolvconf in
|
||||
# host-tracking mode (ignores `networking.nameservers`, and never
|
||||
# gets the host file across the shared-netns boundary), so it
|
||||
# regenerates an empty file and tuwunel dies at boot.
|
||||
#
|
||||
# Trusting resolvconf to honour `networking.nameservers` doesn't
|
||||
# work either — that's a RUNTIME resolvconf behaviour, not
|
||||
# verifiable at eval time, and it still comes up empty in
|
||||
# practice. So take resolvconf out of the loop entirely and
|
||||
# write a STATIC `/etc/resolv.conf` from `bridgeIp` that nothing
|
||||
# regenerates. Eval-proven: the generated
|
||||
# `environment.etc."resolv.conf".text` is `nameserver <bridgeIp>`.
|
||||
# This container always shares the host netns
|
||||
# (`privateNetwork = false`), so it reaches `bridgeIp` regardless
|
||||
# of agent-container isolation. See `docs/network.md`.
|
||||
networking = {
|
||||
# resolvconf is taken out of the loop entirely; the static
|
||||
# `environment.etc."resolv.conf"` below is the sole source of
|
||||
# the resolver file (no `nameservers` — nothing would read it).
|
||||
useHostResolvConf = lib.mkForce false;
|
||||
resolvconf.enable = lib.mkForce false;
|
||||
};
|
||||
|
||||
# resolvconf is disabled above, so write the static resolver file
|
||||
# explicitly — NixOS won't synthesise one from `nameservers` once
|
||||
# resolvconf is off, and this is the file tuwunel parses at boot.
|
||||
environment.etc."resolv.conf".text = ''
|
||||
nameserver ${networkCfg.bridgeIp}
|
||||
options edns0
|
||||
'';
|
||||
|
||||
services.matrix-tuwunel = {
|
||||
enable = true;
|
||||
package = cfg.package;
|
||||
settings.global = {
|
||||
server_name = effectiveServerName;
|
||||
# `address` + `port` are upstream `listOf` — wrap singles.
|
||||
address = [ "0.0.0.0" ];
|
||||
port = [ cfg.httpPort ];
|
||||
max_request_size = cfg.maxRequestSize;
|
||||
# Federation enabled at the protocol level; empty
|
||||
# trustedServers keeps it effectively closed.
|
||||
allow_federation = true;
|
||||
trusted_servers = cfg.trustedServers;
|
||||
# Token-gated registration. The absent
|
||||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||||
# keeps the server closed to anyone without the token.
|
||||
allow_registration = true;
|
||||
# LoadCredential below copies the host file into a
|
||||
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
||||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
||||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||||
# client always supports decryption regardless.
|
||||
allow_encryption = cfg.allowEncryption;
|
||||
# Tuwunel's default suffix is " 💕" — suppress it so agent
|
||||
# display names are clean (just the agent name, no emoji).
|
||||
new_user_displayname_suffix = "";
|
||||
};
|
||||
};
|
||||
# Keeps DynamicUser=true + PrivateUsers=true intact — no
|
||||
# host-side chown :tuwunel / GID-pin gymnastics needed.
|
||||
# See `man systemd.exec` → LoadCredential.
|
||||
systemd.services.tuwunel.serviceConfig.LoadCredential = [
|
||||
"registration_token:${toString cfg.registrationTokenFile}"
|
||||
];
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||
allowedTCPPorts = [
|
||||
cfg.httpPort
|
||||
];
|
||||
};
|
||||
|
||||
# The matrix container's resolver is the dnsmasq that runs in the
|
||||
# gateway container (bound at `bridgeIp`). Order the matrix
|
||||
# container start after the gateway container so the resolver is up
|
||||
# before tuwunel's first federation lookups. tuwunel boots fine
|
||||
# without this — it configures the resolver from `/etc/resolv.conf`
|
||||
# at startup and only queries on-demand (the boot failure this
|
||||
# module guards against is an *empty* resolv.conf, a parse error,
|
||||
# not a connectivity one) — so this is robustness, not a boot
|
||||
# requirement. Soft `after` ordering (not `requires`) keeps the
|
||||
# matrix container's lifecycle decoupled from the gateway's. The
|
||||
# gateway always runs alongside hyperhive, so the gateway container
|
||||
# unit always exists here. (Declarative `containers.<n>` →
|
||||
# `container@<n>.service` — the nspawn template NixOS generates.)
|
||||
systemd.services."container@hive-matrix".after = [
|
||||
"container@hive-gateway.service"
|
||||
];
|
||||
};
|
||||
}
|
||||
257
nix/host-modules/hive-network.nix
Normal file
257
nix/host-modules/hive-network.nix
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
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.
|
||||
# Always active when hyperhive is enabled: agent containers run in
|
||||
# private netns behind the bridge. Full design: docs/network.md.
|
||||
|
||||
imports = [
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "enable" ] ''
|
||||
The hive network (bridge + dnsmasq resolver + private-netns
|
||||
isolation) is always on whenever hyperhive is enabled. Remove the
|
||||
setting.
|
||||
'')
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] ''
|
||||
Network isolation is the only mode and is always on whenever
|
||||
hyperhive is enabled; the shared-netns path was removed. Remove
|
||||
the setting.
|
||||
'')
|
||||
];
|
||||
|
||||
options.services.hyperhive.network = {
|
||||
bridgeName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hive-br0";
|
||||
example = "h0";
|
||||
description = ''
|
||||
Name of the host-side bridge interface the hive uses for
|
||||
inter-container traffic. Kept short so it survives the
|
||||
IFNAMSIZ (15-char) cap, and prefixed so it's obviously
|
||||
hive-managed in `ip link` output.
|
||||
'';
|
||||
};
|
||||
|
||||
bridgeIp = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "10.42.0.1";
|
||||
example = "172.30.0.1";
|
||||
description = ''
|
||||
IPv4 address assigned to the bridge interface on the host
|
||||
side. Agents use this address as their DNS server (dnsmasq
|
||||
in the gateway container binds here). Default `10.42.0.1`
|
||||
is in RFC 1918 space and unlikely to clash with operator's
|
||||
existing setup; override if a different range is already in
|
||||
use.
|
||||
'';
|
||||
};
|
||||
|
||||
bridgePrefixLength = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 24;
|
||||
example = 16;
|
||||
description = ''
|
||||
Netmask prefix length for the bridge subnet. Default `/24`
|
||||
gives 254 usable per-agent addresses, enough for any
|
||||
single-host hive. Operator with a larger swarm or a tighter
|
||||
addressing scheme overrides.
|
||||
'';
|
||||
};
|
||||
|
||||
upstreamDns = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [
|
||||
"1.1.1.1"
|
||||
"9.9.9.9"
|
||||
];
|
||||
example = [
|
||||
"192.168.1.1"
|
||||
"8.8.8.8"
|
||||
];
|
||||
description = ''
|
||||
Upstream DNS servers dnsmasq forwards non-hive queries to.
|
||||
Defaults to Cloudflare + Quad9. Override for operators on
|
||||
private networks who need a specific resolver (corporate
|
||||
DNS, pi-hole, etc.). The hive resolver itself stays
|
||||
authoritative for `<hive-domain>` and its sub-domains
|
||||
regardless of upstream choice.
|
||||
'';
|
||||
};
|
||||
|
||||
exposeHostPorts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
example = [ 4318 ];
|
||||
description = ''
|
||||
TCP ports on the host that agent containers may reach at the bridge
|
||||
IP (`bridgeIp`). Each listed port `P` is opened on the bridge-interface
|
||||
firewall, so an agent can connect to `''${bridgeIp}:P` (default
|
||||
`10.42.0.1:P`).
|
||||
|
||||
Use this to let agents reach a host-local service — e.g. an
|
||||
OpenTelemetry collector for `services.hyperhive.otel.endpoint` (set
|
||||
`endpoint = "http://''${bridgeIp}:P"`).
|
||||
|
||||
**The host service must bind an address reachable from the bridge** —
|
||||
`0.0.0.0` or the bridge IP (`bridgeIp`) — not loopback-only. The
|
||||
bridge→`127.0.0.0/8` DROP rule (defence-in-depth) is unchanged: this
|
||||
only opens the firewall, it does not bridge loopback. A service that
|
||||
binds `127.0.0.1` only is still unreachable; rebind it to `0.0.0.0`.
|
||||
|
||||
The exposed port is reachable by EVERY agent on the bridge subnet
|
||||
(same as DNS/gateway), so only expose services safe for any agent to
|
||||
reach.
|
||||
'';
|
||||
};
|
||||
|
||||
# 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.
|
||||
'';
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
# The hive network + container isolation are unconditional whenever
|
||||
# hyperhive is enabled: the shared-netns mode was removed, so there
|
||||
# is one mode (private netns behind the bridge).
|
||||
(lib.mkIf config.services.hyperhive.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.services.hyperhive.domain != null;
|
||||
message = ''
|
||||
hyperhive requires services.hyperhive.domain to be set — the
|
||||
hive resolver is authoritative for `<hive-domain>` and its
|
||||
sub-domains, and agents reach the forge/matrix through the
|
||||
gateway by that domain. Pin a hostname
|
||||
(`services.hyperhive.domain = "example.com";`).
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Virtual bridge — each agent container attaches a veth pair (isolation
|
||||
# is unconditional now).
|
||||
networking.bridges.${cfg.bridgeName}.interfaces = [ ];
|
||||
|
||||
# Bridge IP — dnsmasq (in the gateway container) binds here.
|
||||
networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [
|
||||
{
|
||||
address = cfg.bridgeIp;
|
||||
prefixLength = cfg.bridgePrefixLength;
|
||||
}
|
||||
];
|
||||
|
||||
# DNS + DHCP on the bridge interface only — no external amplification
|
||||
# surface. UDP 67 is required for the dnsmasq DHCP pool: dnsmasq
|
||||
# receives DHCPDISCOVER via a regular UDP socket (no netfilter-bypassing
|
||||
# raw socket like ISC dhcpd), so without this hole the host INPUT chain
|
||||
# drops the broadcasts and every container falls back to IPv4LL.
|
||||
networking.firewall.interfaces.${cfg.bridgeName} = {
|
||||
allowedUDPPorts = [
|
||||
53
|
||||
67
|
||||
];
|
||||
allowedTCPPorts = [ 53 ];
|
||||
};
|
||||
})
|
||||
|
||||
# Container isolation overlay — now unconditional (the shared-netns
|
||||
# mode was removed). See docs/network.md#container-isolation.
|
||||
(lib.mkIf config.services.hyperhive.enable {
|
||||
|
||||
# Agents route internet traffic via the bridge; NAT masquerades their RFC-1918 IPs.
|
||||
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
|
||||
networking.nat = {
|
||||
enable = true;
|
||||
internalInterfaces = [ cfg.bridgeName ];
|
||||
};
|
||||
|
||||
# Defence-in-depth: DROP bridge→loopback so compromised agents can't
|
||||
# reach host-loopback services even via routing table leaks.
|
||||
networking.firewall.extraInputRules = ''
|
||||
ip saddr ${cfg.bridgeIp}/${toString cfg.bridgePrefixLength} ip daddr 127.0.0.0/8 drop
|
||||
'';
|
||||
|
||||
# Allow isolated agents to reach the gateway (nginx on the host, shared
|
||||
# netns). Port 80 covers `http://forge.<domain>`, per-agent UI proxies,
|
||||
# and any other HTTP services the gateway fronts. Port 443 for HTTPS.
|
||||
# (`exposeHostPorts` opens its own ports in its dedicated block below,
|
||||
# co-located with the proxies so the firewall hole + listener can't drift.)
|
||||
networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
|
||||
# Tells hive-c0re to pass PRIVATE_NETWORK + bridge settings to each
|
||||
# container. HIVE_NETWORK_SUBNET is host-bridge IP/prefix, not canonical
|
||||
# network address — the Rust side normalises before subnet arithmetic.
|
||||
systemd.services.hive-c0re.environment = {
|
||||
HIVE_NETWORK_ISOLATION = "1";
|
||||
HIVE_NETWORK_BRIDGE = cfg.bridgeName;
|
||||
HIVE_NETWORK_SUBNET = "${cfg.bridgeIp}/${toString cfg.bridgePrefixLength}";
|
||||
};
|
||||
})
|
||||
|
||||
# Host port exposure: open each `exposeHostPorts` entry on the bridge
|
||||
# firewall so agents can reach a host service at `<bridgeIp>:P`. The host
|
||||
# service must bind `0.0.0.0` or the bridge IP (a loopback-only bind stays
|
||||
# unreachable — the bridge→127.0.0.0/8 DROP rule above is unchanged). This
|
||||
# is firewall-only by design: a host service that binds `0.0.0.0` already
|
||||
# serves the bridge IP, so an extra bridge-IP proxy would only collide
|
||||
# (EADDRINUSE) with it. Merges with the [ 80 443 ] gateway ports above.
|
||||
(lib.mkIf (config.services.hyperhive.enable && cfg.exposeHostPorts != [ ]) {
|
||||
networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts;
|
||||
})
|
||||
];
|
||||
}
|
||||
157
nix/host-modules/hive-priv.nix
Normal file
157
nix/host-modules/hive-priv.nix
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# hive-priv — the narrow root privileged helper hive-c0re delegates
|
||||
# to, socket-activated at /run/hive/priv.sock. See docs/boundary.md
|
||||
# for the operator/agent trust-boundary design.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.c0re;
|
||||
|
||||
# Same safe.directory gitconfig as the c0re unit (see ./hive-c0re)
|
||||
# — hive-priv (root) runs nix, which fetches the hive-core-owned
|
||||
# meta/applied repos; libgit2 refuses cross-user reads without it.
|
||||
safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" ''
|
||||
[safe]
|
||||
directory = *
|
||||
'';
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Socket unit for hive-priv — the narrow root helper that executes
|
||||
# privileged operations on behalf of hive-c0re. Systemd creates and
|
||||
# holds `/run/hive/priv.sock` before the first connection arrives.
|
||||
#
|
||||
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
|
||||
# connect. hive-priv (server) runs as root and validates every request
|
||||
# against a strict allowlist before executing any privileged op.
|
||||
systemd.sockets.hive-priv = {
|
||||
description = "hive-priv privileged helper socket";
|
||||
wantedBy = [ "sockets.target" ];
|
||||
socketConfig = {
|
||||
ListenStream = "/run/hive/priv.sock";
|
||||
SocketMode = "0660";
|
||||
SocketGroup = "hive-core";
|
||||
# Create /run/hive/ if absent; 0755 so the hive-core user can
|
||||
# traverse into it to reach the socket.
|
||||
DirectoryMode = "0755";
|
||||
};
|
||||
};
|
||||
|
||||
# Service unit for hive-priv. Runs as root — it genuinely needs root to
|
||||
# invoke `nixos-container`, write `/etc/nixos-containers/`, write
|
||||
# systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`.
|
||||
# Every request is validated against a strict container-name allowlist
|
||||
# inside the binary; the attack surface is narrow by design.
|
||||
#
|
||||
# Socket-activated: systemd starts hive-priv on the first connection
|
||||
# (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv
|
||||
# reads them to accept the pre-bound socket fd instead of binding its
|
||||
# own.
|
||||
systemd.services.hive-priv = {
|
||||
description = "hive-priv privileged helper";
|
||||
# No wantedBy — socket-activated exclusively. The socket unit is the
|
||||
# entry point; systemd starts this service on first connect.
|
||||
after = [ "hive-priv.socket" ];
|
||||
requires = [ "hive-priv.socket" ];
|
||||
# `nixos-container` is a perl script that shells out by bare name to
|
||||
# nix / nix-env / nix-instantiate (create + update), machinectl +
|
||||
# systemctl (start/stop), and find / rm / umount / chattr (destroy);
|
||||
# only nsenter + su are hardcoded. Give the helper exactly those —
|
||||
# not the whole system profile — on top of the systemd/coreutils/
|
||||
# findutils already in the default unit PATH. Without `nixos-container`
|
||||
# on PATH every container op fails ENOENT, which `build_all` silently
|
||||
# swallows into an empty list ("no managed containers").
|
||||
#
|
||||
# `nix` itself shells out by bare name too: `git` whenever it has to
|
||||
# fetch/re-resolve a git-source flake input (an agent.nix with a
|
||||
# `git+https://…` input, or a stale flake.lock whose node URL no longer
|
||||
# matches the flake's declared input → nix re-resolves at eval), and
|
||||
# `ssh` to dispatch to remote builders (`nix.buildMachines` /
|
||||
# `ssh-ng://`). Without these on PATH `nixos-container update` dies with
|
||||
# `executing "git": No such file or directory` / `Could not find
|
||||
# executable 'ssh'` — the agent build fails before it starts.
|
||||
path = [
|
||||
pkgs.nixos-container
|
||||
pkgs.nix # nix, nix-env, nix-instantiate — create + update
|
||||
pkgs.gitMinimal # git — nix fetches/re-resolves git-source flake inputs
|
||||
pkgs.openssh # ssh — nix dispatches builds to remote builders
|
||||
pkgs.util-linux # umount (nsenter is hardcoded in the script)
|
||||
pkgs.e2fsprogs # chattr
|
||||
pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume
|
||||
];
|
||||
environment = {
|
||||
# `nixos-container update/create` runs `nix`, which writes its
|
||||
# fetcher/eval cache under $HOME/.cache. With ProtectHome and no
|
||||
# explicit HOME this lands on the unwritable /var/empty and Lix
|
||||
# errors out. Point HOME at the StateDirectory below (persistent,
|
||||
# so the cache survives across rebuilds).
|
||||
HOME = "/var/lib/hive-priv";
|
||||
# hive-priv runs as root. Root nix defaults to store=auto which
|
||||
# resolves to the LOCAL store — bypassing the host daemon, its
|
||||
# remote builders, and prebuilt derivation outputs. Force daemon
|
||||
# routing so nixos-container update and the nix prebuild see the
|
||||
# same store and substituters as every other build context.
|
||||
NIX_REMOTE = "daemon";
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${cfg.package}/bin/hive-priv";
|
||||
SyslogIdentifier = "hive-priv";
|
||||
Type = "simple";
|
||||
User = "root";
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
# Harden the file system view: strict makes the entire hierarchy
|
||||
# read-only by default; ReadWritePaths carves out exactly the
|
||||
# paths hive-priv must write to at runtime.
|
||||
#
|
||||
# Why each entry is needed:
|
||||
# /etc/nixos-containers — writes <container>.conf (bind mounts,
|
||||
# network isolation, nspawn flags)
|
||||
# /run/hive-agent — chown/chmod per-agent socket directories
|
||||
# /run/systemd — container@ unit drop-ins (resource limits)
|
||||
# + machinectl / systemd-machined state
|
||||
# /run/lock — `nixos-container` opens a lock file at
|
||||
# /run/lock/nixos-container to serialise
|
||||
# create/destroy. Under ProtectSystem=strict
|
||||
# /run is read-only, so without this the very
|
||||
# first `nixos-container create` (ruth, on a
|
||||
# fresh host) dies with "Read-only file
|
||||
# system" before any container exists.
|
||||
# /var/lib/nixos-containers — container rootfs written by nixos-container
|
||||
# /var/lib/hyperhive — agent state files written by WriteAgentForgeToken
|
||||
# / WriteAgentMatrixToken (tokens under agents/<n>/state/)
|
||||
# /nix — nix store + profile updates during
|
||||
# container create/update
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [
|
||||
"/etc/nixos-containers"
|
||||
"/run/hive-agent"
|
||||
"/run/systemd"
|
||||
"/run/lock"
|
||||
"/var/lib/nixos-containers"
|
||||
"/var/lib/hyperhive"
|
||||
"/nix"
|
||||
];
|
||||
# Writable HOME for nix's caches (see environment.HOME above).
|
||||
StateDirectory = "hive-priv";
|
||||
# With ProtectSystem=strict the root filesystem is read-only inside
|
||||
# hive-priv. When `nixos-container create/update` invokes nix, nix
|
||||
# creates a temporary result symlink in its working directory. Without
|
||||
# an explicit WorkingDirectory the cwd is / (inherited from systemd),
|
||||
# which is read-only under strict, causing:
|
||||
# error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system
|
||||
# Point the working directory at the writable StateDirectory so nix
|
||||
# drops its temp symlink there instead.
|
||||
WorkingDirectory = "/var/lib/hive-priv";
|
||||
# nix (run here as root for `nixos-container update --flake
|
||||
# /var/lib/hyperhive/meta#<agent>`) fetches the hive-core-owned
|
||||
# meta/applied repos; libgit2 refuses them without safe.directory.
|
||||
# See safeDirGitconfig above.
|
||||
ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
274
nix/host-modules/hive-tls.nix
Normal file
274
nix/host-modules/hive-tls.nix
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.tls;
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
domain = hyperhiveCfg.domain;
|
||||
|
||||
# The host-managed hive CA is the trust anchor for self-signed mode.
|
||||
# It is only stood up when the gateway actually serves a self-signed
|
||||
# cert: the gateway must be in self-signed mode. `domain` is required
|
||||
# (asserted in hive-network.nix), so the leaf SANs always have a
|
||||
# domain to derive from. The self-signed condition is the gateway
|
||||
# module's single source of truth (`gateway.useSelfSigned`): true when
|
||||
# neither an operator cert (`tls.certDir`) nor ACME is set.
|
||||
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
|
||||
in
|
||||
{
|
||||
# Host-side TLS trust root for the self-signed gateway mode.
|
||||
#
|
||||
# A bare self-signed leaf would be its own trust anchor, so every
|
||||
# regeneration would be a new anchor and every consumer (agents,
|
||||
# federation peers) would have to re-trust on each rotation — and a
|
||||
# runtime-generated, in-container cert can't be wired into an agent's
|
||||
# build-time trust store at all.
|
||||
#
|
||||
# So the anchor is a long-lived **hive CA** held on the host. The
|
||||
# gateway serves a **leaf** signed by that CA (via the `tls.certDir`
|
||||
# bind-mount path); agents and federation peers trust the *CA* once,
|
||||
# and leaf rotation never re-breaks them. See `docs/gateway.md`
|
||||
# ("Self-signed TLS").
|
||||
|
||||
options.services.hyperhive.tls = {
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/hive-tls";
|
||||
description = ''
|
||||
Host directory holding the hive CA + gateway leaf cert for the
|
||||
self-signed gateway mode. `ca.pem` (the anchor agents and
|
||||
federation peers trust), `ca-key.pem` (0600, never leaves the
|
||||
host), `gateway.pem` / `gateway-key.pem` (the leaf the gateway
|
||||
container bind-mounts and nginx serves). Persistent so the CA
|
||||
survives reboots — re-deriving it would re-break every consumer.
|
||||
'';
|
||||
};
|
||||
|
||||
caValidityDays = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 7300;
|
||||
description = ''
|
||||
Validity window of the hive CA in days (default ~20y). Kept long
|
||||
and well beyond `leafValidityDays` so the CA outlives many leaf
|
||||
rotations — the whole point of the CA is to be a stable anchor
|
||||
that consumers trust once. The CA is regenerated only if missing
|
||||
or already expired.
|
||||
'';
|
||||
};
|
||||
|
||||
leafValidityDays = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 30;
|
||||
description = ''
|
||||
Validity window of the gateway leaf cert in days (default 30).
|
||||
Short-lived by design — ahead of the CA/Browser-Forum's move
|
||||
toward ~47-day max lifetimes — which bounds the blast radius of a
|
||||
leaf-key compromise. The leaf is re-signed by the (stable) CA
|
||||
when it is missing or near expiry; because it shares the CA
|
||||
anchor, a rotation does not disturb consumer trust. Agents and
|
||||
federation peers validate against the CA, not browser CA/B-forum
|
||||
limits. The weekly `hive-tls-resign` timer re-signs the leaf once
|
||||
it is within half its validity of expiry and propagates the new
|
||||
leaf into the running gateway, so a long-uptime host renews
|
||||
automatically without a reboot.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf active {
|
||||
# Generate (and rotate) the hive CA + gateway leaf before the gateway
|
||||
# container starts. Idempotent: the CA is created once and reused; the
|
||||
# leaf is re-signed on expiry under the same CA so the anchor is stable.
|
||||
systemd.services.hive-tls-ca = {
|
||||
description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# Gateway nginx reads the leaf from the bind-mount, so the cert must
|
||||
# exist before the container starts. Declarative nixos-containers are
|
||||
# instances of the `container@.service` template.
|
||||
before = [ "container@hive-gateway.service" ];
|
||||
requiredBy = [ "container@hive-gateway.service" ];
|
||||
path = [ pkgs.openssl ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
UMask = "0077";
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "hive-tls-ca";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
d=${lib.escapeShellArg cfg.stateDir}
|
||||
install -d -m 0755 "$d"
|
||||
|
||||
ca="$d/ca.pem"
|
||||
cak="$d/ca-key.pem"
|
||||
leaf="$d/gateway.pem"
|
||||
leafk="$d/gateway-key.pem"
|
||||
|
||||
# --- CA: generate once, reuse across leaf rotations. Regenerate
|
||||
# only if missing or already expired (checkend 0). A new CA means
|
||||
# every consumer must re-trust, so the leaf is dropped to force a
|
||||
# re-sign under the fresh CA.
|
||||
if [ ! -s "$ca" ] || [ ! -s "$cak" ] \
|
||||
|| ! openssl x509 -in "$ca" -noout -checkend 0 >/dev/null 2>&1; then
|
||||
echo "generating fresh hive CA at $ca"
|
||||
openssl req -x509 -newkey rsa:4096 -nodes -sha256 \
|
||||
-days ${toString cfg.caValidityDays} \
|
||||
-keyout "$cak" -out "$ca" \
|
||||
-subj "/CN=hive-ca ${domain}" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
chmod 0600 "$cak"
|
||||
chmod 0644 "$ca"
|
||||
rm -f "$leaf" "$leafk"
|
||||
fi
|
||||
|
||||
# --- Leaf: (re)sign when missing or within 30 days of expiry,
|
||||
# always under the current (stable) CA.
|
||||
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
||||
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
||||
echo "signing fresh gateway leaf at $leaf"
|
||||
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
|
||||
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
|
||||
trap 'rm -f "$csr" "$ext"' EXIT
|
||||
|
||||
openssl req -newkey rsa:4096 -nodes -sha256 \
|
||||
-keyout "$leafk" -out "$csr" \
|
||||
-subj "/CN=${domain}"
|
||||
|
||||
# printf (not a heredoc) so the ext-file lines carry no leading
|
||||
# whitespace once nix has stripped the indented-string indent.
|
||||
{
|
||||
printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \
|
||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \
|
||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain}
|
||||
printf 'basicConstraints=critical,CA:FALSE\n'
|
||||
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
||||
printf 'extendedKeyUsage=serverAuth\n'
|
||||
} > "$ext"
|
||||
|
||||
openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \
|
||||
-CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \
|
||||
-extfile "$ext" -out "$leaf"
|
||||
chmod 0600 "$leafk"
|
||||
chmod 0644 "$leaf"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# Weekly re-sign of the gateway leaf so short-lived leaves renew
|
||||
# without depending on a reboot.
|
||||
#
|
||||
# `hive-tls-ca` only re-signs at service activation (boot/rebuild); a
|
||||
# long-uptime host would otherwise let a 30-day leaf lapse silently.
|
||||
# This service re-signs the leaf directly (not by bouncing hive-tls-ca)
|
||||
# and propagates the new leaf into the running gateway container when
|
||||
# the file actually changed.
|
||||
#
|
||||
# Propagation mechanism: nginx in the gateway container serves a *copy*
|
||||
# of the leaf written by `hive-gateway-self-signed-cert` (which runs at
|
||||
# container start). A host-side `systemctl -M hive-gateway` call
|
||||
# triggers the re-import + reload, mirroring how hive-c0re reloads the
|
||||
# gateway after each agents.conf write. A path unit *inside* the
|
||||
# container was tried first but does not work: IN_MOVED_TO from an
|
||||
# atomic rename on the host does not propagate across the nspawn
|
||||
# mount-namespace boundary.
|
||||
#
|
||||
# `|| true` on propagation so a stopped gateway never fails the unit —
|
||||
# its next boot will import the already-rotated leaf anyway.
|
||||
systemd.services.hive-tls-resign = {
|
||||
description = "Re-sign the gateway TLS leaf and propagate it into the gateway container";
|
||||
# hive-tls-ca must have run first so the CA key exists before we try
|
||||
# to re-sign under it. On first boot `Persistent=true` on the weekly
|
||||
# timer fires immediately; without this ordering the resign could race
|
||||
# the CA initialisation and fail with "no such file" on the CA key.
|
||||
after = [ "hive-tls-ca.service" ];
|
||||
path = [
|
||||
pkgs.openssl
|
||||
pkgs.coreutils
|
||||
pkgs.systemd
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
UMask = "0077";
|
||||
SyslogIdentifier = "hive-tls-resign";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
d=${lib.escapeShellArg cfg.stateDir}
|
||||
ca="$d/ca.pem"
|
||||
cak="$d/ca-key.pem"
|
||||
leaf="$d/gateway.pem"
|
||||
leafk="$d/gateway-key.pem"
|
||||
|
||||
# Re-sign only when the leaf is within half its validity of expiry.
|
||||
# The weekly cadence catches this window well before the leaf lapses.
|
||||
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
|
||||
if [ -s "$leaf" ] && \
|
||||
openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then
|
||||
echo "gateway leaf valid for more than half its lifetime — no resign needed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "gateway leaf missing or near expiry — re-signing under current CA"
|
||||
before="$(sha256sum "$leaf" 2>/dev/null || true)"
|
||||
|
||||
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
|
||||
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
|
||||
trap 'rm -f "$csr" "$ext"' EXIT
|
||||
|
||||
openssl req -newkey rsa:4096 -nodes -sha256 \
|
||||
-keyout "$leafk" -out "$csr" \
|
||||
-subj "/CN=${domain}"
|
||||
|
||||
{
|
||||
printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \
|
||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \
|
||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain}
|
||||
printf 'basicConstraints=critical,CA:FALSE\n'
|
||||
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
||||
printf 'extendedKeyUsage=serverAuth\n'
|
||||
} > "$ext"
|
||||
|
||||
openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \
|
||||
-CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \
|
||||
-extfile "$ext" -out "$leaf"
|
||||
chmod 0600 "$leafk"
|
||||
chmod 0644 "$leaf"
|
||||
|
||||
after="$(sha256sum "$leaf" 2>/dev/null || true)"
|
||||
if [ "$before" != "$after" ]; then
|
||||
echo "gateway leaf rotated — propagating into hive-gateway"
|
||||
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
|
||||
systemctl -M hive-gateway reload nginx.service || true
|
||||
else
|
||||
echo "gateway leaf unchanged (already up to date)"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.hive-tls-resign = {
|
||||
description = "Weekly gateway-leaf re-sign and propagation";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
# Run weekly; Persistent=true fires a missed run on next boot if
|
||||
# the timer was not active (e.g. the host was off on the scheduled
|
||||
# day), preventing a dormant timer from letting the leaf lapse.
|
||||
OnCalendar = "weekly";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts
|
||||
# this file (read-only, the CA cert ONLY — never the key) into each
|
||||
# agent container so agents + their tools can trust the gateway's
|
||||
# self-signed leaf, and the meta flake wires the per-agent trust
|
||||
# bundle. Only the `ca.pem` path is exposed; `ca-key.pem` stays on the
|
||||
# host (an agent that could read it could mint trusted certs).
|
||||
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/ca.pem";
|
||||
};
|
||||
}
|
||||
109
nix/host-modules/hyperhive.nix
Normal file
109
nix/host-modules/hyperhive.nix
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Top-level, cross-cutting hyperhive options: the master enable
|
||||
# switch, the hive's identity (domain + display names), and hive-wide
|
||||
# feature toggles read by several subsystem modules. Imported by the
|
||||
# ./default.nix aggregator.
|
||||
{
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Top-level hyperhive enable flag. When true, automatically enables
|
||||
# hive-c0re and the on-by-default hyperhive subsystems.
|
||||
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator";
|
||||
|
||||
# Canonical hive DNS domain shared by every subsystem that needs a
|
||||
# stable hostname. Typed nullOr (default null) so the option always
|
||||
# exists, but it's REQUIRED whenever hyperhive is enabled — an
|
||||
# assertion in hive-network.nix fails eval when it's unset, since
|
||||
# matrix bakes it in on first boot and the gateway/forge/agent URLs all
|
||||
# derive from it (no safe default). Full identity-surface
|
||||
# context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var
|
||||
# chain → identity.rs → claude prompt): docs/conventions.md::
|
||||
# Hive identity (label + domain + display names).
|
||||
options.services.hyperhive.domain = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "darkest.space";
|
||||
description = ''
|
||||
Canonical host domain for hyperhive subsystems that need a
|
||||
stable name (currently: `services.hyperhive.matrix.serverName`
|
||||
derives from this, defaulting to
|
||||
`matrix.''${services.hyperhive.domain}` when `serverName` is
|
||||
null). **Required** when `services.hyperhive.enable` — eval fails
|
||||
with a helpful message if it's unset (it's baked into matrix on
|
||||
first boot and drives the gateway/forge/agent URLs, with no safe
|
||||
default; changing it later is destructive). Exposed to agents as
|
||||
`HYPERHIVE_HIVE_DOMAIN`; consumed by
|
||||
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
|
||||
qualified labels.
|
||||
'';
|
||||
};
|
||||
|
||||
# Human display names for hive + swarm. Distinct from the DNS
|
||||
# domain above (machine-readable) — see
|
||||
# docs/conventions.md::Hive identity for the
|
||||
# domain-vs-name-vs-swarm distinction + the env-var
|
||||
# propagation chain.
|
||||
options.services.hyperhive.hiveName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "pr1ma";
|
||||
description = ''
|
||||
Human-readable name of this single-host hive instance.
|
||||
Distinct from `services.hyperhive.domain` (the machine-
|
||||
addressable DNS name): the domain may carry the hive name as
|
||||
its leftmost label by convention, but this option is the
|
||||
canonical readable identity. Exposed to agents as
|
||||
`HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and
|
||||
per-agent system prompt when set. Null falls back to the
|
||||
default behaviour (chrome shows the domain, prompt doesn't
|
||||
mention a hive name).
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.swarmName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "constellat1on";
|
||||
description = ''
|
||||
Human-readable name of the wider swarm this hive belongs to.
|
||||
Hives at different DNS domains can share a swarm name when
|
||||
they federate together. Exposed to agents as
|
||||
`HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and
|
||||
per-agent system prompt when set.
|
||||
'';
|
||||
};
|
||||
|
||||
# Whether this hive runs "ruthless" — with no root/manager agent at
|
||||
# all. Some hives don't want a root agent — see issue tracker
|
||||
# "scope concept: special agents".
|
||||
options.services.hyperhive.ruthless = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Run this hive "ruthless" — with no root (manager) agent at all (no
|
||||
ruth). When `true`, hive-c0re skips the root-agent auto-management
|
||||
sweep entirely (it otherwise creates the root agent's container when
|
||||
missing and restarts it when present but stopped). Defaults to
|
||||
`false` (the root agent is auto-managed as required
|
||||
infrastructure). Exposed to hive-c0re as `HYPERHIVE_RUTHLESS`.
|
||||
'';
|
||||
};
|
||||
|
||||
options.services.hyperhive.github.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
|
||||
wrapper + git credential helper, per `hyperhive.github.enable`). On by
|
||||
default: every agent gets the integration, inert until a PAT is
|
||||
provisioned via the dashboard credentials tab or `hivectl github
|
||||
set-token`. Set `false` to turn it off for the whole hive --- the
|
||||
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
|
||||
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
|
||||
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off).
|
||||
'';
|
||||
};
|
||||
}
|
||||
117
nix/host-modules/otel.nix
Normal file
117
nix/host-modules/otel.nix
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Hive-wide OTEL stats export. Set ONCE here at host level; the
|
||||
# meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the
|
||||
# HYPERHIVE_OTEL_* env exported off hive-c0re's unit (see
|
||||
# ./hive-c0re) and injects the matching `hyperhive.otel.*` build-time
|
||||
# config into EVERY agent (mirroring the CA-cert injection), so each
|
||||
# agent's harness exports its own Claude Code stats directly to the
|
||||
# collector. There is no per-agent opt-in — this is the single switch
|
||||
# for the whole hive.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.services.hyperhive.otel = {
|
||||
enable = lib.mkEnableOption ''
|
||||
hive-wide export of every agent's Claude Code stats (token usage,
|
||||
cost, tool calls) to an OTLP endpoint via Claude Code's built-in
|
||||
OpenTelemetry. One switch for all agents; each harness exports
|
||||
directly to the collector, so it keeps working even when hive-c0re
|
||||
is down
|
||||
'';
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "https://collector.example.com/otel";
|
||||
description = ''
|
||||
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||
for every agent. Required when `enable` is true.
|
||||
'';
|
||||
};
|
||||
|
||||
protocol = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"http/protobuf"
|
||||
"http/json"
|
||||
"grpc"
|
||||
];
|
||||
default = "http/protobuf";
|
||||
description = ''
|
||||
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
'';
|
||||
};
|
||||
|
||||
headersCredential = lib.mkOption {
|
||||
# `str`, not `path`: a `path`-typed relative literal is hash-copied
|
||||
# into the world-readable nix store at eval time, defeating the
|
||||
# point. Keep it a string + require an absolute runtime path so the
|
||||
# secret is only ever read from disk by systemd at start.
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/run/secrets/otel-headers";
|
||||
description = ''
|
||||
Absolute path to an operator-provided secret file whose contents
|
||||
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
|
||||
`Authorization=Bearer <token>`). hive-c0re forwards this host
|
||||
file into each agent container's credential store via
|
||||
systemd-nspawn `--load-credential=otel-headers:<path>`; the inner
|
||||
harness unit inherits it by name (`LoadCredential`), so the token
|
||||
is never copied into the nix store, the generated config, a bind
|
||||
mount, or argv. Must be absolute. Leave null if the endpoint
|
||||
needs no auth header. A configured-but-missing file is skipped
|
||||
with a log warning (OTEL still exports, without the auth header).
|
||||
'';
|
||||
};
|
||||
|
||||
extraResourceAttributes = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "deployment.environment=prod";
|
||||
description = ''
|
||||
Extra comma-separated entries appended to
|
||||
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
|
||||
`service.name` / `agent` / `hive` / `swarm` labels.
|
||||
'';
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Emit OTEL SDK diagnostic messages to every agent's stderr by
|
||||
setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when
|
||||
troubleshooting collector connectivity or endpoint config;
|
||||
leave off in normal operation to avoid noise in agent logs.
|
||||
Only meaningful when `enable` is true.
|
||||
'';
|
||||
};
|
||||
|
||||
metricIntervalMs = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
example = 10000;
|
||||
description = ''
|
||||
Metric export interval in milliseconds, set as
|
||||
`OTEL_METRIC_EXPORT_INTERVAL` for every agent. Claude Code's
|
||||
default is 60000 (60s). Leave `null` to use that default.
|
||||
|
||||
Each agent runs claude as a short-lived per-turn process; claude
|
||||
force-flushes metrics on shutdown, so this is not required for
|
||||
metrics to be exported, but a lower value gives more frequent
|
||||
intermediate flushes within long turns. Cosmetic, not a
|
||||
correctness knob.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.services.hyperhive.c0re.enable {
|
||||
assertions = lib.optionals config.services.hyperhive.otel.enable [
|
||||
{
|
||||
assertion = config.services.hyperhive.otel.endpoint != "";
|
||||
message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
246
nix/host-modules/swarm.nix
Normal file
246
nix/host-modules/swarm.nix
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Swarm peering: the peer-hive declarations and the optional
|
||||
# WireGuard inter-hive mesh. The peers are serialised into hive-c0re's
|
||||
# environment (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS — see ./hive-c0re)
|
||||
# and consumed by identity.rs + the dashboard's P33RS tab; the mesh
|
||||
# config below is host-level networking.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Peer hives in the same swarm. Each entry declares a remote hive
|
||||
# reachable from this host.
|
||||
options.services.hyperhive.swarm.peers = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
certFingerprint = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
|
||||
description = ''
|
||||
Expected TLS certificate fingerprint for this peer's HTTPS
|
||||
endpoint. Null = trust the system CA bundle (for Let's
|
||||
Encrypt peers). Set to pin a self-signed cert.
|
||||
|
||||
Format: the literal `sha256:` followed by exactly 64
|
||||
hex digits (case-insensitive, no colon separators) — the
|
||||
SHA-256 digest of the peer's DER-encoded leaf certificate.
|
||||
Generate with `openssl x509 -noout -fingerprint -sha256`,
|
||||
then strip the colons and prepend `sha256:`. A malformed
|
||||
value is ignored with a warning rather than weakening
|
||||
trust. See docs/swarm.md for the full recipe.
|
||||
|
||||
Scopes only to hive-c0re's own peer HTTPS checks — it does
|
||||
NOT help Matrix federation (tuwunel validates against its
|
||||
container trust bundle). For a self-signed peer whose root
|
||||
CA you want trusted hive-wide (every agent + Matrix
|
||||
federation), set `caCert` below.
|
||||
'';
|
||||
};
|
||||
|
||||
caCert = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "./peers/edge-ca.pem";
|
||||
description = ''
|
||||
Path to this peer hive's root CA certificate (PEM). When
|
||||
set, the CA is embedded (at build time, into the nix store
|
||||
— no runtime file on the host) and trusted **everywhere the
|
||||
hive's own internal CA is**: it rides alongside `hive-ca.pem`
|
||||
in each agent's `security.pki.certificateFiles` (via the
|
||||
meta-flake renderer), and is added to the Matrix homeserver
|
||||
container's trust bundle so tuwunel validates *federation*
|
||||
TLS from a self-signed peer hive whose cert chains to it.
|
||||
This is the CA-trust path that `certFingerprint`
|
||||
(leaf-pinning, c0re-only) can't cover, and is what unblocks
|
||||
Matrix federation with a self-signed peer hive. Trust stays
|
||||
inside the hive (agents + the Matrix container), never the
|
||||
host system trust store. Mutually complementary with
|
||||
`certFingerprint`; set `caCert` for the federation case. See
|
||||
docs/swarm.md.
|
||||
'';
|
||||
};
|
||||
|
||||
wireguardPublicKey = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "base64pubkey=";
|
||||
description = ''
|
||||
WireGuard public key for this peer host. Required when
|
||||
`services.hyperhive.swarm.wireguard.enable = true` and
|
||||
you want this peer reachable over the mesh. Null = TLS-
|
||||
only peering (public internet, no mesh tunnel).
|
||||
'';
|
||||
};
|
||||
|
||||
wireguardEndpoint = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "203.0.113.1:51820";
|
||||
description = ''
|
||||
WireGuard endpoint for this peer in `host:port` form.
|
||||
Required when the peer host is behind a firewall and
|
||||
this host needs to initiate the tunnel. Null = this host
|
||||
waits for the peer to connect (peer-initiates; peer must
|
||||
have an endpoint pointing back at this host).
|
||||
'';
|
||||
};
|
||||
|
||||
wireguardAddress = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.100.0.2/32";
|
||||
description = ''
|
||||
IP address (with prefix) of the peer host on the
|
||||
WireGuard mesh. Used as the `allowedIPs` for the peer's
|
||||
WireGuard config entry and injected into `HYPERHIVE_PEERS`
|
||||
so hive-c0re can route intra-swarm traffic to the mesh
|
||||
address rather than the public domain. Required to include
|
||||
the peer in the WireGuard mesh (peers missing this field
|
||||
are silently excluded from `wg-hive`).
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = {
|
||||
"lab.example.com" = {
|
||||
certFingerprint = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
|
||||
};
|
||||
"edge.corp" = { };
|
||||
};
|
||||
description = ''
|
||||
Peer hives in the same swarm. The attrset key is the peer's DNS
|
||||
domain — used for dashboard links and Matrix federation discovery.
|
||||
Null `certFingerprint` trusts the system CA bundle; set it to pin
|
||||
a self-signed TLS cert. Add `wireguardPublicKey` + `wireguardAddress`
|
||||
(and optionally `wireguardEndpoint`) to include the peer in the
|
||||
WireGuard mesh when `swarm.wireguard.enable = true`.
|
||||
'';
|
||||
};
|
||||
|
||||
# WireGuard mesh config for the local host.
|
||||
# When enabled, a `wg-hive` interface connects to all peers that have
|
||||
# `wireguardPublicKey` declared. Peers reachable over the mesh are
|
||||
# preferred for inter-hive traffic (no public TLS round-trip needed);
|
||||
# peers without a public key still work via normal HTTPS.
|
||||
options.services.hyperhive.swarm.wireguard = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable the WireGuard inter-hive mesh. When true, a `wg-hive`
|
||||
interface is brought up connecting to all swarm peers that
|
||||
declare a `wireguardPublicKey`. Requires
|
||||
`privateKeyFile` to be set.
|
||||
'';
|
||||
};
|
||||
|
||||
privateKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "/etc/wireguard/hive.key";
|
||||
description = ''
|
||||
Path to the host's WireGuard private key file. The file must
|
||||
be readable by root and should have mode 0400. Generate with
|
||||
`wg genkey > /etc/wireguard/hive.key`. Required when
|
||||
`swarm.wireguard.enable = true`.
|
||||
'';
|
||||
};
|
||||
|
||||
address = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "10.100.0.1/24";
|
||||
description = ''
|
||||
IP address (with prefix) of this host on the WireGuard mesh.
|
||||
Use a /24 (or broader) prefix so the routing table covers all
|
||||
peer /32 routes. Example: `"10.100.0.1/24"` for a 256-host mesh.
|
||||
'';
|
||||
};
|
||||
|
||||
listenPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 51820;
|
||||
description = ''
|
||||
UDP port the local WireGuard interface listens on. Must be
|
||||
reachable from peer hosts when they initiate the tunnel.
|
||||
Default: 51820 (standard WireGuard port).
|
||||
'';
|
||||
};
|
||||
|
||||
persistentKeepalive = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = 25;
|
||||
example = 25;
|
||||
description = ''
|
||||
Seconds between keepalive packets sent to each peer. Useful
|
||||
when this host (or a peer) is behind NAT — keeps the UDP hole
|
||||
open. Set to null to disable. Default: 25 seconds.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Gated on the c0re daemon being enabled (the historical shape — the
|
||||
# mesh is part of the coordinator host's networking).
|
||||
config = lib.mkIf config.services.hyperhive.c0re.enable {
|
||||
assertions = lib.optionals config.services.hyperhive.swarm.wireguard.enable [
|
||||
{
|
||||
assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null;
|
||||
message = ''
|
||||
services.hyperhive.swarm.wireguard.enable requires
|
||||
services.hyperhive.swarm.wireguard.privateKeyFile to be set.
|
||||
Generate a key: wg genkey > /etc/wireguard/hive.key
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = config.services.hyperhive.swarm.wireguard.address != "";
|
||||
message = ''
|
||||
services.hyperhive.swarm.wireguard.enable requires
|
||||
services.hyperhive.swarm.wireguard.address to be set
|
||||
(e.g. "10.100.0.1/24").
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# WireGuard inter-hive mesh. Brings up a `wg-hive` interface and
|
||||
# connects to each peer that has `wireguardPublicKey` set.
|
||||
networking.wireguard.interfaces = lib.mkIf config.services.hyperhive.swarm.wireguard.enable (
|
||||
let
|
||||
wgCfg = config.services.hyperhive.swarm.wireguard;
|
||||
meshPeers = lib.filterAttrs (
|
||||
_: p: p.wireguardPublicKey != null && p.wireguardAddress != null
|
||||
) config.services.hyperhive.swarm.peers;
|
||||
in
|
||||
{
|
||||
wg-hive = {
|
||||
ips = [ wgCfg.address ];
|
||||
listenPort = wgCfg.listenPort;
|
||||
privateKeyFile = wgCfg.privateKeyFile;
|
||||
peers = lib.mapAttrsToList (
|
||||
_domain: p:
|
||||
{
|
||||
publicKey = p.wireguardPublicKey;
|
||||
allowedIPs = [ p.wireguardAddress ];
|
||||
}
|
||||
// lib.optionalAttrs (p.wireguardEndpoint != null) {
|
||||
endpoint = p.wireguardEndpoint;
|
||||
}
|
||||
// lib.optionalAttrs (wgCfg.persistentKeepalive != null) {
|
||||
persistentKeepalive = wgCfg.persistentKeepalive;
|
||||
}
|
||||
) meshPeers;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
# Open the WireGuard UDP port on the host firewall when the mesh is
|
||||
# on (host-level networking — not inside containers).
|
||||
networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [
|
||||
config.services.hyperhive.swarm.wireguard.listenPort
|
||||
];
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue