Skip to content
36 changes: 36 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
frigate = ./modules/frigate.nix;
hetzner-bare-metal = ./modules/presets/hetzner-bare-metal.nix;
public-frigate = ./modules/presets/public-frigate.nix;
frigate-edge = ./modules/presets/frigate-edge.nix;
wireguard-mesh = ./modules/wireguard-mesh.nix;

# Batteries-included entry point. Bundles nix-bitcoin so the
# consumer needs only `roost` in their flake inputs to deploy a
Expand Down Expand Up @@ -102,6 +104,34 @@
inherit pkgs extraModules;
roost = self;
};

# Two-node test of the wireguard-mesh module. Boots two VMs on
# the test driver's shared virtual network, brings up the mesh,
# and verifies cross-mesh reachability + firewall scoping.
mkMeshTest =
{
pkgs,
extraModules ? [ ],
}:
import ./test/mesh.nix {
inherit pkgs extraModules;
roost = self;
};

# Two-VM end-to-end test for the frigate-edge preset. Boots a
# full nix-bitcoin stack on the `backend` node (with the
# public-frigate exposeBackends option enabled) and a slim
# frigate-edge consumer on the `edge` node, then exercises the
# edge's Electrum listeners.
mkRegtestEdgeE2E =
{
pkgs,
extraModules ? [ ],
}:
import ./test/regtest-edge.nix {
inherit pkgs extraModules;
roost = self;
};
};

checks = forAllLinux (system: {
Expand All @@ -112,6 +142,12 @@
regtest-preset = self.lib.mkRegtestPresetE2E {
pkgs = pkgsFor system;
};
regtest-edge = self.lib.mkRegtestEdgeE2E {
pkgs = pkgsFor system;
};
wireguard-mesh = self.lib.mkMeshTest {
pkgs = pkgsFor system;
};
});

