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::{FmtCompact as _, SafeUrl, handle_version_hash_command};
33use fedimint_ln_server::LightningInit;
34use fedimint_logging::{LOG_CORE, LOG_SERVER, TracingSetup};
35use fedimint_meta_server::MetaInit;
36use fedimint_mint_server::MintInit;
37use fedimint_rocksdb::RocksDb;
38use fedimint_server::IrohNextApiSettings;
39use fedimint_server::config::ConfigGenSettings;
40use fedimint_server::config::io::{DB_FILE, PLAINTEXT_PASSWORD};
41use fedimint_server::core::ServerModuleInitRegistry;
42use fedimint_server::net::api::ApiSecrets;
43use fedimint_server_bitcoin_rpc::BitcoindClientWithFallback;
44use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
45use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
46use fedimint_server_bitcoin_rpc::tracked::ServerBitcoinRpcTracked;
47use fedimint_server_core::ServerModuleInitRegistryExt;
48use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
49use fedimint_unknown_server::UnknownInit;
50use fedimint_wallet_server::WalletInit;
51use fedimintd_envs::{
52 FM_API_URL_ENV, FM_BIND_API_ENV, FM_BIND_API_NEXT_ENV, FM_BIND_METRICS_ENV, FM_BIND_P2P_ENV,
53 FM_BIND_TOKIO_CONSOLE_ENV, FM_BIND_UI_ENV, FM_BITCOIN_NETWORK_ENV, FM_BITCOIND_PASSWORD_ENV,
54 FM_BITCOIND_URL_ENV, FM_BITCOIND_URL_PASSWORD_FILE_ENV, FM_BITCOIND_USERNAME_ENV,
55 FM_DATA_DIR_ENV, FM_DB_CHECKPOINT_RETENTION_ENV, FM_DISABLE_META_MODULE_ENV,
56 FM_ENABLE_IROH_ENV, FM_ESPLORA_URL_ENV, FM_FORCE_API_SECRETS_ENV,
57 FM_IROH_API_MAX_CONNECTIONS_ENV, FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV,
58 FM_IROH_NEXT_ENABLE_ENV, FM_IROH_P2P_RELAY_ENV, FM_P2P_MAX_CONNECTION_AGE_SECS_ENV,
59 FM_P2P_URL_ENV, FM_PASSWORD_API_ENV, FM_PASSWORD_UI_ENV, FM_SESSION_TIMEOUT_SECS_ENV,
60};
61use futures::FutureExt as _;
62#[cfg(all(
63 not(feature = "jemalloc"),
64 not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
65))]
66use tracing::warn;
67use tracing::{debug, error, info};
68
69use crate::metrics::APP_START_TS;
70
71const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
73
74#[derive(Parser)]
75#[command(version)]
76#[command(
77 group(
78 ArgGroup::new("bitcoind_password_auth")
79 .args(["bitcoind_password", "bitcoind_url_password_file"])
80 .multiple(false)
81 ),
82 group(
83 ArgGroup::new("bitcoind_auth")
84 .args(["bitcoind_url"])
85 .requires("bitcoind_password_auth")
86 .requires_all(["bitcoind_username", "bitcoind_url"])
87 ),
88 group(
89 ArgGroup::new("bitcoin_rpc")
90 .required(true)
91 .multiple(true)
92 .args(["bitcoind_url", "esplora_url"])
93 )
94)]
95struct ServerOpts {
96 #[arg(long = "data-dir", env = FM_DATA_DIR_ENV)]
98 data_dir: PathBuf,
99
100 #[arg(long, env = FM_PASSWORD_UI_ENV)]
105 password_ui: Option<String>,
106
107 #[arg(long, env = FM_PASSWORD_API_ENV)]
112 password_api: Option<String>,
113
114 #[arg(long, env = FM_BITCOIN_NETWORK_ENV, default_value = "regtest")]
116 bitcoin_network: Network,
117
118 #[arg(long, env = FM_BITCOIND_USERNAME_ENV)]
120 bitcoind_username: Option<String>,
121
122 #[arg(long, env = FM_BITCOIND_PASSWORD_ENV)]
124 bitcoind_password: Option<String>,
125
126 #[arg(long, env = FM_BITCOIND_URL_ENV)]
134 bitcoind_url: Option<SafeUrl>,
135
136 #[arg(long, env = FM_BITCOIND_URL_PASSWORD_FILE_ENV)]
144 bitcoind_url_password_file: Option<PathBuf>,
145
146 #[arg(long, env = FM_ESPLORA_URL_ENV)]
156 esplora_url: Option<SafeUrl>,
157
158 #[arg(long, env = FM_BIND_P2P_ENV, default_value = "0.0.0.0:8173")]
163 bind_p2p: SocketAddr,
164
165 #[arg(long, env = FM_BIND_API_ENV, default_value = "0.0.0.0:8174")]
170 bind_api: SocketAddr,
171
172 #[arg(long, env = FM_BIND_UI_ENV, default_value = "127.0.0.1:8175")]
178 bind_ui: SocketAddr,
179
180 #[arg(long, env = FM_P2P_URL_ENV)]
186 p2p_url: Option<SafeUrl>,
187
188 #[arg(long, env = FM_API_URL_ENV)]
194 api_url: Option<SafeUrl>,
195
196 #[arg(long, env = FM_ENABLE_IROH_ENV, value_parser = BoolishValueParser::new())]
203 enable_iroh: Option<bool>,
204
205 #[arg(long, env = FM_IROH_DNS_ENV, requires = "enable_iroh")]
207 iroh_dns: Option<SafeUrl>,
208
209 #[arg(long, env = FM_IROH_RELAY_ENV, requires = "enable_iroh", value_delimiter = ',')]
211 iroh_relays: Vec<SafeUrl>,
212
213 #[arg(long, env = FM_IROH_P2P_RELAY_ENV, value_delimiter = ',')]
215 iroh_p2p_relays: Vec<SafeUrl>,
216
217 #[arg(long, env = FM_DB_CHECKPOINT_RETENTION_ENV, default_value = "1")]
219 db_checkpoint_retention: u64,
220
221 #[arg(long, env = FM_SESSION_TIMEOUT_SECS_ENV, default_value = "3600")]
225 session_timeout_secs: u64,
226
227 #[arg(long, env = FM_P2P_MAX_CONNECTION_AGE_SECS_ENV)]
231 p2p_max_connection_age_secs: Option<u64>,
232
233 #[arg(long, env = FM_BIND_TOKIO_CONSOLE_ENV)]
235 bind_tokio_console: Option<SocketAddr>,
236
237 #[arg(long, default_value = "false")]
239 with_jaeger: bool,
240
241 #[arg(long, env = FM_BIND_METRICS_ENV, default_value = "127.0.0.1:8176")]
243 bind_metrics: Option<SocketAddr>,
244
245 #[arg(long, env = FM_FORCE_API_SECRETS_ENV, default_value = "")]
260 force_api_secrets: ApiSecrets,
261
262 #[arg(long = "iroh-api-max-connections", env = FM_IROH_API_MAX_CONNECTIONS_ENV, default_value = "1000")]
264 iroh_api_max_connections: usize,
265
266 #[arg(long = "iroh-api-max-requests-per-connection", env = FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, default_value = "50")]
268 iroh_api_max_requests_per_connection: usize,
269
270 #[arg(
277 long,
278 env = FM_IROH_NEXT_ENABLE_ENV,
279 default_value_t = true,
280 action = clap::ArgAction::Set,
281 num_args = 0..=1,
282 default_missing_value = "true",
283 )]
284 enable_iroh_next: bool,
285
286 #[arg(long, env = FM_BIND_API_NEXT_ENV)]
288 bind_api_next: Option<SocketAddr>,
289}
290
291impl ServerOpts {
292 pub async fn get_bitcoind_url_and_password(&self) -> anyhow::Result<(SafeUrl, String)> {
293 let url = self
294 .bitcoind_url
295 .clone()
296 .ok_or_else(|| anyhow::anyhow!("No bitcoind url set"))?;
297 if let Some(password_file) = self.bitcoind_url_password_file.as_ref() {
298 let password = tokio::fs::read_to_string(password_file)
299 .await
300 .context("Failed to read the password")?
301 .trim()
302 .to_owned();
303 Ok((url, password))
304 } else {
305 let password = self
306 .bitcoind_password
307 .clone()
308 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_PASSWORD is not");
309 Ok((url, password))
310 }
311 }
312}
313
314#[allow(clippy::too_many_lines)]
333pub async fn run(
334 module_init_registry: ServerModuleInitRegistry,
335 code_version_hash: &str,
336 code_version_vendor_suffix: Option<&str>,
337) -> anyhow::Result<Infallible> {
338 assert_eq!(
339 env!("FEDIMINT_BUILD_CODE_VERSION").len(),
340 code_version_hash.len(),
341 "version_hash must have an expected length"
342 );
343
344 handle_version_hash_command(code_version_hash);
345
346 let fedimint_version = env!("CARGO_PKG_VERSION");
347
348 APP_START_TS
349 .with_label_values(&[fedimint_version, code_version_hash])
350 .set(fedimint_core::time::duration_since_epoch().as_secs() as i64);
351
352 let server_opts = {
353 let mut module_env_help = String::from("\nModule environment variables:\n");
356 for (_kind, module_init) in module_init_registry.iter() {
357 for doc in module_init.get_documented_env_vars() {
358 let _ = writeln!(module_env_help, " {:40} {}", doc.name, doc.description);
359 }
360 }
361 let matches = ServerOpts::command()
362 .after_long_help(module_env_help)
363 .get_matches();
364 ServerOpts::from_arg_matches(&matches)
365 .expect("clap arg matches must be valid after parsing")
366 };
367
368 let mut tracing_builder = TracingSetup::default();
369
370 tracing_builder
371 .tokio_console_bind(server_opts.bind_tokio_console)
372 .with_jaeger(server_opts.with_jaeger);
373
374 tracing_builder.init().map_err(anyhow::Error::from).unwrap();
375
376 info!("Starting fedimintd (version: {fedimint_version} version_hash: {code_version_hash})");
377
378 #[cfg(all(
379 not(feature = "jemalloc"),
380 not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
381 ))]
382 warn!(
383 target: LOG_SERVER,
384 "fedimintd was built without the `jemalloc` feature. rocksdb is prone to memory \
385 fragmentation with the default allocator; consider rebuilding with `--features jemalloc`."
386 );
387
388 debug!(
389 target: LOG_SERVER,
390 core_consensus = %CORE_CONSENSUS_VERSION,
391 "Supported core consensus version",
392 );
393
394 let code_version_str = code_version_vendor_suffix.map_or_else(
395 || fedimint_version.to_string(),
396 |suffix| format!("{fedimint_version}+{suffix}"),
397 );
398 fedimint_core::version::DkgVersion::parse(&code_version_str)
399 .context("Invalid Fedimint version vendor string")?;
400
401 let timing_total_runtime = timing::TimeReporter::new("total-runtime").info();
402
403 let root_task_group = TaskGroup::new();
404
405 if let Some(bind_metrics) = server_opts.bind_metrics.as_ref() {
406 info!(
407 target: LOG_SERVER,
408 url = %format!("http://{}/metrics", bind_metrics),
409 "Initializing metrics server",
410 );
411 fedimint_metrics::spawn_api_server(*bind_metrics, root_task_group.clone()).await?;
412 }
413
414 let enable_iroh = server_opts.enable_iroh.unwrap_or(!is_running_in_test_env());
415 let iroh_next_api_settings = if server_opts.enable_iroh_next {
416 Some(IrohNextApiSettings::new(server_opts.bind_api_next))
417 } else {
418 None
419 };
420
421 let settings = ConfigGenSettings {
422 p2p_bind: server_opts.bind_p2p,
423 api_bind: server_opts.bind_api,
424 ui_bind: server_opts.bind_ui,
425 p2p_url: server_opts.p2p_url.clone(),
426 api_url: server_opts.api_url.clone(),
427 enable_iroh,
428 iroh_dns: server_opts.iroh_dns.clone(),
429 iroh_relays: server_opts.iroh_relays.clone(),
430 network: server_opts.bitcoin_network,
431 available_modules: module_init_registry.kinds(),
432 default_modules: module_init_registry.default_modules(),
433 };
434
435 let db = Database::new(
436 RocksDb::build(server_opts.data_dir.join(DB_FILE))
437 .open()
438 .await
439 .map_err(anyhow::Error::from)
440 .unwrap(),
441 ModuleRegistry::default(),
442 );
443
444 let dyn_server_bitcoin_rpc = match (
445 server_opts.bitcoind_url.as_ref(),
446 server_opts.esplora_url.as_ref(),
447 ) {
448 (Some(_), None) => {
449 let bitcoind_username = server_opts
450 .bitcoind_username
451 .clone()
452 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
453 let (bitcoind_url, bitcoind_password) = server_opts
454 .get_bitcoind_url_and_password()
455 .await
456 .expect("Failed to get bitcoind url");
457 BitcoindClient::new(bitcoind_username, bitcoind_password, &bitcoind_url)
458 .unwrap()
459 .into_dyn()
460 }
461 (None, Some(url)) => EsploraClient::new(url).unwrap().into_dyn(),
462 (Some(_), Some(esplora_url)) => {
463 let bitcoind_username = server_opts
464 .bitcoind_username
465 .clone()
466 .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
467 let (bitcoind_url, bitcoind_password) = server_opts
468 .get_bitcoind_url_and_password()
469 .await
470 .expect("Failed to get bitcoind url");
471 BitcoindClientWithFallback::new(
472 bitcoind_username,
473 bitcoind_password,
474 &bitcoind_url,
475 esplora_url,
476 )
477 .await
478 .unwrap()
479 .into_dyn()
480 }
481 _ => unreachable!("ArgGroup already enforced at least one Bitcoin backend"),
482 };
483 let dyn_server_bitcoin_rpc =
484 ServerBitcoinRpcTracked::new(dyn_server_bitcoin_rpc, "server").into_dyn();
485
486 root_task_group.install_kill_handler();
487
488 install_crypto_provider().await;
489
490 let password_file = std::fs::read_to_string(server_opts.data_dir.join(PLAINTEXT_PASSWORD))
497 .ok()
498 .map(|s| s.trim().to_owned());
499
500 let auth_ui = server_opts.password_ui.or(password_file).map(ApiAuth::new);
501 let auth_api = server_opts.password_api.map(ApiAuth::new);
502
503 let task_group = root_task_group.clone();
504 let code_version_hash = code_version_hash.to_string();
505 root_task_group.spawn_cancellable("main", async move {
506 fedimint_server::run_with_iroh_p2p_relays_and_next_api(
507 server_opts.data_dir,
508 auth_ui,
509 auth_api,
510 server_opts.force_api_secrets,
511 settings,
512 db,
513 code_version_str,
514 code_version_hash,
515 module_init_registry,
516 task_group,
517 dyn_server_bitcoin_rpc,
518 Box::new(fedimint_server_ui::setup::router),
519 Box::new(fedimint_server_ui::dashboard::router),
520 server_opts.db_checkpoint_retention,
521 Duration::from_secs(server_opts.session_timeout_secs),
522 server_opts
523 .p2p_max_connection_age_secs
524 .map(Duration::from_secs),
525 fedimint_server::ConnectionLimits::new(
526 server_opts.iroh_api_max_connections,
527 server_opts.iroh_api_max_requests_per_connection,
528 ),
529 server_opts.iroh_p2p_relays,
530 iroh_next_api_settings,
531 )
532 .await
533 .unwrap_or_else(|err| panic!("Main task returned error: {}", err.fmt_compact()));
534 });
535
536 let shutdown_future = root_task_group
537 .make_handle()
538 .make_shutdown_rx()
539 .then(|()| async {
540 info!(target: LOG_CORE, "Shutdown called");
541 });
542
543 shutdown_future.await;
544
545 debug!(target: LOG_CORE, "Terminating main task");
546
547 if let Err(err) = root_task_group.join_all(Some(SHUTDOWN_TIMEOUT)).await {
548 error!(target: LOG_CORE, err = %err.fmt_compact(), "Error while shutting down task group");
549 }
550
551 debug!(target: LOG_CORE, "Shutdown complete");
552
553 fedimint_logging::shutdown();
554
555 drop(timing_total_runtime);
556
557 std::process::exit(-1);
558}
559
560pub fn default_modules() -> ServerModuleInitRegistry {
561 let mut server_gens = ServerModuleInitRegistry::new();
562
563 server_gens.attach(MintInit);
564 server_gens.attach(fedimint_mintv2_server::MintInit);
565
566 server_gens.attach(WalletInit);
567 server_gens.attach(fedimint_walletv2_server::WalletInit);
568
569 server_gens.attach(LightningInit);
570 server_gens.attach(fedimint_lnv2_server::LightningInit);
571
572 if !is_env_var_set(FM_DISABLE_META_MODULE_ENV) {
573 server_gens.attach(MetaInit);
574 }
575
576 if is_env_var_set(FM_USE_UNKNOWN_MODULE_ENV) {
577 server_gens.attach(UnknownInit);
578 }
579
580 server_gens
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn server_opts_args() -> Vec<&'static str> {
588 vec![
589 "fedimintd",
590 "--data-dir",
591 "/tmp/fedimintd-test",
592 "--bitcoind-url",
593 "http://127.0.0.1:18443",
594 "--bitcoind-username",
595 "user",
596 "--bitcoind-password",
597 "pass",
598 ]
599 }
600
601 fn parse_server_opts() -> ServerOpts {
602 ServerOpts::try_parse_from(server_opts_args()).expect("server opts should parse")
603 }
604
605 #[test]
606 fn both_bitcoin_backends_parse_together() {
607 let mut args = server_opts_args();
608 args.extend(["--esplora-url", "http://127.0.0.1:50002"]);
609 let opts = ServerOpts::try_parse_from(args).expect("both Bitcoin backends should parse");
610 assert!(opts.bitcoind_url.is_some());
611 assert!(opts.esplora_url.is_some());
612 }
613
614 fn parse_server_opts_with_enable_iroh_env(value: &str) -> ServerOpts {
615 let previous = std::env::var_os(FM_ENABLE_IROH_ENV);
616 unsafe {
619 std::env::set_var(FM_ENABLE_IROH_ENV, value);
620 }
621
622 let opts = parse_server_opts();
623
624 unsafe {
627 if let Some(previous) = previous {
628 std::env::set_var(FM_ENABLE_IROH_ENV, previous);
629 } else {
630 std::env::remove_var(FM_ENABLE_IROH_ENV);
631 }
632 }
633
634 opts
635 }
636
637 #[test]
638 fn enable_iroh_env_accepts_numeric_booleans() {
639 assert_eq!(
640 parse_server_opts_with_enable_iroh_env("1").enable_iroh,
641 Some(true)
642 );
643 assert_eq!(
644 parse_server_opts_with_enable_iroh_env("0").enable_iroh,
645 Some(false)
646 );
647 }
648
649 #[test]
650 fn iroh_next_api_defaults_to_enabled_and_accepts_false() {
651 let command = ServerOpts::command();
652 let enable_iroh_next = command
653 .get_arguments()
654 .find(|arg| arg.get_id() == "enable_iroh_next")
655 .expect("enable-iroh-next argument exists");
656 assert_eq!(enable_iroh_next.get_default_values(), ["true"]);
657
658 let mut args = server_opts_args();
659 args.push("--enable-iroh-next=false");
660 let opts = ServerOpts::try_parse_from(args).expect("explicit false should parse");
661 assert!(!opts.enable_iroh_next);
662 }
663
664 #[test]
665 fn p2p_relay_does_not_require_enable_iroh_or_change_api_relays() {
666 let opts = ServerOpts::try_parse_from([
667 "fedimintd",
668 "--data-dir",
669 "/tmp/fedimintd-test",
670 "--bitcoind-url",
671 "http://127.0.0.1:18443",
672 "--bitcoind-username",
673 "user",
674 "--bitcoind-password",
675 "pass",
676 "--iroh-p2p-relays",
677 "https://relay.example.com/",
678 ])
679 .expect("P2P relay should parse independently");
680
681 assert!(opts.iroh_relays.is_empty());
682 assert_eq!(opts.iroh_p2p_relays.len(), 1);
683 }
684}