feat: harden btcpay and nbxplorer config handling

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-11 02:40:16 +00:00
committed by GitHub
co-authored by naturallaw777
parent 9befcd06e6
commit 05a42bcc4b
4 changed files with 229 additions and 24 deletions
+3
View File
@@ -65,6 +65,9 @@
mempoolPkgs = mempoolPkgs =
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; }; pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
in { in {
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
inherit nixpkgs overlay-stable;
};
mempool-backend = mempoolPkgs.mempool-backend; mempool-backend = mempoolPkgs.mempool-backend;
mempool-frontend = mempoolPkgs.mempool-frontend; mempool-frontend = mempoolPkgs.mempool-frontend;
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; }; rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
+28 -1
View File
@@ -406,7 +406,8 @@ in {
extraRpcauth = concatMapStrings (name: let extraRpcauth = concatMapStrings (name: let
user = cfg.rpc.users.${name}; user = cfg.rpc.users.${name};
in optionalString user.passwordHMACFromFile '' in optionalString user.passwordHMACFromFile ''
echo "rpcauth=${user.name}:$(cat ${secretsDir}/bitcoin-HMAC-${name})" hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
'' ''
) (builtins.attrNames cfg.rpc.users); ) (builtins.attrNames cfg.rpc.users);
in '' in ''
@@ -416,6 +417,32 @@ in {
fi fi
''} ''}
readValidatedRpcHmac() {
local hmacFile="$1"
local hmacPayload
if [[ ! -e "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
exit 1
fi
if [[ ! -r "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
exit 1
fi
hmacPayload="$(<"$hmacFile")"
if [[ -z "$hmacPayload" ]]; then
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
exit 1
fi
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
exit 1
fi
printf '%s\n' "$hmacPayload"
}
cfg=$( cfg=$(
cat ${configFile} cat ${configFile}
${extraRpcauth} ${extraRpcauth}
+66 -23
View File
@@ -139,29 +139,43 @@ let
}; };
nbLib = config.nix-bitcoin.lib; nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
in { in {
inherit options; inherit options;
config = mkMerge [ config = mkMerge [
(mkIf cfg.nbxplorer.enable { (mkIf cfg.nbxplorer.enable {
# For backwards compatibility only systemd.tmpfiles.rules = [
systemd.tmpfiles.rules = mkIf cfg.nbxplorer.addNetworkSymlink [ "d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'" ] ++ optional cfg.nbxplorer.addNetworkSymlink
]; "L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
systemd.services.nbxplorer = rec { systemd.services.nbxplorer = let
configFile = builtins.toFile "nbxplorer-config" ''
network=${cfg.bitcoind.network}
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
bind=${cfg.nbxplorer.address}
port=${toString cfg.nbxplorer.port}
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
'';
in rec {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
requires = [ "bitcoind.service" ]; requires = [ "postgresql.target" ];
after = requires; wants = [ "bitcoind.service" ];
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
preStart = '' preStart = ''
{ install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
echo "btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}" printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
echo "btcrpcpassword=$(cat ${config.nix-bitcoin.secretsDir}/bitcoin-rpcpassword-btcpayserver)" >> '${cfg.nbxplorer.dataDir}/settings.config'
} >> '${cfg.nbxplorer.dataDir}/settings.config'
''; '';
serviceConfig = nbLib.defaultHardening // { serviceConfig = nbLib.defaultHardening // {
ExecStart = "${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config"; ExecStart = ''
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
--datadir='${cfg.nbxplorer.dataDir}'
'';
RuntimeDirectory = "nbxplorer"; RuntimeDirectory = "nbxplorer";
StateDirectory = "nbxplorer"; StateDirectory = "nbxplorer";
User = cfg.nbxplorer.user; User = cfg.nbxplorer.user;
@@ -169,11 +183,13 @@ in {
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "10s"; RestartSec = "10s";
ReadWritePaths = [ cfg.nbxplorer.dataDir ]; ReadWritePaths = [ cfg.nbxplorer.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce; } // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
}; };
services.bitcoind = { services.bitcoind = {
enable = true; enable = true;
listenWhitelisted = true;
txindex = true; txindex = true;
}; };
@@ -188,6 +204,7 @@ in {
services.nbxplorer.enable = true; services.nbxplorer.enable = true;
services.bitcoind = { services.bitcoind = {
listenWhitelisted = true;
rpc.users.btcpayserver = { rpc.users.btcpayserver = {
name = "btcpayserver"; name = "btcpayserver";
passwordHMACFromFile = true; passwordHMACFromFile = true;
@@ -222,18 +239,40 @@ in {
}; };
}; };
systemd.services.btcpayserver = rec { systemd.tmpfiles.rules = [
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
];
systemd.services.btcpayserver = let
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
configFile = builtins.toFile "btcpayserver-config" (
''
network=${cfg.bitcoind.network}
bind=${cfg.btcpayserver.address}
port=${toString cfg.btcpayserver.port}
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
btcexplorerurl=${nbExplorerUrl}
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
(
"btclightning=type=lnd-rest;"
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
+ "certfilepath=${config.services.lnd.certPath}\n"
)
);
in rec {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
requires = [ "nbxplorer.service" ]; requires = [ "postgresql.target" ];
after = requires; wants = [ "nbxplorer.service" ]
preStart = '' ++ optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
{ after = requires ++ wants;
echo "postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver"
echo "explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer"
} >> '${cfg.btcpayserver.dataDir}/settings.config'
'';
serviceConfig = nbLib.defaultHardening // { serviceConfig = nbLib.defaultHardening // {
ExecStart = "${cfg.btcpayserver.package}/bin/btcpayserver --conf=${cfg.btcpayserver.dataDir}/settings.config"; ExecStart = ''
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
--datadir='${cfg.btcpayserver.dataDir}'
'';
RuntimeDirectory = "btcpayserver"; RuntimeDirectory = "btcpayserver";
StateDirectory = "btcpayserver"; StateDirectory = "btcpayserver";
User = cfg.btcpayserver.user; User = cfg.btcpayserver.user;
@@ -241,6 +280,7 @@ in {
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "10s"; RestartSec = "10s";
ReadWritePaths = [ cfg.btcpayserver.dataDir ]; ReadWritePaths = [ cfg.btcpayserver.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce; } // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
}; };
@@ -261,7 +301,10 @@ in {
users.groups.${cfg.btcpayserver.group} = {}; users.groups.${cfg.btcpayserver.group} = {};
nix-bitcoin.secrets = { nix-bitcoin.secrets = {
bitcoin-rpcpassword-btcpayserver.user = cfg.btcpayserver.user; bitcoin-rpcpassword-btcpayserver = {
user = cfg.bitcoind.user;
group = cfg.nbxplorer.group;
};
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user; bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
}; };
nix-bitcoin.generateSecretsCmds.btcpayserver = '' nix-bitcoin.generateSecretsCmds.btcpayserver = ''
+132
View File
@@ -0,0 +1,132 @@
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
let
lib = nixpkgs.lib;
pkgs = import nixpkgs {
inherit system;
overlays = [ overlay-stable ];
};
normalize = s:
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
extractAfter = prefix: str:
let
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${prefix} from: ${normalize str}"
else
builtins.head match;
extractFlagValue = flag: str:
let
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${flag} from: ${normalize str}"
else
builtins.head match;
config = (lib.nixosSystem {
inherit system;
modules = [
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
../modules/bitcoin
{
nix-bitcoin.generateSecrets = true;
nix-bitcoin.secretsDir = "/build/secrets";
services.btcpayserver.enable = true;
services.btcpayserver.lightningBackend = "lnd";
services.nbxplorer.dataDir = "/build/nbxplorer";
services.btcpayserver.dataDir = "/build/btcpayserver";
services.lnd.dataDir = "/build/lnd";
services.bitcoind.dataDir = "/build/bitcoind";
}
];
}).config;
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
btcpayConfig = builtins.readFile btcpayConfigPath;
in
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
assert lib.assertMsg
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
"btcpayserver must not receive the nbxplorer group";
assert lib.assertMsg
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
"nbxplorer must wait for nix-bitcoin-secrets.target";
assert lib.assertMsg
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
"nbxplorer needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
"btcpayserver needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" nbxplorerConfig
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
&& lib.hasInfix "port=24444" nbxplorerConfig
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
"nbxplorer base config must contain the expected non-secret settings";
assert lib.assertMsg
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
"btcpayserver must use a deterministic config file plus --datadir";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" btcpayConfig
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
&& lib.hasInfix "port=23000" btcpayConfig
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
assert lib.assertMsg
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
mkdir -p /build/secrets /build/nbxplorer
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
! grep -q 'first-password' /build/nbxplorer/settings.config
grep -q 'rotated-password' /build/nbxplorer/settings.config
touch "$out"
''