From 026081aea69ce0a7f1fe62dc96cd1c70ac71e316 Mon Sep 17 00:00:00 2001 From: josie Date: Tue, 19 May 2026 18:27:43 +0200 Subject: [PATCH 1/5] refactor: extract bitcoind + fulcrum into a shared internal helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `public-frigate` configured bitcoind + fulcrum + ZMQ + optional mesh exposure inline. Once a backend-only preset becomes necessary (a host that runs the same stack for a remote `frigate-edge` consumer, with no frigate of its own — see following commit), both presets need the same wiring. Move it to a private `_internal/bitcoin-stack.nix` helper, mirroring the pattern already used for `frigate-tls-acme`. The helper takes options under `services._roost.bitcoin-stack` (an internal namespace, marked `internal = true` throughout), and parent presets wire them from their own typed options. No behavior change for existing consumers — `public-frigate`'s external surface is identical (same options, same defaults, same runtime config). Verified by `regtest-preset`, which exercises the full preset and continues to pass against the refactored module. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/_internal/bitcoin-stack.nix | 195 ++++++++++++++++++++++++++++ modules/presets/public-frigate.nix | 176 +++++-------------------- 2 files changed, 231 insertions(+), 140 deletions(-) create mode 100644 modules/_internal/bitcoin-stack.nix diff --git a/modules/_internal/bitcoin-stack.nix b/modules/_internal/bitcoin-stack.nix new file mode 100644 index 0000000..1f27945 --- /dev/null +++ b/modules/_internal/bitcoin-stack.nix @@ -0,0 +1,195 @@ +{ + config, + lib, + pkgs, + ... +}: + +# Internal helper: bitcoind + fulcrum stack with optional mesh exposure. +# Shared between `public-frigate` (whose frigate process consumes the stack +# locally) and `bitcoind-backend` (which provides the same stack as a +# remote backend for edge consumers). +# +# Both presets wire `services._roost.bitcoin-stack.{enable, expose.*}` +# from their own typed options. Not part of the stable API. +# +# Why this exists: the configuration of bitcoind (txindex, listen, ZMQ +# sequence publisher, AF_NETLINK workaround for getifaddrs in libzmq) +# and fulcrum (the canonical Electrum backend), plus the optional +# expose-on-private-interface bits (extra rpcbind line, rpcauth user, +# fulcrum tcp= line, interface-scoped firewall), are identical whether +# the consumer is colocated frigate or a remote frigate-edge. + +let + cfg = config.services._roost.bitcoin-stack; + + # Frigate occupies the canonical Electrum ports (50001 plaintext, + # 50002 TLS) when it is the consumer; fulcrum moves off 50001 to + # this non-conflicting port. The README example uses 60001. Captured + # in one place so consumer presets and this stack don't drift. + backendPort = 60001; + + # bitcoind opens its ZMQ sequence socket here. With no edge + # consumers, bind to loopback only. With `expose.enable`, bind to + # 0.0.0.0 so both local frigate (via 127.0.0.1) and remote edge + # frigate (via `bindAddress`) can subscribe; the firewall scopes + # outside access to `expose.interface` only. + zmqPublishBind = if cfg.expose.enable then "0.0.0.0" else "127.0.0.1"; +in +{ + options.services._roost.bitcoin-stack = with lib; { + enable = mkOption { + type = types.bool; + default = false; + internal = true; + description = "Enable shared bitcoind+fulcrum stack. Set by a parent preset, not by hand."; + }; + + dbCache = mkOption { + type = types.int; + default = 4096; + internal = true; + description = "bitcoind UTXO cache in MB. Parent preset may override."; + }; + + expose = { + enable = mkOption { + type = types.bool; + default = false; + internal = true; + }; + bindAddress = mkOption { + type = types.str; + default = ""; + internal = true; + }; + interface = mkOption { + type = types.str; + default = ""; + internal = true; + }; + allowedPeers = mkOption { + type = types.listOf types.str; + default = [ ]; + internal = true; + }; + rpcAuth = { + user = mkOption { + type = types.str; + default = ""; + internal = true; + }; + passwordHMAC = mkOption { + type = types.str; + default = ""; + internal = true; + }; + }; + }; + + # Re-export `backendPort` so parent presets can reference the + # fulcrum listen port without duplicating the constant. Read-only + # by convention; presets don't override. + backendPort = mkOption { + type = types.port; + default = backendPort; + internal = true; + readOnly = true; + }; + }; + + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + # nix-bitcoin requires a secrets policy whenever bitcoind is + # enabled through it. Default to its built-in generator, which + # writes RPC credentials to /etc/nix-bitcoin-secrets (mode 0400) + # on activation. Override to "manual" if secrets are managed out + # of band (agenix etc.). + nix-bitcoin.generateSecrets = lib.mkDefault true; + + services.bitcoind = { + enable = true; + txindex = true; + listen = true; + address = "0.0.0.0"; + dataDirReadableByGroup = true; + dbCache = lib.mkDefault cfg.dbCache; + }; + + services.fulcrum = { + enable = true; + port = lib.mkDefault backendPort; + }; + + # bitcoind p2p port is always public — that's how the node finds + # peers and stays at tip. + networking.firewall.allowedTCPPorts = [ 8333 ]; + + # ZMQ sequence publisher. The endpoint switches between loopback + # and 0.0.0.0 depending on whether the stack is exposing to edge + # consumers; the firewall scopes any external access to the + # configured interface. + # + # nix-bitcoin's bitcoind module loosens RestrictAddressFamilies + # to include AF_NETLINK only when its *typed* ZMQ options + # (`zmqpubrawblock`, `zmqpubrawtx`) are set — see + # `zmqServerEnabled` in modules/bitcoind.nix and `allowNetlink` + # in pkgs/lib.nix on the locked release. Going through + # `extraConfig` bypasses that gate, so libzmq's `getifaddrs()` + # call during `zmq_bind` hits EAFNOSUPPORT and `resolve_nic_name` + # aborts the daemon. Mirror `allowNetlink` here: + # `AF_UNIX AF_INET AF_INET6` is the verbatim + # `defaultHardening.RestrictAddressFamilies` value, plus the + # `AF_NETLINK` `allowNetlink` would have added. mkForce because + # the nix-bitcoin module already assigns the string. + services.bitcoind.extraConfig = '' + zmqpubsequence=tcp://${zmqPublishBind}:28336 + ''; + systemd.services.bitcoind.serviceConfig.RestrictAddressFamilies = + lib.mkForce "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; + } + + # Expose path: bind bitcoind RPC + ZMQ + fulcrum on a mesh + # interface for edge consumers. + # + # bitcoind RPC: nix-bitcoin's `rpc.address` is single-valued, so + # keep the typed loopback default and append a second `rpcbind=` + # via extraConfig. bitcoind accepts repeated rpcbind lines. + # + # ZMQ: already flips to 0.0.0.0 above when `expose.enable` is set. + # + # fulcrum: same single-bind pattern. Typed `address` stays on + # loopback; an extra `tcp = ...` line is appended via `extraConfig` + # for the mesh address. + (lib.mkIf cfg.expose.enable { + services.bitcoind = { + rpc.allowip = [ "127.0.0.1" ] ++ cfg.expose.allowedPeers; + rpc.users.${cfg.expose.rpcAuth.user} = { + inherit (cfg.expose.rpcAuth) passwordHMAC; + }; + extraConfig = '' + rpcbind=${cfg.expose.bindAddress} + ''; + }; + + services.fulcrum.extraConfig = '' + tcp = ${cfg.expose.bindAddress}:${toString backendPort} + ''; + + # Scope the open ports to the mesh interface only. Outside + # traffic (e.g. the public internet on eth0) is dropped at + # INPUT by NixOS's default-deny firewall posture. + # + # bitcoind's RPC port is pulled from config rather than + # hardcoded — nix-bitcoin's `rpc.port` default tracks the chain + # (8332 mainnet, 18443 regtest, 18332 testnet, etc.). + networking.firewall.interfaces.${cfg.expose.interface}.allowedTCPPorts = [ + config.services.bitcoind.rpc.port + 28336 + backendPort + ]; + }) + ] + ); +} diff --git a/modules/presets/public-frigate.nix b/modules/presets/public-frigate.nix index 9a0528a..7f1b0fb 100644 --- a/modules/presets/public-frigate.nix +++ b/modules/presets/public-frigate.nix @@ -7,32 +7,19 @@ let cfg = config.services.public-frigate; - - # Frigate occupies the canonical Electrum ports (50001 plaintext, - # `publicPort` for TLS); the backend Electrum server (fulcrum) moves - # off 50001 to this non-conflicting port. The README example uses - # 60001. Captured here so the fulcrum listen port and frigate's - # `electrumBackend` URL can't drift apart. - backendPort = 60001; + stack = config.services._roost.bitcoin-stack; # The local frigate process always reads ZMQ off loopback; that's a # constant. When `exposeBackends` is on, bitcoind additionally binds # the same socket on the mesh address so edge consumers can subscribe - # — see the publish endpoint below. + # (the bitcoin-stack helper handles the bind switch). zmqSequenceEndpoint = "tcp://127.0.0.1:28336"; - - # Where bitcoind opens the ZMQ socket. With no edge consumers, bind - # to loopback only. With `exposeBackends.enable`, bind to 0.0.0.0 so - # both local frigate (via 127.0.0.1) and remote edge frigate (via - # `bindAddress`) can subscribe; the firewall scopes outside access - # to `exposeBackends.interface` only. - zmqPublishBind = if cfg.exposeBackends.enable then "0.0.0.0" else "127.0.0.1"; - zmqPublishEndpoint = "tcp://${zmqPublishBind}:28336"; in { imports = [ ../frigate.nix ../_internal/frigate-tls-acme.nix + ../_internal/bitcoin-stack.nix ]; options.services.public-frigate = with lib; { @@ -194,6 +181,27 @@ in }; } + # bitcoind + fulcrum (+ optional mesh exposure) are shared with + # `bitcoind-backend`; delegate to the private helper module. + # Only activate the helper when this preset is the one managing + # the services locally — the `manage = false` path lets a + # consumer wire bitcoind/fulcrum out of band and just have + # frigate point at them. + (lib.mkIf (cfg.bitcoind.manage && cfg.fulcrum.manage) { + services._roost.bitcoin-stack = { + enable = true; + expose = { + enable = cfg.exposeBackends.enable; + bindAddress = cfg.exposeBackends.bindAddress; + interface = cfg.exposeBackends.interface; + allowedPeers = cfg.exposeBackends.allowedPeers; + rpcAuth = { + inherit (cfg.exposeBackends.rpcAuth) user passwordHMAC; + }; + }; + }; + }) + { assertions = [ { @@ -217,40 +225,26 @@ in and enable it, or set services.public-frigate.fulcrum.manage = true. ''; } + { + assertion = !cfg.exposeBackends.enable || (cfg.bitcoind.manage && cfg.fulcrum.manage); + message = '' + services.public-frigate.exposeBackends.enable requires both + bitcoind.manage = true and fulcrum.manage = true. The preset + cannot expose services it does not configure. + ''; + } ]; } - (lib.mkIf cfg.bitcoind.manage { - # nix-bitcoin requires a secrets policy whenever bitcoind is enabled - # through it. Default to its built-in generator, which writes RPC - # credentials to /etc/nix-bitcoin-secrets (mode 0400) on activation. - # Override to "manual" if you manage secrets out of band (agenix etc.). - nix-bitcoin.generateSecrets = lib.mkDefault true; - - services.bitcoind = { - enable = true; - txindex = true; - listen = true; - address = "0.0.0.0"; - dataDirReadableByGroup = true; - dbCache = lib.mkDefault 4096; - }; - networking.firewall.allowedTCPPorts = [ 8333 ]; - }) - - (lib.mkIf cfg.fulcrum.manage { - services.fulcrum.enable = true; - }) - { # Frigate terminates TLS itself on the public port. The plaintext # listener is bound to loopback for local probes/operator use — # all public traffic comes in over `ssl`. The backend Electrum - # server (fulcrum/electrs/etc.) listens on a non-conflicting port - # so frigate can occupy the canonical Electrum ports. + # server (fulcrum) listens on `bitcoin-stack`'s `backendPort` so + # frigate can occupy the canonical Electrum ports. # # `sslCert`, `sslKey` and `extraSupplementaryGroups` are set by - # the shared TLS+ACME helper (imported above). + # the shared TLS+ACME helper. services.frigate = { enable = true; host = cfg.host; @@ -264,7 +258,7 @@ in cookieDir = "/var/lib/bitcoind"; inherit zmqSequenceEndpoint; }; - electrumBackend = "tcp://127.0.0.1:${toString backendPort}"; + electrumBackend = "tcp://127.0.0.1:${toString stack.backendPort}"; }; users.users.frigate.extraGroups = [ "bitcoin" ]; @@ -278,106 +272,8 @@ in "fulcrum.service" ]; - # Move fulcrum off 50001 so frigate can occupy the canonical - # Electrum ports. mkDefault so a consumer running their own - # fulcrum out of band can still override. - services.fulcrum.port = lib.mkDefault backendPort; - networking.firewall.allowedTCPPorts = [ cfg.publicPort ]; } - - # Pair bitcoind's ZMQ sequence publisher with frigate's - # `zmqSequenceEndpoint`. Only wired here when the preset is - # managing bitcoind — a consumer running bitcoind out of band must - # add `zmqpubsequence=...` (matching the endpoint above) - # themselves, or mkForce - # `services.frigate.bitcoind.zmqSequenceEndpoint = null` to fall - # back to polling (and accept the upstream warning). - # - # nix-bitcoin's bitcoind module loosens RestrictAddressFamilies to - # include AF_NETLINK only when its *typed* ZMQ options - # (`zmqpubrawblock`, `zmqpubrawtx`) are set — see `zmqServerEnabled` - # in modules/bitcoind.nix and `allowNetlink` in pkgs/lib.nix on - # the locked release. Going through `extraConfig` bypasses that - # gate, so libzmq's `getifaddrs()` call during `zmq_bind` hits - # EAFNOSUPPORT and `resolve_nic_name` aborts the daemon. Mirror - # `allowNetlink` here: `AF_UNIX AF_INET AF_INET6` is the verbatim - # `defaultHardening.RestrictAddressFamilies` value, plus the - # `AF_NETLINK` `allowNetlink` would have added. mkForce because - # the nix-bitcoin module already assigns the string. - (lib.mkIf cfg.bitcoind.manage { - services.bitcoind.extraConfig = '' - zmqpubsequence=${zmqPublishEndpoint} - ''; - systemd.services.bitcoind.serviceConfig.RestrictAddressFamilies = - lib.mkForce "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; - }) - - # exposeBackends: bind bitcoind RPC + ZMQ + fulcrum on the mesh - # interface for an edge consumer. Only honored when the preset is - # managing those services locally — exposing services we don't - # manage would be a contract violation. - # - # bitcoind RPC: nix-bitcoin's `rpc.address` is single-valued, so - # we keep the typed loopback default and append a second - # `rpcbind=` via extraConfig. bitcoind accepts repeated rpcbind - # lines and binds each one. - # - # ZMQ: the publish endpoint above (`zmqPublishEndpoint`) already - # flips to 0.0.0.0 when exposeBackends is on — no extraConfig - # work needed here for ZMQ. - # - # fulcrum: same single-bind option pattern as bitcoind RPC. The - # typed `address` stays on loopback; an extra `tcp = ...` line is - # appended via `extraConfig` for the mesh address. - (lib.mkIf cfg.exposeBackends.enable { - assertions = [ - { - assertion = cfg.bitcoind.manage; - message = '' - services.public-frigate.exposeBackends.enable requires - services.public-frigate.bitcoind.manage = true. The preset - cannot expose a bitcoind it does not configure. - ''; - } - { - assertion = cfg.fulcrum.manage; - message = '' - services.public-frigate.exposeBackends.enable requires - services.public-frigate.fulcrum.manage = true. The preset - cannot expose a fulcrum it does not configure. - ''; - } - ]; - - services.bitcoind = { - rpc.allowip = [ "127.0.0.1" ] ++ cfg.exposeBackends.allowedPeers; - rpc.users.${cfg.exposeBackends.rpcAuth.user} = { - inherit (cfg.exposeBackends.rpcAuth) passwordHMAC; - }; - extraConfig = '' - rpcbind=${cfg.exposeBackends.bindAddress} - ''; - }; - - services.fulcrum.extraConfig = '' - tcp = ${cfg.exposeBackends.bindAddress}:${toString backendPort} - ''; - - # Scope the open ports to the mesh interface only. Outside - # traffic (e.g. the public internet on eth0) is dropped at - # INPUT by NixOS's default-deny firewall posture. - # - # Pull bitcoind's RPC port from config rather than hardcoding - # `8332`. nix-bitcoin's `rpc.port` default tracks the chain - # (8332 mainnet, 18443 regtest, 18332 testnet, etc.), and the - # firewall has to match wherever bitcoind actually listens. - networking.firewall.interfaces.${cfg.exposeBackends.interface}.allowedTCPPorts = [ - config.services.bitcoind.rpc.port - 28336 - backendPort - ]; - }) ] ); } From 3fc4fef2146317bddf28d2f51b6b9d0b879f2335 Mon Sep 17 00:00:00 2001 From: josie Date: Tue, 19 May 2026 18:30:59 +0200 Subject: [PATCH 2/5] feat: add bitcoind-backend preset for split-deployment backend host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bitcoind-backend` is the sibling preset to `public-frigate`: bitcoind + fulcrum + ZMQ sequence publisher, exposed on a private interface, with no frigate process on this box. Pairs with `frigate-edge` on remote consumer hosts. Useful when the latency-sensitive consumer is in a different DC from the existing public-frigate host (the loopback cost is loopback; the remote-backend cost is per-call RTT) and the consumer host can't afford the ~950 GB storage that bitcoind+fulcrum needs. Shares the bitcoind+fulcrum+expose logic with `public-frigate` via the `_internal/bitcoin-stack.nix` helper from the preceding commit; this preset is mostly an options surface and a small `mkIf` that wires the helper. Also adds: - `nixosModules.bitcoind-backend-host` — a batteries-included bundle (nix-bitcoin + the preset), so consumers needing only `roost` in their flake inputs can deploy a backend host. - `test/regtest-backend.nix` — single-VM nixosTest that boots `bitcoind-backend`, mines 101 regtest blocks, and verifies the backend ports listen on the configured mesh address, the configured rpcauth user can authenticate, and a wrong password is rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- flake.nix | 41 ++++++-- modules/presets/bitcoind-backend.nix | 148 +++++++++++++++++++++++++++ test/regtest-backend.nix | 142 +++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 modules/presets/bitcoind-backend.nix create mode 100644 test/regtest-backend.nix diff --git a/flake.nix b/flake.nix index 2aef96e..ca63c80 100644 --- a/flake.nix +++ b/flake.nix @@ -81,14 +81,15 @@ hetzner-bare-metal = ./modules/presets/hetzner-bare-metal.nix; public-frigate = ./modules/presets/public-frigate.nix; frigate-edge = ./modules/presets/frigate-edge.nix; + bitcoind-backend = ./modules/presets/bitcoind-backend.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 - # complete public Frigate node, and turns on the preset's manage - # flags so bitcoind and fulcrum are configured automatically. - # Use `nixosModules.public-frigate` directly if you operate - # bitcoind/fulcrum out of band. + # Batteries-included entry point for an all-in-one public + # Frigate node. Bundles nix-bitcoin so the consumer needs only + # `roost` in their flake inputs, and turns on the preset's + # manage flags so bitcoind and fulcrum are configured + # automatically. Use `nixosModules.public-frigate` directly if + # you operate bitcoind/fulcrum out of band. default = { imports = [ nix-bitcoin.nixosModules.default @@ -99,6 +100,17 @@ fulcrum.manage = nixpkgs.lib.mkDefault true; }; }; + + # Batteries-included entry point for a bitcoind-backend host — + # bundles nix-bitcoin + the bitcoind-backend preset. Use this + # on the box that hosts bitcoind/fulcrum for a remote + # `frigate-edge` consumer; no frigate is configured here. + bitcoind-backend-host = { + imports = [ + nix-bitcoin.nixosModules.default + ./modules/presets/bitcoind-backend.nix + ]; + }; }; formatter = forAllSystems (system: (pkgsFor system).nixfmt-tree); @@ -156,6 +168,20 @@ inherit pkgs extraModules; roost = self; }; + + # Single-VM test for the bitcoind-backend preset. Verifies the + # backend stack (bitcoind RPC + ZMQ + fulcrum) comes up with + # the right bindings and that an external-looking RPC call + # using the configured rpcauth user succeeds. + mkRegtestBackend = + { + pkgs, + extraModules ? [ ], + }: + import ./test/regtest-backend.nix { + inherit pkgs extraModules; + roost = self; + }; }; checks = forAllLinux (system: { @@ -169,6 +195,9 @@ regtest-edge = self.lib.mkRegtestEdgeE2E { pkgs = pkgsFor system; }; + regtest-backend = self.lib.mkRegtestBackend { + pkgs = pkgsFor system; + }; wireguard-mesh = self.lib.mkMeshTest { pkgs = pkgsFor system; }; diff --git a/modules/presets/bitcoind-backend.nix b/modules/presets/bitcoind-backend.nix new file mode 100644 index 0000000..eb0d901 --- /dev/null +++ b/modules/presets/bitcoind-backend.nix @@ -0,0 +1,148 @@ +{ + config, + lib, + ... +}: + +# Backend-only preset: bitcoind + fulcrum + ZMQ sequence publisher, +# exposed on a private interface for one or more edge consumers (a +# `frigate-edge` somewhere else). No frigate process here, no TLS, no +# ACME — this box's job is to be the Bitcoin Core RPC + Electrum +# backend reachable over a mesh. +# +# Pairs with `frigate-edge` on consumer hosts. The two halves wire up +# via `roost.nixosModules.wireguard-mesh` (or any other private +# transport — `bitcoind-backend` is transport-neutral; it just binds +# its services on a configured address and scopes the firewall to a +# configured interface). +# +# This preset and `public-frigate` share the bitcoind/fulcrum +# implementation via the private `_internal/bitcoin-stack.nix` +# helper. Differences: +# +# - `public-frigate` adds frigate + TLS + ACME on top of the stack +# (one box does everything). +# - `bitcoind-backend` is just the stack with exposure always on +# (one box hosts the backends for another box's frigate-edge). +# +# Bitcoin implementation: the underlying `services.bitcoind` is from +# nix-bitcoin. To swap to a Bitcoin Core fork (Knots, etc.), set +# `services.bitcoind.package` in the consumer's host config — the +# RPC/ZMQ contract is identical. For a non-Core implementation that +# speaks Bitcoin Core RPC + ZMQ sequence (e.g. btcd), this preset +# would need a sibling preset that provides the same exposed +# interface via different internals. + +let + cfg = config.services.bitcoind-backend; +in +{ + imports = [ + ../_internal/bitcoin-stack.nix + ]; + + options.services.bitcoind-backend = with lib; { + enable = mkEnableOption "Bitcoin Core RPC + Electrum backend exposed on a private interface"; + + network = mkOption { + type = types.enum [ + "mainnet" + "testnet" + "testnet4" + "signet" + "regtest" + ]; + default = "mainnet"; + description = '' + Chain this bitcoind serves. The exposed RPC port follows + nix-bitcoin's per-chain defaults (8332 mainnet, 18443 regtest, + 18332 testnet, 38332 signet). Consumers reaching this backend + need to use the matching port for the chain in their + `frigate-edge.backend.bitcoind.rpcUrl`. + ''; + }; + + dbCache = mkOption { + type = types.int; + default = 4096; + description = '' + bitcoind UTXO cache size in MB. Default 4 GB — fine for a + steady-state node. Raise this transiently during initial sync + if RAM is plentiful (the cost is initial bring-up time, not + steady-state memory). + ''; + }; + + bindAddress = mkOption { + type = types.str; + example = "10.42.0.3"; + description = '' + Private-network address bitcoind RPC, ZMQ sequence, and + fulcrum bind to (in addition to their loopback defaults). + Typically this host's mesh IP. The interface this address sits + on must match `interface` below — that's where firewall rules + are scoped. + ''; + }; + + interface = mkOption { + type = types.str; + example = "wg0"; + description = '' + Name of the interface used to scope firewall rules. Only + traffic arriving on this interface is allowed to reach the + backend ports; nothing on eth0 (the public interface) can + reach them. + ''; + }; + + allowedPeers = mkOption { + type = types.listOf types.str; + example = [ + "10.42.0.1/32" + "10.42.0.2/32" + ]; + description = '' + Source CIDRs added to bitcoind's `rpcallowip`. Must include + every edge consumer's mesh IP (/32) that needs to talk to the + backends. Loopback is always allowed. + ''; + }; + + rpcAuth = { + user = mkOption { + type = types.str; + example = "frigate-edge"; + description = "RPC user name added to bitcoind for edge consumers."; + }; + + passwordHMAC = mkOption { + type = types.str; + example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae"; + description = '' + Literal `salt$hash` portion of an rpcauth line, as produced + by bitcoind's `rpcauth.py`. Committed to nix config — the + HMAC is one-way derived from the password; only the + corresponding plaintext is a secret (lives on the edge + consumer). + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + services._roost.bitcoin-stack = { + enable = true; + dbCache = cfg.dbCache; + expose = { + enable = true; + bindAddress = cfg.bindAddress; + interface = cfg.interface; + allowedPeers = cfg.allowedPeers; + rpcAuth = { + inherit (cfg.rpcAuth) user passwordHMAC; + }; + }; + }; + }; +} diff --git a/test/regtest-backend.nix b/test/regtest-backend.nix new file mode 100644 index 0000000..1b36a7f --- /dev/null +++ b/test/regtest-backend.nix @@ -0,0 +1,142 @@ +{ + pkgs, + roost, + extraModules ? [ ], +}: + +# Single-VM test for the `bitcoind-backend` preset. +# +# Verifies the backend stack the preset spins up: +# - bitcoind RPC listens on both loopback AND the configured +# bindAddress (we use eth1 inside the VM as the "mesh" interface) +# - fulcrum listens on the same bindAddress + loopback +# - bitcoind's ZMQ sequence publisher binds 0.0.0.0 (exposed mode) +# - the configured rpcauth user can actually authenticate +# - the firewall scopes the new ports to the configured interface +# +# Frigate is intentionally not in this test — that's regtest-edge's +# job. This test is the unit-style check that the bitcoind+fulcrum +# stack the preset configures is consistent with the options the +# user set. +# +# Same rpcauth fixture as regtest-edge.nix so both tests cross-check +# the HMAC math. + +let + rpcUser = "frigate-edge"; + rpcPassword = "testpassword"; + rpcPasswordHMAC = "2316d0a5e8ee6339ffb4d86c983bb421$34cc4776187170b359d40928b25deb28ea2bfc436c96fdd0db7150ec5211de85"; + + # nixosTest assigns 192.168.1.1 to the first declared node. + meshIp = "192.168.1.1"; +in +pkgs.testers.runNixOSTest { + name = "regtest-backend"; + + nodes.machine = + { + config, + pkgs, + lib, + ... + }: + { + imports = [ + roost.nixosModules.bitcoind-backend-host + ] + ++ extraModules; + + services.bitcoind-backend = { + enable = true; + network = "regtest"; + bindAddress = meshIp; + interface = "eth1"; + allowedPeers = [ "192.168.1.0/24" ]; + rpcAuth = { + user = rpcUser; + passwordHMAC = rpcPasswordHMAC; + }; + }; + + # Regtest overrides on top of the stack the preset configured. + # See regtest-preset.nix for the per-knob rationale. + services.bitcoind = { + regtest = true; + dbCache = lib.mkForce 100; + disablewallet = lib.mkForce false; + extraConfig = '' + maxtipage=2147483647 + ''; + }; + + # netcat-openbsd for the auth probe; curl is in the base image + # but we also want `-q` semantics consistent with regtest-edge. + environment.systemPackages = [ + pkgs.netcat-openbsd + pkgs.curl + ]; + + virtualisation.cores = 4; + virtualisation.memorySize = 4096; + }; + + testScript = + { nodes, ... }: + let + cli = "bitcoin-cli -regtest -datadir=/var/lib/bitcoind"; + in + '' + machine.wait_for_unit("bitcoind.service") + machine.wait_until_succeeds("${cli} getblockchaininfo", timeout=30) + + # 101 blocks: first coinbase matures, fulcrum + ZMQ get real + # state to publish. + machine.succeed("${cli} createwallet test") + addr = machine.succeed("${cli} -rpcwallet=test getnewaddress").strip() + machine.succeed(f"${cli} generatetoaddress 101 {addr}") + machine.wait_until_succeeds( + "${cli} getblockchaininfo | grep -q '\"initialblockdownload\": false'", + timeout=30, + ) + + machine.wait_for_unit("fulcrum.service") + + # Bind verification: every backend service should accept + # connections on the configured mesh address, not just loopback. + machine.wait_for_open_port(18443, addr="${meshIp}") + machine.wait_for_open_port(28336, addr="${meshIp}") + machine.wait_for_open_port(60001, addr="${meshIp}") + + # Loopback continues to work — the preset adds the mesh bind on + # top, doesn't replace the typed loopback binding. + machine.wait_for_open_port(18443, addr="127.0.0.1") + machine.wait_for_open_port(60001, addr="127.0.0.1") + + # The point of bitcoind-backend: an edge consumer can hit the + # JSON-RPC server using the configured rpcauth user. Verify the + # HMAC line bitcoind writes really does match the password the + # client sends. + auth_check = machine.succeed( + f'curl -s --fail -u "${rpcUser}:${rpcPassword}" ' + f'-H "Content-Type: application/json" ' + f'-d \'{{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}}\' ' + f'http://${meshIp}:18443/' + ) + print(f"rpcauth probe: {auth_check}") + assert '"result":101' in auth_check, ( + f"rpcauth probe did not return block count 101 — auth or " + f"binding broken: {auth_check}" + ) + + # Wrong password should be rejected. (Catches HMAC-line-malformed + # bugs that would otherwise let any auth succeed.) + wrong = machine.execute( + f'curl -s -o /dev/null -w "%{{http_code}}" ' + f'-u "${rpcUser}:not-the-password" ' + f'-H "Content-Type: application/json" ' + f'-d \'{{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}}\' ' + f'http://${meshIp}:18443/' + ) + assert "401" in wrong[1], f"wrong password should yield 401, got: {wrong[1]!r}" + ''; +} From 057530a15cef23efa2e5d1e3b3390c964e5e3c7d Mon Sep 17 00:00:00 2001 From: josie Date: Tue, 19 May 2026 18:38:57 +0200 Subject: [PATCH 3/5] fix: drop f-prefix on test strings without Python placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruff F541 fired in CI because the curl invocation in regtest-backend was f-stringed but the only interpolations were Nix `${...}`, which are resolved at Nix eval time. By the time Python sees the lines, there are no `{}` placeholders left — drop the `f` prefix, and collapse `{{ }}` back to `{ }` in the JSON bodies (those were only escaped to survive f-string syntax). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/regtest-backend.nix | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/regtest-backend.nix b/test/regtest-backend.nix index 1b36a7f..30f50bb 100644 --- a/test/regtest-backend.nix +++ b/test/regtest-backend.nix @@ -116,11 +116,16 @@ pkgs.testers.runNixOSTest { # JSON-RPC server using the configured rpcauth user. Verify the # HMAC line bitcoind writes really does match the password the # client sends. + # + # The ''${...} are Nix interpolations resolved before this Python + # source ever exists — the resulting literals don't need an + # f-prefix (Ruff F541 otherwise) and the JSON body's `{`/`}` are + # plain characters in a non-f-string. auth_check = machine.succeed( - f'curl -s --fail -u "${rpcUser}:${rpcPassword}" ' - f'-H "Content-Type: application/json" ' - f'-d \'{{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}}\' ' - f'http://${meshIp}:18443/' + 'curl -s --fail -u "${rpcUser}:${rpcPassword}" ' + '-H "Content-Type: application/json" ' + '-d \'{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}\' ' + 'http://${meshIp}:18443/' ) print(f"rpcauth probe: {auth_check}") assert '"result":101' in auth_check, ( @@ -131,11 +136,11 @@ pkgs.testers.runNixOSTest { # Wrong password should be rejected. (Catches HMAC-line-malformed # bugs that would otherwise let any auth succeed.) wrong = machine.execute( - f'curl -s -o /dev/null -w "%{{http_code}}" ' - f'-u "${rpcUser}:not-the-password" ' - f'-H "Content-Type: application/json" ' - f'-d \'{{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}}\' ' - f'http://${meshIp}:18443/' + 'curl -s -o /dev/null -w "%{http_code}" ' + '-u "${rpcUser}:not-the-password" ' + '-H "Content-Type: application/json" ' + '-d \'{"jsonrpc":"1.0","id":"t","method":"getblockcount","params":[]}\' ' + 'http://${meshIp}:18443/' ) assert "401" in wrong[1], f"wrong password should yield 401, got: {wrong[1]!r}" ''; From 2606ac0f6beb5a2d857a23652567c191966f16a3 Mon Sep 17 00:00:00 2001 From: josie Date: Tue, 19 May 2026 21:15:18 +0200 Subject: [PATCH 4/5] fix: add nginx to acme group so HTTP-01 challenges don't 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order-renew script that NixOS generates for `webroot`-mode ACME creates the leaf directory `/var/lib/acme/acme-challenge/.well-known/ acme-challenge/` with mode 0750 owned by `acme:acme`. Without nginx in the acme group, lego writes the challenge token but nginx can't read it back, Let's Encrypt POSTs to the challenge URL, gets 403, and the order fails with `urn:ietf:params:acme:error:unauthorized`. NixOS's `enableACME = true;` nginx shorthand wires this automatically; the helper here uses webroot directly (the prior `enableACME` path asserted nginx had read access to the *issued cert*, which it shouldn't), so we have to add nginx to the acme group ourselves. Bit a live host today during the albatross.2140.dev → frigate.2140.dev move: the first issuance after the hostname change failed 403 until the leaf dir was manually chgrp'd to `nginx`. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/_internal/frigate-tls-acme.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modules/_internal/frigate-tls-acme.nix b/modules/_internal/frigate-tls-acme.nix index 7504fb7..bb60468 100644 --- a/modules/_internal/frigate-tls-acme.nix +++ b/modules/_internal/frigate-tls-acme.nix @@ -131,6 +131,16 @@ in }; }; + # nginx needs to read the HTTP-01 challenge files lego drops + # under `/var/lib/acme/acme-challenge/.well-known/acme-challenge/`. + # The NixOS ACME order-renew script creates that leaf directory + # with mode 0750 owned by `acme:acme`, so unless nginx is in the + # acme group it gets a 403 trying to serve the challenge token + # and validation fails with `urn:ietf:params:acme:error:unauthorized`. + # `enableACME` shorthand wires this automatically; we use webroot + # directly so we have to opt nginx into the group ourselves. + users.users.nginx.extraGroups = [ "acme" ]; + networking.firewall.allowedTCPPorts = [ 80 ]; # Block frigate startup until the cert exists, otherwise it From 47ac76229a9f83f583aea636ef9f4602b2513293 Mon Sep 17 00:00:00 2001 From: josie Date: Tue, 19 May 2026 21:18:26 +0200 Subject: [PATCH 5/5] fix: chmod 0640 on the PKCS#8-converted key so frigate can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openssl pkcs8 -out` hardcodes mode 0600 on the output regardless of umask (defensive default for private-key files), so the `umask 0027` preamble in postRun was a no-op for the actual file lego produced. The resulting key.pem is acme:acme 0600 — frigate joins the `acme` group via extraSupplementaryGroups but mode 0600 leaves the file unreadable, and frigate fails on startup with SSL: failed to read private key /var/lib/acme//key.pem Add an explicit `chmod 0640` after the conversion. Same bug shape as the nginx-acme-group fix one commit earlier — both came up live during the albatross.2140.dev -> frigate.2140.dev cutover. Also drop the `umask 0027` line: it didn't do anything useful and its presence suggests the perms are coming from the umask. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/_internal/frigate-tls-acme.nix | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/modules/_internal/frigate-tls-acme.nix b/modules/_internal/frigate-tls-acme.nix index bb60468..c959a2d 100644 --- a/modules/_internal/frigate-tls-acme.nix +++ b/modules/_internal/frigate-tls-acme.nix @@ -106,19 +106,25 @@ in # (`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. + # in the cert directory. Idempotent — running `openssl pkcs8 + # -topk8` on an already-PKCS#8 key is a no-op. + # + # `openssl pkcs8 -out` hardcodes mode 0600 on the output file + # regardless of umask (defensive for private keys), so we + # `chmod 0640` explicitly afterward — frigate joins the `acme` + # group via `extraSupplementaryGroups` above and needs group + # read to load the key. `chown acme:acme` keeps the file owned + # the way NixOS would have set it. 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 + chmod 0640 key.pem.pkcs8 mv key.pem.pkcs8 key.pem ''; };