#![deny(clippy::pedantic)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::return_self_not_must_use)]
#![allow(clippy::large_futures)]
mod metrics;
use std::convert::Infallible;
use std::env;
use std::fmt::Write as _;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context as _;
use bitcoin::Network;
use clap::builder::BoolishValueParser;
use clap::{ArgGroup, CommandFactory, FromArgMatches, Parser};
use fedimint_core::db::Database;
use fedimint_core::envs::{
FM_IROH_DNS_ENV, FM_IROH_RELAY_ENV, FM_USE_UNKNOWN_MODULE_ENV, is_env_var_set,
is_running_in_test_env,
};
use fedimint_core::module::registry::ModuleRegistry;
use fedimint_core::module::{ApiAuth, CORE_CONSENSUS_VERSION};
use fedimint_core::rustls::install_crypto_provider;
use fedimint_core::task::TaskGroup;
use fedimint_core::timing;
use fedimint_core::util::{FmtCompactAnyhow as _, SafeUrl, handle_version_hash_command};
use fedimint_ln_server::LightningInit;
use fedimint_logging::{LOG_CORE, LOG_SERVER, TracingSetup};
use fedimint_meta_server::MetaInit;
use fedimint_mint_server::MintInit;
use fedimint_rocksdb::RocksDb;
use fedimint_server::IrohNextApiSettings;
use fedimint_server::config::ConfigGenSettings;
use fedimint_server::config::io::{DB_FILE, PLAINTEXT_PASSWORD};
use fedimint_server::core::ServerModuleInitRegistry;
use fedimint_server::net::api::ApiSecrets;
use fedimint_server_bitcoin_rpc::BitcoindClientWithFallback;
use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
use fedimint_server_bitcoin_rpc::tracked::ServerBitcoinRpcTracked;
use fedimint_server_core::ServerModuleInitRegistryExt;
use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
use fedimint_unknown_server::UnknownInit;
use fedimint_wallet_server::WalletInit;
use fedimintd_envs::{
FM_API_URL_ENV, FM_BIND_API_ENV, FM_BIND_API_NEXT_ENV, FM_BIND_METRICS_ENV, FM_BIND_P2P_ENV,
FM_BIND_TOKIO_CONSOLE_ENV, FM_BIND_UI_ENV, FM_BITCOIN_NETWORK_ENV, FM_BITCOIND_PASSWORD_ENV,
FM_BITCOIND_URL_ENV, FM_BITCOIND_URL_PASSWORD_FILE_ENV, FM_BITCOIND_USERNAME_ENV,
FM_DATA_DIR_ENV, FM_DB_CHECKPOINT_RETENTION_ENV, FM_DISABLE_META_MODULE_ENV,
FM_ENABLE_IROH_ENV, FM_ESPLORA_URL_ENV, FM_FORCE_API_SECRETS_ENV,
FM_IROH_API_MAX_CONNECTIONS_ENV, FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV,
FM_IROH_NEXT_ENABLE_ENV, FM_IROH_P2P_RELAY_ENV, FM_P2P_MAX_CONNECTION_AGE_SECS_ENV,
FM_P2P_URL_ENV, FM_PASSWORD_API_ENV, FM_PASSWORD_UI_ENV, FM_SESSION_TIMEOUT_SECS_ENV,
};
use futures::FutureExt as _;
#[cfg(all(
not(feature = "jemalloc"),
not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
))]
use tracing::warn;
use tracing::{debug, error, info};
use crate::metrics::APP_START_TS;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Parser)]
#[command(version)]
#[command(
group(
ArgGroup::new("bitcoind_password_auth")
.args(["bitcoind_password", "bitcoind_url_password_file"])
.multiple(false)
),
group(
ArgGroup::new("bitcoind_auth")
.args(["bitcoind_url"])
.requires("bitcoind_password_auth")
.requires_all(["bitcoind_username", "bitcoind_url"])
),
group(
ArgGroup::new("bitcoin_rpc")
.required(true)
.multiple(true)
.args(["bitcoind_url", "esplora_url"])
)
)]
struct ServerOpts {
#[arg(long = "data-dir", env = FM_DATA_DIR_ENV)]
data_dir: PathBuf,
#[arg(long, env = FM_PASSWORD_UI_ENV)]
password_ui: Option<String>,
#[arg(long, env = FM_PASSWORD_API_ENV)]
password_api: Option<String>,
#[arg(long, env = FM_BITCOIN_NETWORK_ENV, default_value = "regtest")]
bitcoin_network: Network,
#[arg(long, env = FM_BITCOIND_USERNAME_ENV)]
bitcoind_username: Option<String>,
#[arg(long, env = FM_BITCOIND_PASSWORD_ENV)]
bitcoind_password: Option<String>,
#[arg(long, env = FM_BITCOIND_URL_ENV)]
bitcoind_url: Option<SafeUrl>,
#[arg(long, env = FM_BITCOIND_URL_PASSWORD_FILE_ENV)]
bitcoind_url_password_file: Option<PathBuf>,
#[arg(long, env = FM_ESPLORA_URL_ENV)]
esplora_url: Option<SafeUrl>,
#[arg(long, env = FM_BIND_P2P_ENV, default_value = "0.0.0.0:8173")]
bind_p2p: SocketAddr,
#[arg(long, env = FM_BIND_API_ENV, default_value = "0.0.0.0:8174")]
bind_api: SocketAddr,
#[arg(long, env = FM_BIND_UI_ENV, default_value = "127.0.0.1:8175")]
bind_ui: SocketAddr,
#[arg(long, env = FM_P2P_URL_ENV)]
p2p_url: Option<SafeUrl>,
#[arg(long, env = FM_API_URL_ENV)]
api_url: Option<SafeUrl>,
#[arg(long, env = FM_ENABLE_IROH_ENV, value_parser = BoolishValueParser::new())]
enable_iroh: Option<bool>,
#[arg(long, env = FM_IROH_DNS_ENV, requires = "enable_iroh")]
iroh_dns: Option<SafeUrl>,
#[arg(long, env = FM_IROH_RELAY_ENV, requires = "enable_iroh", value_delimiter = ',')]
iroh_relays: Vec<SafeUrl>,
#[arg(long, env = FM_IROH_P2P_RELAY_ENV, value_delimiter = ',')]
iroh_p2p_relays: Vec<SafeUrl>,
#[arg(long, env = FM_DB_CHECKPOINT_RETENTION_ENV, default_value = "1")]
db_checkpoint_retention: u64,
#[arg(long, env = FM_SESSION_TIMEOUT_SECS_ENV, default_value = "3600")]
session_timeout_secs: u64,
#[arg(long, env = FM_P2P_MAX_CONNECTION_AGE_SECS_ENV)]
p2p_max_connection_age_secs: Option<u64>,
#[arg(long, env = FM_BIND_TOKIO_CONSOLE_ENV)]
bind_tokio_console: Option<SocketAddr>,
#[arg(long, default_value = "false")]
with_jaeger: bool,
#[arg(long, env = FM_BIND_METRICS_ENV, default_value = "127.0.0.1:8176")]
bind_metrics: Option<SocketAddr>,
#[arg(long, env = FM_FORCE_API_SECRETS_ENV, default_value = "")]
force_api_secrets: ApiSecrets,
#[arg(long = "iroh-api-max-connections", env = FM_IROH_API_MAX_CONNECTIONS_ENV, default_value = "1000")]
iroh_api_max_connections: usize,
#[arg(long = "iroh-api-max-requests-per-connection", env = FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, default_value = "50")]
iroh_api_max_requests_per_connection: usize,
#[arg(
long,
env = FM_IROH_NEXT_ENABLE_ENV,
default_value_t = true,
action = clap::ArgAction::Set,
num_args = 0..=1,
default_missing_value = "true",
)]
enable_iroh_next: bool,
#[arg(long, env = FM_BIND_API_NEXT_ENV)]
bind_api_next: Option<SocketAddr>,
}
impl ServerOpts {
pub async fn get_bitcoind_url_and_password(&self) -> anyhow::Result<(SafeUrl, String)> {
let url = self
.bitcoind_url
.clone()
.ok_or_else(|| anyhow::anyhow!("No bitcoind url set"))?;
if let Some(password_file) = self.bitcoind_url_password_file.as_ref() {
let password = tokio::fs::read_to_string(password_file)
.await
.context("Failed to read the password")?
.trim()
.to_owned();
Ok((url, password))
} else {
let password = self
.bitcoind_password
.clone()
.expect("FM_BITCOIND_URL is set but FM_BITCOIND_PASSWORD is not");
Ok((url, password))
}
}
}
#[allow(clippy::too_many_lines)]
pub async fn run(
module_init_registry: ServerModuleInitRegistry,
code_version_hash: &str,
code_version_vendor_suffix: Option<&str>,
) -> anyhow::Result<Infallible> {
assert_eq!(
env!("FEDIMINT_BUILD_CODE_VERSION").len(),
code_version_hash.len(),
"version_hash must have an expected length"
);
handle_version_hash_command(code_version_hash);
let fedimint_version = env!("CARGO_PKG_VERSION");
APP_START_TS
.with_label_values(&[fedimint_version, code_version_hash])
.set(fedimint_core::time::duration_since_epoch().as_secs() as i64);
let server_opts = {
let mut module_env_help = String::from("\nModule environment variables:\n");
for (_kind, module_init) in module_init_registry.iter() {
for doc in module_init.get_documented_env_vars() {
let _ = writeln!(module_env_help, " {:40} {}", doc.name, doc.description);
}
}
let matches = ServerOpts::command()
.after_long_help(module_env_help)
.get_matches();
ServerOpts::from_arg_matches(&matches)
.expect("clap arg matches must be valid after parsing")
};
let mut tracing_builder = TracingSetup::default();
tracing_builder
.tokio_console_bind(server_opts.bind_tokio_console)
.with_jaeger(server_opts.with_jaeger);
tracing_builder.init().unwrap();
info!("Starting fedimintd (version: {fedimint_version} version_hash: {code_version_hash})");
#[cfg(all(
not(feature = "jemalloc"),
not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
))]
warn!(
target: LOG_SERVER,
"fedimintd was built without the `jemalloc` feature. rocksdb is prone to memory \
fragmentation with the default allocator; consider rebuilding with `--features jemalloc`."
);
debug!(
target: LOG_SERVER,
core_consensus = %CORE_CONSENSUS_VERSION,
"Supported core consensus version",
);
let code_version_str = code_version_vendor_suffix.map_or_else(
|| fedimint_version.to_string(),
|suffix| format!("{fedimint_version}+{suffix}"),
);
let timing_total_runtime = timing::TimeReporter::new("total-runtime").info();
let root_task_group = TaskGroup::new();
if let Some(bind_metrics) = server_opts.bind_metrics.as_ref() {
info!(
target: LOG_SERVER,
url = %format!("http://{}/metrics", bind_metrics),
"Initializing metrics server",
);
fedimint_metrics::spawn_api_server(*bind_metrics, root_task_group.clone()).await?;
}
let enable_iroh = server_opts.enable_iroh.unwrap_or(!is_running_in_test_env());
let iroh_next_api_settings = if server_opts.enable_iroh_next {
Some(IrohNextApiSettings::new(server_opts.bind_api_next))
} else {
None
};
let settings = ConfigGenSettings {
p2p_bind: server_opts.bind_p2p,
api_bind: server_opts.bind_api,
ui_bind: server_opts.bind_ui,
p2p_url: server_opts.p2p_url.clone(),
api_url: server_opts.api_url.clone(),
enable_iroh,
iroh_dns: server_opts.iroh_dns.clone(),
iroh_relays: server_opts.iroh_relays.clone(),
network: server_opts.bitcoin_network,
available_modules: module_init_registry.kinds(),
default_modules: module_init_registry.default_modules(),
};
let db = Database::new(
RocksDb::build(server_opts.data_dir.join(DB_FILE))
.open()
.await
.unwrap(),
ModuleRegistry::default(),
);
let dyn_server_bitcoin_rpc = match (
server_opts.bitcoind_url.as_ref(),
server_opts.esplora_url.as_ref(),
) {
(Some(_), None) => {
let bitcoind_username = server_opts
.bitcoind_username
.clone()
.expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
let (bitcoind_url, bitcoind_password) = server_opts
.get_bitcoind_url_and_password()
.await
.expect("Failed to get bitcoind url");
BitcoindClient::new(bitcoind_username, bitcoind_password, &bitcoind_url)
.unwrap()
.into_dyn()
}
(None, Some(url)) => EsploraClient::new(url).unwrap().into_dyn(),
(Some(_), Some(esplora_url)) => {
let bitcoind_username = server_opts
.bitcoind_username
.clone()
.expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
let (bitcoind_url, bitcoind_password) = server_opts
.get_bitcoind_url_and_password()
.await
.expect("Failed to get bitcoind url");
BitcoindClientWithFallback::new(
bitcoind_username,
bitcoind_password,
&bitcoind_url,
esplora_url,
)
.unwrap()
.into_dyn()
}
_ => unreachable!("ArgGroup already enforced XOR relation"),
};
let dyn_server_bitcoin_rpc =
ServerBitcoinRpcTracked::new(dyn_server_bitcoin_rpc, "server").into_dyn();
root_task_group.install_kill_handler();
install_crypto_provider().await;
let password_file = std::fs::read_to_string(server_opts.data_dir.join(PLAINTEXT_PASSWORD))
.ok()
.map(|s| s.trim().to_owned());
let auth_ui = server_opts.password_ui.or(password_file).map(ApiAuth::new);
let auth_api = server_opts.password_api.map(ApiAuth::new);
let task_group = root_task_group.clone();
let code_version_hash = code_version_hash.to_string();
root_task_group.spawn_cancellable("main", async move {
fedimint_server::run_with_iroh_p2p_relays_and_next_api(
server_opts.data_dir,
auth_ui,
auth_api,
server_opts.force_api_secrets,
settings,
db,
code_version_str,
code_version_hash,
module_init_registry,
task_group,
dyn_server_bitcoin_rpc,
Box::new(fedimint_server_ui::setup::router),
Box::new(fedimint_server_ui::dashboard::router),
server_opts.db_checkpoint_retention,
Duration::from_secs(server_opts.session_timeout_secs),
server_opts
.p2p_max_connection_age_secs
.map(Duration::from_secs),
fedimint_server::ConnectionLimits::new(
server_opts.iroh_api_max_connections,
server_opts.iroh_api_max_requests_per_connection,
),
server_opts.iroh_p2p_relays,
iroh_next_api_settings,
)
.await
.unwrap_or_else(|err| panic!("Main task returned error: {}", err.fmt_compact_anyhow()));
});
let shutdown_future = root_task_group
.make_handle()
.make_shutdown_rx()
.then(|()| async {
info!(target: LOG_CORE, "Shutdown called");
});
shutdown_future.await;
debug!(target: LOG_CORE, "Terminating main task");
if let Err(err) = root_task_group.join_all(Some(SHUTDOWN_TIMEOUT)).await {
error!(target: LOG_CORE, err = %err.fmt_compact_anyhow(), "Error while shutting down task group");
}
debug!(target: LOG_CORE, "Shutdown complete");
fedimint_logging::shutdown();
drop(timing_total_runtime);
std::process::exit(-1);
}
pub fn default_modules() -> ServerModuleInitRegistry {
let mut server_gens = ServerModuleInitRegistry::new();
server_gens.attach(MintInit);
server_gens.attach(fedimint_mintv2_server::MintInit);
server_gens.attach(WalletInit);
server_gens.attach(fedimint_walletv2_server::WalletInit);
server_gens.attach(LightningInit);
server_gens.attach(fedimint_lnv2_server::LightningInit);
if !is_env_var_set(FM_DISABLE_META_MODULE_ENV) {
server_gens.attach(MetaInit);
}
if is_env_var_set(FM_USE_UNKNOWN_MODULE_ENV) {
server_gens.attach(UnknownInit);
}
server_gens
}
#[cfg(test)]
mod tests {
use super::*;
fn server_opts_args() -> Vec<&'static str> {
vec![
"fedimintd",
"--data-dir",
"/tmp/fedimintd-test",
"--bitcoind-url",
"http://127.0.0.1:18443",
"--bitcoind-username",
"user",
"--bitcoind-password",
"pass",
]
}
fn parse_server_opts() -> ServerOpts {
ServerOpts::try_parse_from(server_opts_args()).expect("server opts should parse")
}
fn parse_server_opts_with_enable_iroh_env(value: &str) -> ServerOpts {
let previous = std::env::var_os(FM_ENABLE_IROH_ENV);
unsafe {
std::env::set_var(FM_ENABLE_IROH_ENV, value);
}
let opts = parse_server_opts();
unsafe {
if let Some(previous) = previous {
std::env::set_var(FM_ENABLE_IROH_ENV, previous);
} else {
std::env::remove_var(FM_ENABLE_IROH_ENV);
}
}
opts
}
#[test]
fn enable_iroh_env_accepts_numeric_booleans() {
assert_eq!(
parse_server_opts_with_enable_iroh_env("1").enable_iroh,
Some(true)
);
assert_eq!(
parse_server_opts_with_enable_iroh_env("0").enable_iroh,
Some(false)
);
}
#[test]
fn iroh_next_api_defaults_to_enabled_and_accepts_false() {
let command = ServerOpts::command();
let enable_iroh_next = command
.get_arguments()
.find(|arg| arg.get_id() == "enable_iroh_next")
.expect("enable-iroh-next argument exists");
assert_eq!(enable_iroh_next.get_default_values(), ["true"]);
let mut args = server_opts_args();
args.push("--enable-iroh-next=false");
let opts = ServerOpts::try_parse_from(args).expect("explicit false should parse");
assert!(!opts.enable_iroh_next);
}
#[test]
fn p2p_relay_does_not_require_enable_iroh_or_change_api_relays() {
let opts = ServerOpts::try_parse_from([
"fedimintd",
"--data-dir",
"/tmp/fedimintd-test",
"--bitcoind-url",
"http://127.0.0.1:18443",
"--bitcoind-username",
"user",
"--bitcoind-password",
"pass",
"--iroh-p2p-relays",
"https://relay.example.com/",
])
.expect("P2P relay should parse independently");
assert!(opts.iroh_relays.is_empty());
assert_eq!(opts.iroh_p2p_relays.len(), 1);
}
}