feat: Phase 1 — per-installation credential generation, eliminate hardcoded passwords

Generate unique random passwords at first boot for Bitcoin RPC, all database
services (mempool, btcpay, immich, penpot, mysql-root), and Fedimint gateway.
Credentials stored in /var/lib/archipelago/secrets/ with 600 permissions.

Scripts: first-boot-containers.sh, deploy-to-target.sh, deploy-bitcoin-knots.sh,
container-doctor.sh all read from secrets files instead of hardcoded values.

Rust backend: new bitcoin_rpc module reads password from secrets file, env var,
or dev fallback. All .basic_auth() calls and container config strings now use
the shared credential reader instead of hardcoded "archipelago123".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 00:39:52 +00:00
co-authored by Claude Opus 4.6
parent f273816405
commit 809a976960
12 changed files with 1804 additions and 251 deletions
+2 -1
View File
@@ -77,6 +77,7 @@ impl RpcHandler {
method: &str,
params: &[serde_json::Value],
) -> Result<T> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "archy",
@@ -86,7 +87,7 @@ impl RpcHandler {
let resp = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await
+18 -13
View File
@@ -193,12 +193,15 @@ impl RpcHandler {
"--restart=unless-stopped", // Auto-restart policy
];
// Read Bitcoin RPC password from secrets for container configs
let rpc_pass = crate::bitcoin_rpc::bitcoin_rpc_password().await;
// App-specific configuration (should come from manifest)
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
let mut allocator = self.port_allocator.lock().map_err(|e| {
anyhow::anyhow!("Port allocator lock poisoned: {}", e)
})?;
get_app_config(package_id, &self.config.host_ip, &mut allocator)
get_app_config(package_id, &self.config.host_ip, &mut allocator, &rpc_pass)
};
// Fedimint Gateway: auto-detect LND and switch to lnd mode
@@ -222,7 +225,7 @@ impl RpcHandler {
"--network".to_string(), "bitcoin".to_string(),
"--bitcoind-url".to_string(), format!("http://{}:8332", self.config.host_ip),
"--bitcoind-username".to_string(), "archipelago".to_string(),
"--bitcoind-password".to_string(), "archipelago123".to_string(),
"--bitcoind-password".to_string(), rpc_pass.clone(),
"lnd".to_string(),
"--lnd-rpc-host".to_string(), format!("{}:10009", self.config.host_ip),
"--lnd-tls-cert".to_string(), "/lnd/tls.cert".to_string(),
@@ -305,16 +308,16 @@ impl RpcHandler {
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
let bitcoin_conf = "\
let bitcoin_conf = format!("\
server=1\n\
prune=550\n\
rpcuser=archipelago\n\
rpcpassword=archipelago123\n\
rpcpassword={}\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
rpcport=8332\n\
listen=1\n\
printtoconsole=1\n";
printtoconsole=1\n", rpc_pass);
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
info!("Created bitcoin.conf at {} with RPC + txindex enabled", conf_path);
@@ -347,7 +350,7 @@ printtoconsole=1\n";
run_args.push("--cpus=2");
// Health check definitions
let health_args = get_health_check_args(package_id);
let health_args = get_health_check_args(package_id, &rpc_pass);
for arg in &health_args {
run_args.push(arg);
}
@@ -1316,10 +1319,11 @@ fn is_readonly_compatible(app_id: &str) -> bool {
/// Get container health check arguments for podman run.
/// Returns (health-cmd, interval, retries) args to append to run_args.
fn get_health_check_args(app_id: &str) -> Vec<String> {
fn get_health_check_args(app_id: &str, rpc_pass: &str) -> Vec<String> {
let btc_health = format!("bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1", rpc_pass);
let (cmd, interval, retries) = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
"bitcoin-cli -rpcuser=archipelago -rpcpassword=archipelago123 getblockchaininfo || exit 1",
btc_health.as_str(),
"30s", "3",
),
"lnd" => (
@@ -1462,6 +1466,7 @@ fn get_app_config(
app_id: &str,
host_ip: &str,
allocator: &mut PortAllocator,
rpc_pass: &str,
) -> (Vec<String>, Vec<String>, Vec<String>, Option<String>, Option<Vec<String>>) {
match app_id {
"homeassistant" | "home-assistant" => (
@@ -1495,7 +1500,7 @@ fn get_app_config(
"BTCPAY_CHAINS=btc".to_string(),
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
"BTCPAY_BTCRPCUSER=archipelago".to_string(),
"BTCPAY_BTCRPCPASSWORD=archipelago123".to_string(),
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
"BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true".to_string(),
],
None,
@@ -1519,7 +1524,7 @@ fn get_app_config(
format!("CORE_RPC_HOST={}", host_ip),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=archipelago".to_string(),
"CORE_RPC_PASSWORD=archipelago123".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
@@ -1536,7 +1541,7 @@ fn get_app_config(
vec!["50001:50001".to_string()],
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!("DAEMON_URL=http://archipelago:archipelago123@{}:8332/", bitcoin_host),
format!("DAEMON_URL=http://archipelago:{}@{}:8332/", rpc_pass, bitcoin_host),
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
@@ -1701,7 +1706,7 @@ fn get_app_config(
vec![
"FM_DATA_DIR=/data".to_string(),
"FM_BITCOIND_USERNAME=archipelago".to_string(),
"FM_BITCOIND_PASSWORD=archipelago123".to_string(),
format!("FM_BITCOIND_PASSWORD={}", rpc_pass),
"FM_BITCOIN_NETWORK=bitcoin".to_string(),
"FM_BIND_P2P=0.0.0.0:8173".to_string(),
"FM_BIND_API=0.0.0.0:8174".to_string(),
@@ -1727,7 +1732,7 @@ fn get_app_config(
"--network".to_string(), "bitcoin".to_string(),
"--bitcoind-url".to_string(), format!("http://{}:8332", host_ip),
"--bitcoind-username".to_string(), "archipelago".to_string(),
"--bitcoind-password".to_string(), "archipelago123".to_string(),
"--bitcoind-password".to_string(), rpc_pass.to_string(),
"ldk".to_string(),
"--ldk-lightning-port".to_string(), "9737".to_string(),
"--ldk-alias".to_string(), "archipelago-gateway".to_string(),
+49
View File
@@ -0,0 +1,49 @@
//! Shared Bitcoin RPC credential management.
//! Reads credentials from the per-installation secrets file, falling back to
//! environment variables, then a dev-only default.
use tokio::sync::OnceCell;
use tracing::debug;
const SECRETS_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
const DEFAULT_USER: &str = "archipelago";
static CACHED_PASSWORD: OnceCell<String> = OnceCell::const_new();
/// Read the Bitcoin RPC password from the secrets file, env var, or dev fallback.
async fn read_password() -> String {
// 1. Try secrets file (production path)
if let Ok(pass) = tokio::fs::read_to_string(SECRETS_PATH).await {
let pass = pass.trim().to_string();
if !pass.is_empty() {
debug!("Bitcoin RPC password loaded from secrets file");
return pass;
}
}
// 2. Try environment variable
if let Ok(pass) = std::env::var("BITCOIN_RPC_PASSWORD") {
if !pass.is_empty() {
debug!("Bitcoin RPC password loaded from env var");
return pass;
}
}
// 3. Dev fallback (will only work on dev machines with default config)
debug!("Bitcoin RPC password: using dev fallback");
"archipelago123".to_string()
}
/// Get Bitcoin RPC credentials (user, password). Cached after first call.
pub async fn bitcoin_rpc_credentials() -> (String, String) {
let pass = CACHED_PASSWORD
.get_or_init(|| async { read_password().await })
.await;
(DEFAULT_USER.to_string(), pass.clone())
}
/// Get the Bitcoin RPC password as a plain string (for config generation).
pub async fn bitcoin_rpc_password() -> String {
let (_, pass) = bitcoin_rpc_credentials().await;
pass
}
+4 -6
View File
@@ -13,11 +13,9 @@ const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
// Approximate final index size in bytes for mainnet (~55GB for ElectrumX full index)
const ESTIMATED_FULL_INDEX_BYTES: f64 = 55_000_000_000.0;
/// Build Bitcoin RPC Basic auth header from env vars.
/// Falls back to cookie auth file if env vars are not set.
fn bitcoin_rpc_auth() -> String {
let user = std::env::var("BITCOIN_RPC_USER").unwrap_or_else(|_| "archipelago".to_string());
let pass = std::env::var("BITCOIN_RPC_PASSWORD").unwrap_or_else(|_| "archipelago123".to_string());
/// Build Bitcoin RPC Basic auth header using shared credentials.
async fn bitcoin_rpc_auth() -> String {
let (user, pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
format!("Basic {}", encoded)
@@ -120,7 +118,7 @@ async fn bitcoin_network_height() -> Result<u64> {
let resp = client
.post(BITCOIN_RPC_URL)
.header("Content-Type", "application/json")
.header("Authorization", bitcoin_rpc_auth())
.header("Authorization", bitcoin_rpc_auth().await)
.body(body.to_string())
.send()
.await
+1
View File
@@ -9,6 +9,7 @@ use tokio::signal;
mod api;
mod auth;
mod backup;
mod bitcoin_rpc;
mod config;
mod content_server;
mod crash_recovery;
+6 -3
View File
@@ -1301,6 +1301,8 @@ async fn handle_tx_relay_broadcast(
}
};
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// Pre-flight: check if Bitcoin Core is reachable and synced
let preflight_body = serde_json::json!({
"jsonrpc": "1.0",
@@ -1311,7 +1313,7 @@ async fn handle_tx_relay_broadcast(
match client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&preflight_body)
.send()
.await
@@ -1364,7 +1366,7 @@ async fn handle_tx_relay_broadcast(
let txid = match client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await
@@ -1522,9 +1524,10 @@ async fn check_tx_confirmations(client: &reqwest::Client, txid: &str) -> anyhow:
"method": "gettransaction",
"params": [txid]
});
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let resp = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await?;
+5 -3
View File
@@ -602,12 +602,13 @@ struct BlockHeaderInfo {
}
async fn bitcoin_rpc_getblockcount(client: &reqwest::Client) -> Result<u64> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0", "id": "mesh", "method": "getblockcount", "params": []
});
let resp: BitcoinRpcResponse<u64> = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await
@@ -625,13 +626,14 @@ async fn bitcoin_rpc_getblockheader_by_height(
client: &reqwest::Client,
height: u64,
) -> Result<BlockHeaderInfo> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// First get block hash for this height
let body = serde_json::json!({
"jsonrpc": "1.0", "id": "mesh", "method": "getblockhash", "params": [height]
});
let resp: BitcoinRpcResponse<String> = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await?
@@ -645,7 +647,7 @@ async fn bitcoin_rpc_getblockheader_by_height(
});
let resp: BitcoinRpcResponse<serde_json::Value> = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await?