1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_wrap)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::must_use_candidate)]
6#![allow(clippy::return_self_not_must_use)]
7#![allow(clippy::large_futures)]
8
9mod metrics;
10
11use std::convert::Infallible;
12use std::env;
13use std::fmt::Write as _;
14use std::net::SocketAddr;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use anyhow::Context as _;
19use bitcoin::Network;
20use clap::builder::BoolishValueParser;
21use clap::{ArgGroup, CommandFactory, FromArgMatches, Parser};
22use fedimint_core::db::Database;
23use fedimint_core::envs::{
24 FM_IROH_DNS_ENV, FM_IROH_RELAY_ENV, FM_USE_UNKNOWN_MODULE_ENV, is_env_var_set,
25 is_running_in_test_env,
26};
27use fedimint_core::module::registry::ModuleRegistry;
28use fedimint_core::module::{ApiAuth, CORE_CONSENSUS_VERSION};
29use fedimint_core::rustls::install_crypto_provider;
30use fedimint_core::task::TaskGroup;
31use fedimint_core::timing;
32use fedimint_core::util::{
33 FmtCompact as _, FmtCompactAnyhow as _, SafeUrl, handle_version_hash_command,
34};
35use fedimint_ln_server::LightningInit;
36use fedimint_logging::{LOG_CORE, LOG_SERVER, TracingSetup};
37use fedimint_meta_server::MetaInit;
38use fedimint_mint_server::MintInit;
39use fedimint_rocksdb::RocksDb;
40use fedimint_server::IrohNextApiSettings;
41use fedimint_server::config::ConfigGenSettings;
42use fedimint_server::config::io::{DB_FILE, PLAINTEXT_PASSWORD};
43use fedimint_server::core::ServerModuleInitRegistry;
44use fedimint_server::net::api::ApiSecrets;
45use fedimint_server_bitcoin_rpc::BitcoindClientWithFallback;
46use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
47use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
48use fedimint_server_bitcoin_rpc::tracked::ServerBitcoinRpcTracked;
49use fedimint_server_core::ServerModuleInitRegistryExt;
50use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
51use fedimint_unknown_server::UnknownInit;
52use fedimint_wallet_server::WalletInit;
53use fedimintd_envs::{
54 FM_API_URL_ENV, FM_BIND_API_ENV, FM_BIND_API_NEXT_ENV, FM_BIND_METRICS_ENV, FM_BIND_P2P_ENV,
55 FM_BIND_TOKIO_CONSOLE_ENV, FM_BIND_UI_ENV, FM_BITCOIN_NETWORK_ENV, FM_BITCOIND_PASSWORD_ENV,
56 FM_BITCOIND_URL_ENV, FM_BITCOIND_URL_PASSWORD_FILE_ENV, FM_BITCOIND_USERNAME_ENV,
57 FM_DATA_DIR_ENV, FM_DB_CHECKPOINT_RETENTION_ENV, FM_DISABLE_META_MODULE_ENV,
58 FM_ENABLE_IROH_ENV, FM_ESPLORA_URL_ENV, FM_FORCE_API_SECRETS_ENV,
59 FM_IROH_API_MAX_CONNECTIONS_ENV, FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV,
60 FM_IROH_NEXT_ENABLE_ENV, FM_IROH_P2P_RELAY_ENV, FM_P2P_MAX_CONNECTION_AGE_SECS_ENV,
61 FM_P2P_URL_ENV, FM_PASSWORD_API_ENV, FM_PASSWORD_UI_ENV, FM_SESSION_TIMEOUT_SECS_ENV,
62};
63use futures::FutureExt as _;
64#[cfg(all(
65 not(feature = "jemalloc"),
66 not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
67))]
68use tracing::warn;
69use tracing::{debug, error, info};
70
71use crate::metrics::APP_START_TS;
72
73const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
75
76#[derive(Parser)]
77#[command(version)]
78#[command(
79 group(
80 ArgGroup::new("bitcoind_password_auth")
81 .args(["bitcoind_password", "bitcoind_url_password_file"])
82 .multiple(false)
83 ),
84 group(
85 ArgGroup::new("bitcoind_auth")
86 .args(["bitcoind_url"])
87 .requires("bitcoind_password_auth")
88 .requires_all(["bitcoind_username", "bitcoind_url"])
89 ),
90 group(
91 ArgGroup::new("bitcoin_rpc")
92 .required(true)
93 .multiple(true)
94 .args(["bitcoind_url", "esplora_url"])
95 )
96)]
97struct ServerOpts {
98 #[arg(long = "data-dir", env = FM_DATA_DIR_ENV)]
100 data_dir: PathBuf,
101
102 #[arg(long, env = FM_PASSWORD_UI_ENV)]
107 password_ui: Option<String>,
108
109 #[arg(long, env = FM_PASSWORD_API_ENV)]
114 password_api: Option<String>,
115
116 #[arg(long, env = FM_BITCOIN_NETWORK_ENV, default_value = "regtest")]
118 bitcoin_network: Network,
119
120 #[arg(long, env = FM_BITCOIND_USERNAME_ENV)]
122 bitcoind_username: Option<String>,
123
124 #[arg(long, env = FM_BITCOIND_PASSWORD_ENV)]
126 bitcoind_password: Option<String>,
127
128 #[arg(long, env = FM_BITCOIND_URL_ENV)]
132 bitcoind_url: Option<SafeUrl>,
133
134 #[arg(long, env = FM_BITCOIND_URL_PASSWORD_FILE_ENV)]
142 bitcoind_url_password_file: Option<PathBuf>,
143
144 #[arg(long, env = FM_ESPLORA_URL_ENV)]
146 esplora_url: Option<SafeUrl>,
147
148 #[arg(long, env = FM_BIND_P2P_ENV, default_value = "0.0.0.0:8173")]
153 bind_p2p: SocketAddr,
154
155 #[arg(long, env = FM_BIND_API_ENV, default_value = "0.0.0.0:8174")]
160 bind_api: SocketAddr,
161
162 #[arg(long, env = FM_BIND_UI_ENV, default_value = "127.0.0.1:8175")]
168 bind_ui: SocketAddr,
169
170 #[arg(long, env = FM_P2P_URL_ENV)]
176 p2p_url: Option<SafeUrl>,
177
178 #[arg(long, env = FM_API_URL_ENV)]
184 api_url: Option<SafeUrl>,
185
186 #[arg(long, env = FM_ENABLE_IROH_ENV, value_parser = BoolishValueParser::new())]
193 enable_iroh: Option<bool>,
194
195 #[arg(long, env = FM_IROH_DNS_ENV, requires = "enable_iroh")]
197 iroh_dns: Option<SafeUrl>,
198
199 #[arg(long, env = FM_IROH_RELAY_ENV, requires = "enable_iroh", value_delimiter = ',')]
201 iroh_relays: Vec<SafeUrl>,
202
203 #[arg(long, env = FM_IROH_P2P_RELAY_ENV, value_delimiter = ',')]
205 iroh_p2p_relays: Vec<SafeUrl>,
206
207 #[arg(long, env = FM_DB_CHECKPOINT_RETENTION_ENV, default_value = "1")]
209 db_checkpoint_retention: u64,
210
211 #[arg(long, env = FM_SESSION_TIMEOUT_SECS_ENV, default_value = "3600")]
215 session_timeout_secs: u64,
216
217 #[arg(long, env = FM_P2P_MAX_CONNECTION_AGE_SECS_ENV)]
221 p2p_max_connection_age_secs: Option<u64>,
222
223 #[arg(long, env = FM_BIND_TOKIO_CONSOLE_ENV)]
225 bind_tokio_console: Option<SocketAddr>,
226
227 #[arg(long, default_value = "false")]
229 with_jaeger: bool,
230
231 #[arg(long, env = FM_BIND_METRICS_ENV, default_value = "127.0.0.1:8176")]
233 bind_metrics: Option<SocketAddr>,
234
235 #[arg(long, env = FM_FORCE_API_SECRETS_ENV, default_value = "")]
250 force_api_secrets: ApiSecrets,
251
252 #[arg(long = "iroh-api-max-connections", env = FM_IROH_API_MAX_CONNECTIONS_ENV, default_value = "1000")]
254 iroh_api_max_connections: usize,
255
256 #[arg(long = "iroh-api-max-requests-per-connection", env = FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, default_value = "50")]
258 iroh_api_max_requests_per_connection: usize,
259
260 #[arg(
267 long,
268 env = FM_IROH_NEXT_ENABLE_ENV,
269 default_value_t = true,
270 action = clap::ArgAction::Set,
271 num_args = 0..=1,
272 default_missing_value = "true",
273 )]
274 enable_iroh_next: bool,
275
276 #[arg(long, env = FM_BIND_API_NEXT_ENV)]
278 bind_api_next: Option<SocketAddr>,
279}
280
281impl ServerOpts {
282 pub async fn get_bitcoind_url_and_password(&self) -> anyhow::Result<(SafeUrl, String)> {
283 let url = self
284 .bitcoind_url
285 .clone()
286 .ok_or_else(|| anyhow::anyhow!("No bitcoind url set"))?;
287 if let Some(password_file) = self.bitcoind_url_password_file.as_ref() {
288 let password = tokio::fs::read_to_string(password_file)
289 .await
290 .context("Failed to read the password")?
291 .trim()
292 .to_owned();
293 Ok((url, password))
294 } else {
295 let password = self
296 .bitcoind_password
297 .clone()
298 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_PASSWORD is not");
299 Ok((url, password))
300 }
301 }
302}
303
304#[allow(clippy::too_many_lines)]
321pub async fn run(
322 module_init_registry: ServerModuleInitRegistry,
323 code_version_hash: &str,
324 code_version_vendor_suffix: Option<&str>,
325) -> anyhow::Result<Infallible> {
326 assert_eq!(
327 env!("FEDIMINT_BUILD_CODE_VERSION").len(),
328 code_version_hash.len(),
329 "version_hash must have an expected length"
330 );
331
332 handle_version_hash_command(code_version_hash);
333
334 let fedimint_version = env!("CARGO_PKG_VERSION");
335
336 APP_START_TS
337 .with_label_values(&[fedimint_version, code_version_hash])
338 .set(fedimint_core::time::duration_since_epoch().as_secs() as i64);
339
340 let server_opts = {
341 let mut module_env_help = String::from("\nModule environment variables:\n");
344 for (_kind, module_init) in module_init_registry.iter() {
345 for doc in module_init.get_documented_env_vars() {
346 let _ = writeln!(module_env_help, " {:40} {}", doc.name, doc.description);
347 }
348 }
349 let matches = ServerOpts::command()
350 .after_long_help(module_env_help)
351 .get_matches();
352 ServerOpts::from_arg_matches(&matches)
353 .expect("clap arg matches must be valid after parsing")
354 };
355
356 let mut tracing_builder = TracingSetup::default();
357
358 tracing_builder
359 .tokio_console_bind(server_opts.bind_tokio_console)
360 .with_jaeger(server_opts.with_jaeger);
361
362 tracing_builder.init().unwrap();
363
364 info!("Starting fedimintd (version: {fedimint_version} version_hash: {code_version_hash})");
365
366 #[cfg(all(
367 not(feature = "jemalloc"),
368 not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
369 ))]
370 warn!(
371 target: LOG_SERVER,
372 "fedimintd was built without the `jemalloc` feature. rocksdb is prone to memory \
373 fragmentation with the default allocator; consider rebuilding with `--features jemalloc`."
374 );
375
376 debug!(
377 target: LOG_SERVER,
378 core_consensus = %CORE_CONSENSUS_VERSION,
379 "Supported core consensus version",
380 );
381
382 let code_version_str = code_version_vendor_suffix.map_or_else(
383 || fedimint_version.to_string(),
384 |suffix| format!("{fedimint_version}+{suffix}"),
385 );
386
387 let timing_total_runtime = timing::TimeReporter::new("total-runtime").info();
388
389 let root_task_group = TaskGroup::new();
390
391 if let Some(bind_metrics) = server_opts.bind_metrics.as_ref() {
392 info!(
393 target: LOG_SERVER,
394 url = %format!("http://{}/metrics", bind_metrics),
395 "Initializing metrics server",
396 );
397 fedimint_metrics::spawn_api_server(*bind_metrics, root_task_group.clone()).await?;
398 }
399
400 let enable_iroh = server_opts.enable_iroh.unwrap_or(!is_running_in_test_env());
401 let iroh_next_api_settings = if server_opts.enable_iroh_next {
402 Some(IrohNextApiSettings::new(server_opts.bind_api_next))
403 } else {
404 None
405 };
406
407 let settings = ConfigGenSettings {
408 p2p_bind: server_opts.bind_p2p,
409 api_bind: server_opts.bind_api,
410 ui_bind: server_opts.bind_ui,
411 p2p_url: server_opts.p2p_url.clone(),
412 api_url: server_opts.api_url.clone(),
413 enable_iroh,
414 iroh_dns: server_opts.iroh_dns.clone(),
415 iroh_relays: server_opts.iroh_relays.clone(),
416 network: server_opts.bitcoin_network,
417 available_modules: module_init_registry.kinds(),
418 default_modules: module_init_registry.default_modules(),
419 };
420
421 let db = Database::new(
422 RocksDb::build(server_opts.data_dir.join(DB_FILE))
423 .open()
424 .await
425 .unwrap(),
426 ModuleRegistry::default(),
427 );
428
429 let dyn_server_bitcoin_rpc = match (
430 server_opts.bitcoind_url.as_ref(),
431 server_opts.esplora_url.as_ref(),
432 ) {
433 (Some(_), None) => {
434 let bitcoind_username = server_opts
435 .bitcoind_username
436 .clone()
437 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
438 let (bitcoind_url, bitcoind_password) = server_opts
439 .get_bitcoind_url_and_password()
440 .await
441 .expect("Failed to get bitcoind url");
442 BitcoindClient::new(bitcoind_username, bitcoind_password, &bitcoind_url)
443 .unwrap()
444 .into_dyn()
445 }
446 (None, Some(url)) => EsploraClient::new(url).unwrap().into_dyn(),
447 (Some(_), Some(esplora_url)) => {
448 let bitcoind_username = server_opts
449 .bitcoind_username
450 .clone()
451 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
452 let (bitcoind_url, bitcoind_password) = server_opts
453 .get_bitcoind_url_and_password()
454 .await
455 .expect("Failed to get bitcoind url");
456 BitcoindClientWithFallback::new(
457 bitcoind_username,
458 bitcoind_password,
459 &bitcoind_url,
460 esplora_url,
461 )
462 .unwrap()
463 .into_dyn()
464 }
465 _ => unreachable!("ArgGroup already enforced XOR relation"),
466 };
467 let dyn_server_bitcoin_rpc =
468 ServerBitcoinRpcTracked::new(dyn_server_bitcoin_rpc, "server").into_dyn();
469
470 root_task_group.install_kill_handler();
471
472 install_crypto_provider().await;
473
474 let password_file = std::fs::read_to_string(server_opts.data_dir.join(PLAINTEXT_PASSWORD))
481 .ok()
482 .map(|s| s.trim().to_owned());
483
484 let auth_ui = server_opts.password_ui.or(password_file).map(ApiAuth::new);
485 let auth_api = server_opts.password_api.map(ApiAuth::new);
486
487 let task_group = root_task_group.clone();
488 let code_version_hash = code_version_hash.to_string();
489 root_task_group.spawn_cancellable("main", async move {
490 fedimint_server::run_with_iroh_p2p_relays_and_next_api(
491 server_opts.data_dir,
492 auth_ui,
493 auth_api,
494 server_opts.force_api_secrets,
495 settings,
496 db,
497 code_version_str,
498 code_version_hash,
499 module_init_registry,
500 task_group,
501 dyn_server_bitcoin_rpc,
502 Box::new(fedimint_server_ui::setup::router),
503 Box::new(fedimint_server_ui::dashboard::router),
504 server_opts.db_checkpoint_retention,
505 Duration::from_secs(server_opts.session_timeout_secs),
506 server_opts
507 .p2p_max_connection_age_secs
508 .map(Duration::from_secs),
509 fedimint_server::ConnectionLimits::new(
510 server_opts.iroh_api_max_connections,
511 server_opts.iroh_api_max_requests_per_connection,
512 ),
513 server_opts.iroh_p2p_relays,
514 iroh_next_api_settings,
515 )
516 .await
517 .unwrap_or_else(|err| panic!("Main task returned error: {}", err.fmt_compact_anyhow()));
518 });
519
520 let shutdown_future = root_task_group
521 .make_handle()
522 .make_shutdown_rx()
523 .then(|()| async {
524 info!(target: LOG_CORE, "Shutdown called");
525 });
526
527 shutdown_future.await;
528
529 debug!(target: LOG_CORE, "Terminating main task");
530
531 if let Err(err) = root_task_group.join_all(Some(SHUTDOWN_TIMEOUT)).await {
532 error!(target: LOG_CORE, err = %err.fmt_compact(), "Error while shutting down task group");
533 }
534
535 debug!(target: LOG_CORE, "Shutdown complete");
536
537 fedimint_logging::shutdown();
538
539 drop(timing_total_runtime);
540
541 std::process::exit(-1);
542}
543
544pub fn default_modules() -> ServerModuleInitRegistry {
545 let mut server_gens = ServerModuleInitRegistry::new();
546
547 server_gens.attach(MintInit);
548 server_gens.attach(fedimint_mintv2_server::MintInit);
549
550 server_gens.attach(WalletInit);
551 server_gens.attach(fedimint_walletv2_server::WalletInit);
552
553 server_gens.attach(LightningInit);
554 server_gens.attach(fedimint_lnv2_server::LightningInit);
555
556 if !is_env_var_set(FM_DISABLE_META_MODULE_ENV) {
557 server_gens.attach(MetaInit);
558 }
559
560 if is_env_var_set(FM_USE_UNKNOWN_MODULE_ENV) {
561 server_gens.attach(UnknownInit);
562 }
563
564 server_gens
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 fn server_opts_args() -> Vec<&'static str> {
572 vec![
573 "fedimintd",
574 "--data-dir",
575 "/tmp/fedimintd-test",
576 "--bitcoind-url",
577 "http://127.0.0.1:18443",
578 "--bitcoind-username",
579 "user",
580 "--bitcoind-password",
581 "pass",
582 ]
583 }
584
585 fn parse_server_opts() -> ServerOpts {
586 ServerOpts::try_parse_from(server_opts_args()).expect("server opts should parse")
587 }
588
589 fn parse_server_opts_with_enable_iroh_env(value: &str) -> ServerOpts {
590 let previous = std::env::var_os(FM_ENABLE_IROH_ENV);
591 unsafe {
594 std::env::set_var(FM_ENABLE_IROH_ENV, value);
595 }
596
597 let opts = parse_server_opts();
598
599 unsafe {
602 if let Some(previous) = previous {
603 std::env::set_var(FM_ENABLE_IROH_ENV, previous);
604 } else {
605 std::env::remove_var(FM_ENABLE_IROH_ENV);
606 }
607 }
608
609 opts
610 }
611
612 #[test]
613 fn enable_iroh_env_accepts_numeric_booleans() {
614 assert_eq!(
615 parse_server_opts_with_enable_iroh_env("1").enable_iroh,
616 Some(true)
617 );
618 assert_eq!(
619 parse_server_opts_with_enable_iroh_env("0").enable_iroh,
620 Some(false)
621 );
622 }
623
624 #[test]
625 fn iroh_next_api_defaults_to_enabled_and_accepts_false() {
626 let command = ServerOpts::command();
627 let enable_iroh_next = command
628 .get_arguments()
629 .find(|arg| arg.get_id() == "enable_iroh_next")
630 .expect("enable-iroh-next argument exists");
631 assert_eq!(enable_iroh_next.get_default_values(), ["true"]);
632
633 let mut args = server_opts_args();
634 args.push("--enable-iroh-next=false");
635 let opts = ServerOpts::try_parse_from(args).expect("explicit false should parse");
636 assert!(!opts.enable_iroh_next);
637 }
638
639 #[test]
640 fn p2p_relay_does_not_require_enable_iroh_or_change_api_relays() {
641 let opts = ServerOpts::try_parse_from([
642 "fedimintd",
643 "--data-dir",
644 "/tmp/fedimintd-test",
645 "--bitcoind-url",
646 "http://127.0.0.1:18443",
647 "--bitcoind-username",
648 "user",
649 "--bitcoind-password",
650 "pass",
651 "--iroh-p2p-relays",
652 "https://relay.example.com/",
653 ])
654 .expect("P2P relay should parse independently");
655
656 assert!(opts.iroh_relays.is_empty());
657 assert_eq!(opts.iroh_p2p_relays.len(), 1);
658 }
659}