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/_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/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/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 - ]; - }) ] ); } diff --git a/test/regtest-backend.nix b/test/regtest-backend.nix new file mode 100644 index 0000000..30f50bb --- /dev/null +++ b/test/regtest-backend.nix @@ -0,0 +1,147 @@ +{ + 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. + # + # 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( + '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, ( + 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( + '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}" + ''; +}