templates.default = {
Expand Down
145 changes: 145 additions & 0 deletions modules/_internal/frigate-tls-acme.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{
config,
lib,
pkgs,
...
}:

# Internal helper: TLS + ACME wiring shared between the `public-frigate`
# and `frigate-edge` presets. Not exported via `nixosModules` and not
# part of the stable API — the options below are flagged `internal`.
#
# A parent preset enables this module and feeds it `host` + `tls`. The
# module materializes `services.frigate.sslCert` / `sslKey`, ACME via
# webroot when an email is set, the nginx vhost serving the HTTP-01
# challenge, the PKCS#8 key conversion frigate's TLS loader requires,
# and the systemd ordering that prevents frigate from racing the
# initial cert issuance.

let
cfg = config.services._roost.frigate-tls-acme;

certFile =
if cfg.tls.certificateFile != null then
cfg.tls.certificateFile
else
"/var/lib/acme/${cfg.host}/fullchain.pem";

keyFile = if cfg.tls.keyFile != null then cfg.tls.keyFile else "/var/lib/acme/${cfg.host}/key.pem";
in
{
options.services._roost.frigate-tls-acme = with lib; {
enable = mkOption {
type = types.bool;
default = false;
internal = true;
description = "Enable shared TLS + ACME wiring. Set by a parent preset, not by hand.";
};

host = mkOption {
type = types.str;
internal = true;
};

tls = {
acmeEmail = mkOption {
type = types.nullOr types.str;
default = null;
internal = true;
};
certificateFile = mkOption {
type = types.nullOr types.path;
default = null;
internal = true;
};
keyFile = mkOption {
type = types.nullOr types.path;
default = null;
internal = true;
};
};
};

config = lib.mkIf cfg.enable (
lib.mkMerge [
{
assertions = [
{
assertion =
(cfg.tls.acmeEmail == null) || (cfg.tls.certificateFile == null && cfg.tls.keyFile == null);
message = ''
tls.acmeEmail is mutually exclusive with tls.certificateFile / tls.keyFile.
'';
}
{
assertion =
(cfg.tls.acmeEmail != null) || (cfg.tls.certificateFile != null && cfg.tls.keyFile != null);
message = ''
TLS requires either tls.acmeEmail (ACME-issued) or both tls.certificateFile
and tls.keyFile (operator-managed).
'';
}
];

services.frigate.sslCert = certFile;
services.frigate.sslKey = keyFile;
services.frigate.extraSupplementaryGroups = lib.optional (cfg.tls.acmeEmail != null) "acme";
}

(lib.mkIf (cfg.tls.acmeEmail != null) {
security.acme = {
acceptTerms = true;
defaults.email = cfg.tls.acmeEmail;
};

# Manage the cert directly via `webroot` HTTP-01 rather than
# nginx's `enableACME` shorthand. The shorthand auto-registers
# nginx (and `nginx-config-reload.service` as root) as cert
# consumers and adds an assertion that the cert be readable by
# both — but our cert lives in the `acme` group for frigate,
# and neither nginx nor the reload service joins it. nginx
# here only needs to serve the HTTP-01 challenge files lego
# drops into the webroot; it never touches the issued cert.
#
# postRun: frigate's TLS loader only accepts PKCS#8
# (`BEGIN PRIVATE KEY`), but lego emits EC keys in SEC1
# (`BEGIN EC PRIVATE KEY`) and RSA keys in PKCS#1
# (`BEGIN RSA PRIVATE KEY`). Convert key.pem in place after
# each issuance/renewal so frigate can parse it. Runs as root
# in the cert directory; `chown acme:acme` keeps the file
# owned the way NixOS would have set it. Idempotent — running
# `openssl pkcs8 -topk8` on an already-PKCS#8 key is a no-op.
security.acme.certs.${cfg.host} = {
domain = cfg.host;
webroot = "/var/lib/acme/acme-challenge";
group = "acme";
reloadServices = [ "frigate.service" ];
postRun = ''
umask 0027
${pkgs.openssl}/bin/openssl pkcs8 -topk8 -nocrypt \
-in key.pem -out key.pem.pkcs8
chown acme:acme key.pem.pkcs8
mv key.pem.pkcs8 key.pem
'';
};

services.nginx = {
enable = true;
virtualHosts.${cfg.host} = {
locations."/.well-known/acme-challenge/".root = "/var/lib/acme/acme-challenge";
locations."/".return = "404";
};
};

networking.firewall.allowedTCPPorts = [ 80 ];

# Block frigate startup until the cert exists, otherwise it
# crash-loops on a missing `fullchain.pem` during a fresh
# deploy. `wants` (not `requires`) so a transient acme failure
# later does not take frigate down with it.
systemd.services.frigate.after = [ "acme-${cfg.host}.service" ];
systemd.services.frigate.wants = [ "acme-${cfg.host}.service" ];
})
]
);
}
161 changes: 161 additions & 0 deletions modules/presets/frigate-edge.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
{
config,
lib,
...
}:

# Edge-mode Frigate: TLS + ACME + frigate, with bitcoind and fulcrum
# living on another host. The consumer points `backend.bitcoind.rpcUrl`,
# `backend.bitcoind.zmqSequenceEndpoint`, and `backend.electrumUrl` at
# the remote endpoints — typically over a private WireGuard mesh (see
# `roost.nixosModules.wireguard-mesh`) — and supplies a credentials
# file containing `user:password` for the bitcoind RPC.
#
# This preset is intentionally narrow: no nix-bitcoin, no local
# services.bitcoind or services.fulcrum, no `manage` flags. If you want
# everything on one box, use `public-frigate` (or `nixosModules.default`)
# instead.

let
cfg = config.services.frigate-edge;
in
{
imports = [
../frigate.nix
../_internal/frigate-tls-acme.nix
];

options.services.frigate-edge = with lib; {
enable = mkEnableOption "edge-mode public Frigate (TLS + ACME, backends on another host)";

host = mkOption {
type = types.str;
example = "albatross.example.com";
description = ''
Public DNS name for this frigate node. Advertised in the Electrum
`server.features` response, used as the SAN clients validate
against the served TLS certificate, and — when `tls.acmeEmail`
is set — as the `security.acme.certs.<name>` identifier.
'';
};

network = mkOption {
type = types.enum [
"mainnet"
"testnet"
"testnet4"
"signet"
"regtest"
];
default = "mainnet";
};

publicPort = mkOption {
type = types.port;
default = 50002;
description = ''
Public TLS port. 50002 is the convention for Electrum-over-SSL.
'';
};

tls = {
acmeEmail = mkOption {
type = types.nullOr types.str;
default = null;
example = "ops@example.com";
description = ''
Email address for Let's Encrypt registration. Setting it enables
ACME for `host`. Mutually exclusive with manual cert/key files.
'';
};

certificateFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to a TLS certificate. Required when not using ACME.";
};

keyFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to the matching PKCS#8 TLS private key. Required when not using ACME.";
};
};

backend = {
bitcoind = {
rpcUrl = mkOption {
type = types.str;
example = "http://10.42.0.1:8332";
description = ''
URL of the bitcoind JSON-RPC endpoint on the backend host.
Plain `http://` is fine when the transport is a private
mesh; do not expose the backend RPC to the public internet.
'';
};

authCredentialFile = mkOption {
type = types.path;
description = ''
File on disk containing literally `user:password` for the
bitcoind RPC user. Loaded via systemd `LoadCredential` and
substituted into frigate's config.toml at service start;
never read by the frigate process directly. Typically an
agenix-decrypted path under `/run/agenix/`.

The corresponding rpcauth line (`user:salt$hash`) lives on
the backend host's bitcoin.conf. Generate the pair once via
bitcoind's `rpcauth.py`.
'';
};

zmqSequenceEndpoint = mkOption {
type = types.str;
example = "tcp://10.42.0.1:28336";
description = ''
URL of the bitcoind ZMQ `sequence` publisher on the backend
host. Frigate subscribes for sub-100ms mempool ingestion.
'';
};
};

electrumUrl = mkOption {
type = types.str;
example = "tcp://10.42.0.1:60001";
description = ''
URL of the backing Electrum server (fulcrum) on the backend
host. Frigate proxies non-silent-payments queries here.
'';
};
};
};

config = lib.mkIf cfg.enable {
# TLS + ACME wiring is shared with public-frigate; delegate to the
# private helper module. TLS-mutex assertions live there.
services._roost.frigate-tls-acme = {
enable = true;
inherit (cfg) host tls;
};

services.frigate = {
enable = true;
host = cfg.host;
network = cfg.network;
# Plaintext listener stays on loopback. All public traffic
# arrives via the TLS listener below.
tcp = "tcp://127.0.0.1:50001";
ssl = "ssl://0.0.0.0:${toString cfg.publicPort}";
bitcoind = {
enable = true;
server = cfg.backend.bitcoind.rpcUrl;
authType = "USERPASS";
authCredentialFile = cfg.backend.bitcoind.authCredentialFile;
zmqSequenceEndpoint = cfg.backend.bitcoind.zmqSequenceEndpoint;
};
electrumBackend = cfg.backend.electrumUrl;
};

networking.firewall.allowedTCPPorts = [ cfg.publicPort ];
};
}
Loading
Loading