use std::net::IpAddr;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{bail, Context};
use bitcoin::hex::FromHex;
use bitcoin::secp256k1::rand::{self, RngCore};
use clap::{Parser, Subcommand};
use clap::builder::BoolishValueParser;
use log::{info, warn};
use bark_json::web::{BarkNetwork, BitcoindAuth, ChainSourceConfig, CreateWalletRequest};
use bark_rest::{Config, OnGetMnemonic, OnWalletCreate, OnWalletDelete, RestServer, ServerState};
use bark_rest::http::HeaderValue;
use bark_rest::error::ContextExt;
use bark_rest::auth::AuthToken;
use bark::fs_perms;
use bark_cli::VERSION_DEV_MARKER;
use bark_cli::connection;
use bark_cli::log::init_logging;
use bark_cli::wallet::{ConfigOpts, CreateOpts, create_wallet, open_wallet, read_mnemonic, AUTH_TOKEN_FILE};
use tokio_util::sync::CancellationToken;
const FULL_VERSION: &str = concat!(env!("BARK_VERSION"), " (", env!("GIT_HASH"), ")");
const USER_AGENT: &str = concat!("barkd/", env!("BARK_VERSION"));
fn default_datadir() -> String {
home::home_dir().or_else(|| {
std::env::current_dir().ok()
}).unwrap_or_else(|| {
"./".into()
}).join(".bark").display().to_string()
}
#[derive(Parser)]
#[command(name = "barkd", about = "Bark daemon", version = FULL_VERSION)]
struct Cli {
#[arg(
long,
short = 'v',
env = "BARK_VERBOSE",
global = true,
value_parser = BoolishValueParser::new(),
)]
verbose: bool,
#[arg(
long,
short = 'q',
env = "BARK_QUIET",
global = true,
value_parser = BoolishValueParser::new(),
)]
quiet: bool,
#[arg(long, env = "BARK_LOGFILE", global = true, conflicts_with = "no_logfile")]
logfile: Option<PathBuf>,
#[arg(
long,
env = "BARK_NO_LOGFILE",
global = true,
conflicts_with = "logfile",
value_parser = BoolishValueParser::new(),
)]
no_logfile: bool,
#[arg(long, env = "BARKD_DATADIR", global = true, default_value_t = default_datadir())]
datadir: String,
#[command(subcommand)]
command: Option<Command>,
#[arg(long, env = "BARKD_BIND_PORT")]
port: Option<u16>,
#[arg(long, env = "BARKD_BIND_HOST")]
host: Option<String>,
#[arg(long, env = "BARKD_ALLOWED_ORIGINS", value_delimiter = ',')]
allowed_origins: Vec<String>,
#[arg(
long,
env = "BARKD_EXPOSE_MNEMONIC",
default_value_t = false,
value_parser = BoolishValueParser::new(),
)]
expose_mnemonic: bool,
#[arg(long)]
no_auth: bool,
#[arg(long)]
dangerously_allow_remote_no_auth: bool,
}
#[derive(Subcommand)]
enum Command {
Secret {
#[command(subcommand)]
action: SecretCommand,
},
}
fn parse_hex_secret(s: &str) -> Result<[u8; 32], String> {
<[u8; 32]>::from_hex(s)
.map_err(|_| "must be exactly 64 hex characters (32 bytes)".to_string())
}
#[derive(Subcommand)]
enum SecretCommand {
Show,
Refresh {
#[arg(long, value_parser = parse_hex_secret)]
secret: Option<[u8; 32]>,
},
}
impl Cli {
fn to_config(&self) -> anyhow::Result<Config> {
let mut cfg = Config::default();
if let Some(port) = &self.port {
if *port == 0 {
bail!("--port 0 is not supported; barkd listens on a fixed \
port (default {})", cfg.port);
}
cfg.port = *port;
}
if let Some(host) = &self.host {
cfg.host = host.parse()
.with_context(|| format!("invalid bind host: {host}"))?;
}
for origin in &self.allowed_origins {
origin.parse::<HeaderValue>()
.with_context(|| format!("invalid CORS origin: {origin}"))?;
let valid = (origin.starts_with("http://") || origin.starts_with("https://"))
&& !origin.ends_with('/')
&& origin.matches("://").count() == 1;
if !valid {
bail!(
"invalid CORS origin: {origin} \
(expected format: http://host[:port] or https://host[:port])"
);
}
}
cfg.allowed_origins = self.allowed_origins.clone();
Ok(cfg)
}
fn auth_disabled(&self) -> bool {
self.no_auth || self.dangerously_allow_remote_no_auth
}
}
fn check_remote_no_auth(host: IpAddr, allow_remote: bool) -> anyhow::Result<()> {
if host.is_loopback() || allow_remote {
return Ok(());
}
bail!(
"refusing to start: --no-auth with bind host {host} would give full wallet access \
to every client that can reach the port. Bind a loopback address, or use \
--dangerously-allow-remote-no-auth instead if reachability is restricted by other \
means (and terminate TLS in front of barkd)",
);
}
async fn run_shutdown_signal_listener(shutdown: CancellationToken) {
async fn signal_recv() {
#[cfg(unix)]
{
let mut sigterm = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate()
).expect("Failed to listen for SIGTERM");
sigterm.recv().await;
info!("SIGTERM received! Sending shutdown signal...");
return;
}
#[cfg(windows)]
{
let mut ctrl_break = tokio::signal::windows::ctrl_break()
.expect("Failed to listen for CTRL+BREAK");
ctrl_break.recv().await;
info!("CTRL+BREAK received! Sending shutdown signal...");
return
}
#[cfg(not(any(unix, windows)))]
{
log::warn!("Unknown platform, not listening for shutdown signals");
std::future::pending().await
}
}
tokio::select! {
_ = signal_recv() => {},
r = tokio::signal::ctrl_c() => match r {
Ok(()) => info!("Ctrl+C received! Sending shutdown signal..."),
Err(e) => panic!("failed to listen to ctrl-c signal: {e}"),
},
}
shutdown.cancel();
}
fn load_auth_token(datadir: &PathBuf) -> anyhow::Result<Option<AuthToken>> {
let path = datadir.join(AUTH_TOKEN_FILE);
if !path.exists() {
return Ok(None);
}
let str = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
Ok(Some(AuthToken::decode(&str)?))
}
fn store_auth_token(datadir: &PathBuf, token: &AuthToken) -> anyhow::Result<()> {
let path = datadir.join(AUTH_TOKEN_FILE);
fs_perms::write_atomic_owner_only(&path, token.encode().as_bytes())
}
fn generate_store_auth_token(datadir: &PathBuf) -> anyhow::Result<AuthToken> {
let mut secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut secret);
let token = AuthToken::new(secret);
store_auth_token(datadir, &token)?;
Ok(token)
}
fn wallet_create_request_to_create_opts(req: CreateWalletRequest) -> anyhow::Result<CreateOpts> {
let mnemonic = if let Some(mnemonic) = req.mnemonic {
Some(bip39::Mnemonic::from_str(&mnemonic).badarg("Invalid mnemonic")?)
} else {
None
};
#[allow(deprecated)]
let mut config = ConfigOpts {
ark: req.ark_server,
access_token: req.ark_server_access_token,
esplora: None,
bitcoind: None,
bitcoind_cookie: None,
bitcoind_user: None,
bitcoind_pass: None,
socks5_proxy: None,
};
if let Some(chain_source) = req.chain_source {
match chain_source {
ChainSourceConfig::Esplora { url } => {
config.esplora = Some(url);
},
ChainSourceConfig::Bitcoind { bitcoind, bitcoind_auth } => {
config.bitcoind = Some(bitcoind);
match bitcoind_auth {
BitcoindAuth::Cookie { cookie } => {
config.bitcoind_cookie = Some(cookie);
},
BitcoindAuth::UserPass { user, pass } => {
config.bitcoind_user = Some(user);
config.bitcoind_pass = Some(pass);
},
}
},
}
}
Ok(CreateOpts {
force: req.force,
use_filestore: false,
mainnet: req.network == BarkNetwork::Mainnet,
regtest: req.network == BarkNetwork::Regtest,
signet: req.network == BarkNetwork::Signet,
mutinynet: req.network == BarkNetwork::Mutinynet,
mnemonic: mnemonic,
birthday_height: req.birthday_height,
config: config,
})
}
#[tokio::main]
async fn main() -> anyhow::Result<()>{
let cli = Cli::parse();
let datadir = PathBuf::from_str(&cli.datadir).unwrap();
let datadir_existed = datadir.exists();
std::fs::create_dir_all(&datadir)
.with_context(|| format!("failed to create datadir {}", datadir.display()))?;
if !datadir_existed {
fs_perms::harden(&datadir, 0o700)?;
}
init_logging(cli.verbose, cli.quiet, &datadir, cli.logfile.clone(), cli.no_logfile);
if datadir_existed {
fs_perms::warn_if_loose(&datadir, 0o700);
}
if let Some(command) = &cli.command {
if cli.port.is_some() || cli.host.is_some() {
warn!("--port and --host are only used when running the daemon, ignoring");
}
match command {
Command::Secret { action: SecretCommand::Show } => {
let token = load_auth_token(&datadir)?
.context("no auth token found — run `barkd secret refresh` to generate one")?;
println!("{}", token.encode());
return Ok(());
},
Command::Secret { action: SecretCommand::Refresh { secret: user_secret } } => {
let token = if let Some(bytes) = user_secret {
let token = AuthToken::new(*bytes);
store_auth_token(&datadir, &token)?;
token
} else {
generate_store_auth_token(&datadir)?
};
info!("Restart barkd for the new token to take effect.");
println!("{}", token.encode());
return Ok(());
},
}
}
let config = cli.to_config()?;
let shutdown = CancellationToken::new();
info!("Starting barkd version {} with datadir {}", FULL_VERSION, datadir.display());
if env!("BARK_VERSION").contains(VERSION_DEV_MARKER) {
warn!("You're running a custom build of barkd, which might cause unexpected issues. \
Consider building at one of the tagged versions or using the release builds.");
}
let _barkd_lock = connection::acquire_barkd_lock(&datadir)?;
let auth_token = if cli.auth_disabled() {
check_remote_no_auth(config.host, cli.dangerously_allow_remote_no_auth)?;
if cli.allowed_origins.is_empty() {
warn!("Auth is disabled and no CORS origins are configured — \
any client that can reach this port has full API access.");
}
None
} else {
let token = match load_auth_token(&datadir)? {
Some(token) => token,
None => {
let token = generate_store_auth_token(&datadir)?;
info!("No auth token found — generated a new one. Use `barkd secret show` to view it.");
token
},
};
Some(token)
};
let wallet_opt = if let Some(wallet) = open_wallet(&datadir, USER_AGENT).await? {
wallet.start_daemon()?;
info!("Wallet loaded and daemon started");
Some(wallet)
} else {
warn!("No wallet found. Starting rest server without daemon");
None
};
let on_wallet_create: Box<OnWalletCreate> = Box::new({
let datadir = datadir.clone();
move |req: CreateWalletRequest| {
let datadir = datadir.clone();
Box::pin(async move {
let create_opts = wallet_create_request_to_create_opts(req)?;
create_wallet(&datadir, USER_AGENT, create_opts).await?;
let wallet = open_wallet(&datadir, USER_AGENT).await?
.expect("Wallet should exist");
if let Err(e) = wallet.refresh_server().await {
warn!("Ark server handshake failed on wallet creation: {:#}", e);
}
wallet.start_daemon()?;
Ok::<_, anyhow::Error>(wallet)
})
}
});
let on_wallet_delete: Box<OnWalletDelete> = Box::new({
let datadir = datadir.clone();
move || {
let datadir = datadir.clone();
Box::pin(async move {
connection::wipe_datadir_except_barkd_files(&datadir)?;
Ok(())
})
}
});
let on_get_mnemonic: Option<Box<OnGetMnemonic>> = if cli.expose_mnemonic {
let datadir = datadir.clone();
Some(Box::new(move || {
let datadir = datadir.clone();
Box::pin(async move { read_mnemonic(&datadir).await })
}))
} else {
None
};
let inner_wallet = wallet_opt.as_ref().map(|w| w.clone());
let state = ServerState::builder()
.wallet(wallet_opt)
.auth_token(auth_token)
.on_wallet_create(on_wallet_create)
.on_wallet_delete(on_wallet_delete)
.on_get_mnemonic(on_get_mnemonic)
.build(shutdown.clone());
let server = RestServer::start(&config, Arc::new(state), shutdown.clone()).await?;
run_shutdown_signal_listener(shutdown.clone()).await;
if let Some(wallet) = inner_wallet {
wallet.stop_daemon();
}
if let Err(e) = server.stop_wait().await {
warn!("Error while stopping REST server: {:#}", e);
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn expose_mnemonic_flag_parsing() {
let cli = Cli::try_parse_from(["barkd"])
.expect("bare invocation should parse");
assert!(!cli.expose_mnemonic, "mnemonic exposure must be off by default");
let cli = Cli::try_parse_from(["barkd", "--expose-mnemonic"])
.expect("--expose-mnemonic should parse");
assert!(cli.expose_mnemonic, "--expose-mnemonic must enable exposure");
}
#[test]
fn to_config_rejects_port_zero() {
let cli = Cli::try_parse_from(["barkd", "--port", "0"])
.expect("--port 0 should parse");
let err = cli.to_config().unwrap_err();
assert!(
err.to_string().contains("--port 0 is not supported"),
"unexpected error: {}", err,
);
let cli = Cli::try_parse_from(["barkd", "--port", "3001"])
.expect("--port 3001 should parse");
assert_eq!(cli.to_config().unwrap().port, 3001);
}
#[test]
fn no_auth_flag_parsing() {
let cli = Cli::try_parse_from(["barkd"])
.expect("bare invocation should parse");
assert!(!cli.auth_disabled(), "auth must be required by default");
assert!(!cli.dangerously_allow_remote_no_auth, "remote must be off by default");
let cli = Cli::try_parse_from(["barkd", "--no-auth"])
.expect("--no-auth should parse");
assert!(cli.auth_disabled(), "--no-auth must disable auth");
assert!(!cli.dangerously_allow_remote_no_auth, "--no-auth must not permit remote binds");
let cli = Cli::try_parse_from(["barkd", "--dangerously-allow-remote-no-auth"])
.expect("--dangerously-allow-remote-no-auth should parse");
assert!(cli.auth_disabled(), "the dangerous flag must disable auth on its own");
assert!(cli.dangerously_allow_remote_no_auth, "the dangerous flag must permit remote binds");
}
#[test]
fn remote_no_auth_needs_dangerous_flag() {
let addr = |s: &str| s.parse::<IpAddr>().unwrap();
check_remote_no_auth(addr("127.0.0.1"), false).expect("IPv4 loopback");
check_remote_no_auth(addr("::1"), false).expect("IPv6 loopback");
for host in ["0.0.0.0", "192.168.1.10", "::"] {
check_remote_no_auth(addr(host), false)
.expect_err(&format!("{host} is reachable from other hosts"));
check_remote_no_auth(addr(host), true)
.unwrap_or_else(|e| panic!("{host} should be allowed by the flag: {e:#}"));
}
}
}