From 745e1801027a6c31055d9113c4a5bfd1c3e0d487 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 03:47:06 -0400 Subject: [PATCH] =?UTF-8?q?fix(quadlet):=20escape=20%=20for=20systemd=20sp?= =?UTF-8?q?ecifiers=20=E2=80=94=20bitcoind=20RPC=20creds=20were=20/bin/bas?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creds-off-argv script used printf "rpcuser=%s" in the manifest's custom_args; quadlet copies Exec= into the generated service's ExecStart, where systemd expands %s (user's shell) at load time — bitcoind's rpc.conf came out as rpcuser=/bin/bash and every RPC consumer got 401s on quadlet nodes (framework-pt: bitcoin-status, HA block-height sensor, LND chain RPC). Two-layer fix: quadlet.rs now escapes % -> %% in Exec/Entrypoint/HealthCmd/ Environment emission (with regression test), and the bitcoin manifests write rpc.conf with plain echo so the catalog also heals nodes still running older binaries. Release catalog regenerated with the fixed scripts. Co-Authored-By: Claude Fable 5 --- apps/bitcoin-core/manifest.yml | 2 +- apps/bitcoin-knots/manifest.yml | 2 +- core/archipelago/src/container/quadlet.rs | 28 +++++++++++++++++++---- releases/app-catalog.json | 4 ++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 326a7315..b4cca115 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -37,7 +37,7 @@ app: RPC_PASS="$(printenv BITCOIN_RPC_PASS)"; RPC_CONF="/tmp/rpc.conf"; umask 077; - printf "rpcuser=%s\nrpcpassword=%s\n" "$RPC_USER" "$RPC_PASS" > "$RPC_CONF"; + { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 21372b74..2ed0da00 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -37,7 +37,7 @@ app: RPC_PASS="$(printenv BITCOIN_RPC_PASS)"; RPC_CONF="/tmp/rpc.conf"; umask 077; - printf "rpcuser=%s\nrpcpassword=%s\n" "$RPC_USER" "$RPC_PASS" > "$RPC_CONF"; + { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 3c091102..d3ce9bfe 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -284,7 +284,7 @@ impl QuadletUnit { let _ = writeln!(s, "PodmanArgs=--cpus={cpus}"); } if let Some(h) = &self.health { - let _ = writeln!(s, "HealthCmd={}", h.cmd); + let _ = writeln!(s, "HealthCmd={}", h.cmd.replace('%', "%%")); let _ = writeln!(s, "HealthInterval={}", h.interval); let _ = writeln!(s, "HealthTimeout={}", h.timeout); let _ = writeln!(s, "HealthRetries={}", h.retries); @@ -298,7 +298,7 @@ impl QuadletUnit { // images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper // into `bitcoind sh -lc ...` and crash-looped). Emitting // Entrypoint= makes the unit independent of the image's entrypoint. - let _ = writeln!(s, "Entrypoint={first}"); + let _ = writeln!(s, "Entrypoint={}", first.replace('%', "%%")); let mut parts: Vec = rest.to_vec(); parts.extend(self.command.iter().cloned()); if !parts.is_empty() { @@ -335,11 +335,16 @@ impl QuadletUnit { /// the minimum quoting needed so quadlet's parser sees one element per /// item: anything containing whitespace, quotes, or shell metacharacters /// gets wrapped in double quotes with embedded `"` and `\` escaped. +/// +/// `%` is escaped to `%%`: quadlet copies Exec= content into the generated +/// service's ExecStart, where systemd expands `%` specifiers at load time — +/// a bare `%s` in a manifest script silently became `/bin/bash` (the user's +/// shell) and corrupted bitcoind's generated rpc.conf. fn shell_join(parts: &[String]) -> String { parts .iter() .map(|p| { - let p = p.replace(['\r', '\n'], " "); + let p = p.replace(['\r', '\n'], " ").replace('%', "%%"); if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) { let escaped = p .replace('\\', "\\\\") @@ -355,7 +360,8 @@ fn shell_join(parts: &[String]) -> String { } fn quote_environment(env: &str) -> String { - let env = env.replace(['\r', '\n'], " "); + // `%` → `%%` for the same systemd-specifier reason as shell_join. + let env = env.replace(['\r', '\n'], " ").replace('%', "%%"); if env.is_empty() || env .chars() @@ -1122,6 +1128,20 @@ mod tests { ); } + #[test] + fn percent_is_escaped_for_systemd_specifiers() { + // Regression: a bare `%s` in an Exec= line is a systemd specifier + // (user's shell = /bin/bash) once quadlet copies it into the + // generated ExecStart — bitcoind's rpc.conf came out as + // `rpcuser=/bin/bash` and every RPC consumer got 401s. + assert_eq!( + shell_join(&["printf 'rpcuser=%s' \"$U\"".to_string()]), + "\"printf 'rpcuser=%%s' \\\"$$U\\\"\"" + ); + assert_eq!(shell_join(&["--fmt=%h:%p".to_string()]), "--fmt=%%h:%%p"); + assert_eq!(quote_environment("FMT=%m"), "FMT=%%m"); + } + #[test] fn restart_policy_emits_correct_systemd_string() { assert_eq!(RestartPolicy::Always.as_systemd(), "always"); diff --git a/releases/app-catalog.json b/releases/app-catalog.json index a12513fc..d5a475c4 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -423,7 +423,7 @@ "-lc" ], "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; printf \"rpcuser=%s\\nrpcpassword=%s\\n\" \"$RPC_USER\" \"$RPC_PASS\" > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "derived_env": [ { @@ -566,7 +566,7 @@ "-lc" ], "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; printf \"rpcuser=%s\\nrpcpassword=%s\\n\" \"$RPC_USER\" \"$RPC_PASS\" > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "derived_env": [ {