use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::context::WalletContext;
use crate::server::{self, ServerConfig, TlsConfig};
#[derive(Debug, PartialEq, Eq)]
pub struct FleetEntry {
pub dir: PathBuf,
pub port: u16,
}
pub fn parse_entry(spec: &str) -> Result<FleetEntry> {
let (dir, port) = spec
.rsplit_once(':')
.with_context(|| format!("--wallet '{spec}': expected <seat-dir>:<port>"))?;
if dir.is_empty() {
bail!("--wallet '{spec}': empty seat dir");
}
let port: u16 = port
.parse()
.with_context(|| format!("--wallet '{spec}': '{port}' is not a port"))?;
if port == 0 {
bail!("--wallet '{spec}': port 0 is not servable");
}
Ok(FleetEntry {
dir: PathBuf::from(dir),
port,
})
}
pub fn parse_fleet(specs: &[String]) -> Result<Vec<FleetEntry>> {
if specs.is_empty() {
bail!("serve-fleet needs at least one --wallet <seat-dir>:<port>");
}
let entries: Vec<FleetEntry> = specs
.iter()
.map(|s| parse_entry(s))
.collect::<Result<_>>()?;
for (i, a) in entries.iter().enumerate() {
for b in &entries[i + 1..] {
if a.port == b.port {
bail!(
"duplicate port {} ({} and {})",
a.port,
a.dir.display(),
b.dir.display()
);
}
if a.dir == b.dir {
bail!(
"duplicate seat dir {} (ports {} and {})",
a.dir.display(),
a.port,
b.port
);
}
}
}
Ok(entries)
}
pub fn root_key_from_env_file(env_path: &Path) -> Result<String> {
let iter = dotenvy::from_path_iter(env_path)
.with_context(|| format!("reading {}", env_path.display()))?;
for item in iter {
let (k, v) = item.with_context(|| format!("parsing {}", env_path.display()))?;
if k == "ROOT_KEY" && !v.is_empty() {
return Ok(v);
}
}
bail!("no ROOT_KEY in {}", env_path.display())
}
pub const SHARED_ENV_KEYS: &[&str] = &[
"ARC_MODE",
"ARCADE",
"ARC_URL",
"PUBLIC_CALLBACK_URL",
"CALLBACK_TOKEN",
"CHAINTRACKS_URL",
"TAAL_API_KEY",
"MAIN_TAAL_API_KEY",
"AUTH_TOKEN",
"BIND_ADDR",
"RELAY_URL",
];
pub fn assert_env_uniform(env_path: &Path) -> Result<()> {
let iter = dotenvy::from_path_iter(env_path)
.with_context(|| format!("reading {}", env_path.display()))?;
for item in iter {
let (k, v) = item.with_context(|| format!("parsing {}", env_path.display()))?;
if SHARED_ENV_KEYS.contains(&k.as_str()) {
match std::env::var(&k) {
Ok(procv) if procv == v => {}
_ => bail!(
"fleet env divergence: {} sets {} to a value the shared process env does not carry — \
under per-process daemons that line applied; in one process it would be silently ignored. \
Export it globally (identical fleet-wide) or drop the per-seat line.",
env_path.display(),
k
),
}
}
}
Ok(())
}
fn tenant_config(ctx: &WalletContext) -> Result<ServerConfig> {
let tls = match (
std::env::var("TLS_CERT_PATH").ok(),
std::env::var("TLS_KEY_PATH").ok(),
) {
(Some(cert_path), Some(key_path)) => Some(TlsConfig {
cert_path,
key_path,
}),
_ => None,
};
let callback_token = crate::services_env::arcade_runtime(&ctx.db_path)?
.map(|rt| rt.callback_token)
.or_else(|| {
std::env::var("CALLBACK_TOKEN")
.ok()
.filter(|s| !s.is_empty())
});
let bind_addr: std::net::IpAddr = std::env::var("BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]));
Ok(ServerConfig {
auth_token: std::env::var("AUTH_TOKEN").ok(),
tls,
chain: ctx.chain,
bind_addr,
callback_token,
})
}
pub async fn run(specs: &[String], testnet: bool, daemon: bool) -> Result<()> {
let entries = parse_fleet(specs)?;
let n = entries.len();
let chain = if testnet {
bsv_wallet_toolbox::Chain::Test
} else {
bsv_wallet_toolbox::Chain::Main
};
let mut keys = Vec::with_capacity(n);
for e in &entries {
let env_path = e.dir.join(".env");
assert_env_uniform(&env_path)?;
keys.push(root_key_from_env_file(&env_path)?);
}
if daemon {
let mut monitors = Vec::with_capacity(n);
let (tx, mut rx) = tokio::sync::mpsc::channel::<(u16, Result<()>)>(n.max(1));
for (e, key) in entries.iter().zip(&keys) {
let db = e.dir.join("wallet.db");
let db_str = db
.to_str()
.with_context(|| format!("non-UTF8 path {}", db.display()))?;
let ident = super::daemon_tenant::identity_of(key)?;
eprintln!(
"[serve-fleet] {} → port {} (identity {}…, daemon)",
e.dir.display(),
e.port,
&ident[..12.min(ident.len())]
);
let tenant = super::daemon_tenant::start(db_str, key, chain, e.port)
.await
.with_context(|| format!("starting tenant {}", e.dir.display()))?;
let port = tenant.port;
let server = tenant.server;
monitors.push(tenant.monitor);
let tx = tx.clone();
tokio::spawn(async move {
let res = match server.await {
Ok(r) => r,
Err(join) => Err(anyhow::anyhow!("server task panicked: {join}")),
};
let _ = tx.send((port, res)).await;
});
}
drop(tx);
eprintln!("[serve-fleet] {n} wallet(s) serving (daemon mode)");
let outcome = rx.recv().await;
for m in monitors {
let _ = m.stop().await;
}
return match outcome {
Some((port, Ok(()))) => {
eprintln!("[serve-fleet] port {port} exited cleanly — shutting the fleet down");
Ok(())
}
Some((port, Err(e))) => bail!("tenant on port {port} failed: {e:#}"),
None => bail!("serve-fleet: no tenants ran"),
};
}
let mut tenants = Vec::with_capacity(n);
for (e, key) in entries.iter().zip(&keys) {
let db = e.dir.join("wallet.db");
let db_str = db
.to_str()
.with_context(|| format!("non-UTF8 path {}", db.display()))?;
let ctx = WalletContext::load_with(db_str, key, testnet, false)
.await
.with_context(|| format!("loading wallet {}", e.dir.display()))?;
eprintln!(
"[serve-fleet] {} → port {} (identity {}…)",
e.dir.display(),
e.port,
&ctx.identity_key[..12.min(ctx.identity_key.len())]
);
tenants.push((ctx, e.port));
}
let (tx, mut rx) = tokio::sync::mpsc::channel::<(u16, Result<()>)>(n.max(1));
for (ctx, port) in tenants {
let config = tenant_config(&ctx)?;
let chain = ctx.chain;
let db_path = ctx.db_path.clone();
let state = server::make_wallet_state(ctx.wallet);
let reconcile =
crate::broadcast_reconcile::spawn_serve_loop(state.clone(), chain, &db_path);
let tx = tx.clone();
tokio::spawn(async move {
let res = server::run(state, port, config).await;
if let Some(handle) = reconcile {
handle.abort();
}
let _ = tx.send((port, res)).await;
});
}
drop(tx);
eprintln!("[serve-fleet] {n} wallet(s) serving");
match rx.recv().await {
Some((port, Ok(()))) => {
eprintln!("[serve-fleet] port {port} exited cleanly — shutting the fleet down");
Ok(())
}
Some((port, Err(e))) => bail!("tenant on port {port} failed: {e:#}"),
None => bail!("serve-fleet: no tenants ran"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parses_dir_colon_port() {
let e = parse_entry("e2e/wallets/.fleet/p7:3407").unwrap();
assert_eq!(e.dir, PathBuf::from("e2e/wallets/.fleet/p7"));
assert_eq!(e.port, 3407);
}
#[test]
fn refuses_bad_specs() {
assert!(parse_entry("no-port").is_err());
assert!(parse_entry(":3407").is_err());
assert!(parse_entry("dir:notaport").is_err());
assert!(parse_entry("dir:0").is_err());
}
#[test]
fn refuses_duplicate_port_and_dir() {
let dup_port = ["a:3401".to_string(), "b:3401".to_string()];
assert!(parse_fleet(&dup_port)
.unwrap_err()
.to_string()
.contains("duplicate port"));
let dup_dir = ["a:3401".to_string(), "a:3402".to_string()];
assert!(parse_fleet(&dup_dir)
.unwrap_err()
.to_string()
.contains("duplicate seat dir"));
assert!(parse_fleet(&[]).is_err());
}
#[test]
fn env_divergence_refused_by_name_secrets_never_printed() {
let dir = std::env::temp_dir().join(format!("serve-fleet-env-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let env_path = dir.join(".env");
std::fs::write(
&env_path,
"ROOT_KEY=aa\nTAAL_API_KEY=seat-specific-secret\n",
)
.unwrap();
std::env::remove_var("TAAL_API_KEY");
let err = assert_env_uniform(&env_path).unwrap_err().to_string();
assert!(err.contains("fleet env divergence"), "{err}");
assert!(err.contains("TAAL_API_KEY"), "{err}");
assert!(
!err.contains("seat-specific-secret"),
"secret leaked: {err}"
);
std::env::set_var("TAAL_API_KEY", "seat-specific-secret");
assert!(assert_env_uniform(&env_path).is_ok());
std::env::remove_var("TAAL_API_KEY");
std::fs::write(&env_path, "ROOT_KEY=aa\n").unwrap();
assert!(assert_env_uniform(&env_path).is_ok());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn reads_root_key_without_touching_process_env() {
let dir = std::env::temp_dir().join(format!("serve-fleet-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let env_path = dir.join(".env");
let mut f = std::fs::File::create(&env_path).unwrap();
writeln!(f, "# seat p9").unwrap();
writeln!(f, "SOMETHING_ELSE=x").unwrap();
writeln!(f, "ROOT_KEY=deadbeef01").unwrap();
drop(f);
assert_eq!(root_key_from_env_file(&env_path).unwrap(), "deadbeef01");
assert!(std::env::var("SOMETHING_ELSE").is_err());
let missing = dir.join("missing.env");
assert!(root_key_from_env_file(&missing).is_err());
std::fs::remove_dir_all(&dir).ok();
}
}