Skip to main content

fedimintd/
lib.rs

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::{FmtCompactAnyhow 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::config::ConfigGenSettings;
39use fedimint_server::config::io::{DB_FILE, PLAINTEXT_PASSWORD};
40use fedimint_server::core::ServerModuleInitRegistry;
41use fedimint_server::net::api::ApiSecrets;
42use fedimint_server_bitcoin_rpc::BitcoindClientWithFallback;
43use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
44use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
45use fedimint_server_bitcoin_rpc::tracked::ServerBitcoinRpcTracked;
46use fedimint_server_core::ServerModuleInitRegistryExt;
47use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
48use fedimint_unknown_server::UnknownInit;
49use fedimint_wallet_server::WalletInit;
50use fedimintd_envs::{
51    FM_API_URL_ENV, FM_BIND_API_ENV, FM_BIND_METRICS_ENV, FM_BIND_P2P_ENV,
52    FM_BIND_TOKIO_CONSOLE_ENV, FM_BIND_UI_ENV, FM_BITCOIN_NETWORK_ENV, FM_BITCOIND_PASSWORD_ENV,
53    FM_BITCOIND_URL_ENV, FM_BITCOIND_URL_PASSWORD_FILE_ENV, FM_BITCOIND_USERNAME_ENV,
54    FM_DATA_DIR_ENV, FM_DB_CHECKPOINT_RETENTION_ENV, FM_DISABLE_META_MODULE_ENV,
55    FM_ENABLE_IROH_ENV, FM_ESPLORA_URL_ENV, FM_FORCE_API_SECRETS_ENV,
56    FM_IROH_API_MAX_CONNECTIONS_ENV, FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, FM_P2P_URL_ENV,
57    FM_PASSWORD_API_ENV, FM_PASSWORD_UI_ENV, FM_SESSION_TIMEOUT_SECS_ENV,
58};
59use futures::FutureExt as _;
60#[cfg(all(
61    not(feature = "jemalloc"),
62    not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
63))]
64use tracing::warn;
65use tracing::{debug, error, info};
66
67use crate::metrics::APP_START_TS;
68
69/// Time we will wait before forcefully shutting down tasks
70const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
71
72#[derive(Parser)]
73#[command(version)]
74#[command(
75    group(
76        ArgGroup::new("bitcoind_password_auth")
77           .args(["bitcoind_password", "bitcoind_url_password_file"])
78           .multiple(false)
79    ),
80    group(
81        ArgGroup::new("bitcoind_auth")
82            .args(["bitcoind_url"])
83            .requires("bitcoind_password_auth")
84            .requires_all(["bitcoind_username", "bitcoind_url"])
85    ),
86    group(
87        ArgGroup::new("bitcoin_rpc")
88            .required(true)
89            .multiple(true)
90            .args(["bitcoind_url", "esplora_url"])
91    )
92)]
93struct ServerOpts {
94    /// Path to folder containing federation config files
95    #[arg(long = "data-dir", env = FM_DATA_DIR_ENV)]
96    data_dir: PathBuf,
97
98    /// Password gating the guardian admin UI on bind-ui. Optional: falls
99    /// back to reading password.private from data-dir, and if neither is
100    /// present the UI is served without a login form. Only safe when
101    /// bind-ui stays on a trusted interface (the default 127.0.0.1).
102    #[arg(long, env = FM_PASSWORD_UI_ENV)]
103    password_ui: Option<String>,
104
105    /// Password gating admin RPCs on the public API (WebSocket on bind-api
106    /// and iroh). Optional and never falls back to disk: when unset, admin
107    /// RPCs return 401 unconditionally, since the public API is always
108    /// network-reachable and must be enabled explicitly.
109    #[arg(long, env = FM_PASSWORD_API_ENV)]
110    password_api: Option<String>,
111
112    /// The bitcoin network of the federation
113    #[arg(long, env = FM_BITCOIN_NETWORK_ENV, default_value = "regtest")]
114    bitcoin_network: Network,
115
116    /// The username to use when connecting to bitcoind
117    #[arg(long, env = FM_BITCOIND_USERNAME_ENV)]
118    bitcoind_username: Option<String>,
119
120    /// The password to use when connecting to bitcoind
121    #[arg(long, env = FM_BITCOIND_PASSWORD_ENV)]
122    bitcoind_password: Option<String>,
123
124    /// Bitcoind RPC URL, e.g. <http://127.0.0.1:8332>
125    /// This should not include authentication parameters, they should be
126    /// included in `FM_BITCOIND_USERNAME` and `FM_BITCOIND_PASSWORD`
127    #[arg(long, env = FM_BITCOIND_URL_ENV)]
128    bitcoind_url: Option<SafeUrl>,
129
130    /// If set, the password part of `--bitcoind-url` will be set/replaced with
131    /// the content of this file.
132    ///
133    /// This is useful for setups that provide secret material via ramdisk
134    /// e.g. SOPS, age, etc.
135    ///
136    /// Note this is not meant to handle bitcoind's cookie file.
137    #[arg(long, env = FM_BITCOIND_URL_PASSWORD_FILE_ENV)]
138    bitcoind_url_password_file: Option<PathBuf>,
139
140    /// Esplora HTTP base URL, e.g. <https://mempool.space/api>
141    #[arg(long, env = FM_ESPLORA_URL_ENV)]
142    esplora_url: Option<SafeUrl>,
143
144    /// Address we bind to for p2p consensus communication
145    ///
146    /// Should be `0.0.0.0:8173` most of the time, as p2p connectivity is public
147    /// and direct, and the port should be open it in the firewall.
148    #[arg(long, env = FM_BIND_P2P_ENV, default_value = "0.0.0.0:8173")]
149    bind_p2p: SocketAddr,
150
151    /// Address we bind to for the API
152    ///
153    /// Should be `0.0.0.0:8174` most of the time, as api connectivity is public
154    /// and direct, and the port should be open it in the firewall.
155    #[arg(long, env = FM_BIND_API_ENV, default_value = "0.0.0.0:8174")]
156    bind_api: SocketAddr,
157
158    /// Address we bind to for exposing the Web UI
159    ///
160    /// Built-in web UI is exposed as an HTTP port, and typically should
161    /// have TLS terminated by Nginx/Traefik/etc. and forwarded to the locally
162    /// bind port.
163    #[arg(long, env = FM_BIND_UI_ENV, default_value = "127.0.0.1:8175")]
164    bind_ui: SocketAddr,
165
166    /// Our external address for communicating with our peers
167    ///
168    /// `fedimint://<fqdn>:8173` for TCP/TLS p2p connectivity (legacy/standard).
169    ///
170    /// Ignored when Iroh stack is used. (newer/experimental)
171    #[arg(long, env = FM_P2P_URL_ENV)]
172    p2p_url: Option<SafeUrl>,
173
174    /// Our API address for clients to connect to us
175    ///
176    /// Typically `wss://<fqdn>/ws/` for TCP/TLS connectivity (legacy/standard)
177    ///
178    /// Ignored when Iroh stack is used. (newer/experimental)
179    #[arg(long, env = FM_API_URL_ENV)]
180    api_url: Option<SafeUrl>,
181
182    /// Whether to use the Iroh networking stack instead of the legacy
183    /// TCP/TLS/websocket stack. Defaults to Iroh in production and to the
184    /// legacy stack in test environments (`cargo test` / `devimint`). Only
185    /// consulted at DKG / config generation time; existing federations keep the
186    /// stack baked into their config. Pass `--enable-iroh true/false` (or
187    /// `FM_ENABLE_IROH=true/false`) to override.
188    #[arg(long, env = FM_ENABLE_IROH_ENV, value_parser = BoolishValueParser::new())]
189    enable_iroh: Option<bool>,
190
191    /// Optional URL of the Iroh DNS server
192    #[arg(long, env = FM_IROH_DNS_ENV, requires = "enable_iroh")]
193    iroh_dns: Option<SafeUrl>,
194
195    /// Optional URLs of the Iroh relays to use for registering
196    #[arg(long, env = FM_IROH_RELAY_ENV, requires = "enable_iroh", value_delimiter = ',')]
197    iroh_relays: Vec<SafeUrl>,
198
199    /// Number of checkpoints from the current session to retain on disk
200    #[arg(long, env = FM_DB_CHECKPOINT_RETENTION_ENV, default_value = "1")]
201    db_checkpoint_retention: u64,
202
203    /// Exit the process if a consensus session is not completed within this
204    /// many seconds, relying on the process supervisor to restart fedimintd.
205    /// Restarting guardians has been observed to resolve stuck sessions.
206    #[arg(long, env = FM_SESSION_TIMEOUT_SECS_ENV, default_value = "3600")]
207    session_timeout_secs: u64,
208
209    /// Enable tokio console logging
210    #[arg(long, env = FM_BIND_TOKIO_CONSOLE_ENV)]
211    bind_tokio_console: Option<SocketAddr>,
212
213    /// Enable jaeger for tokio console logging
214    #[arg(long, default_value = "false")]
215    with_jaeger: bool,
216
217    /// Enable prometheus metrics
218    #[arg(long, env = FM_BIND_METRICS_ENV, default_value = "127.0.0.1:8176")]
219    bind_metrics: Option<SocketAddr>,
220
221    /// Comma separated list of API secrets.
222    ///
223    /// Setting it will enforce API authentication and make the Federation
224    /// "private".
225    ///
226    /// The first secret in the list is the "active" one that the peer will use
227    /// itself to connect to other peers. Any further one is accepted by
228    /// this peer, e.g. for the purposes of smooth rotation of secret
229    /// between users.
230    ///
231    /// Note that the value provided here will override any other settings
232    /// that the user might want to set via UI at runtime, etc.
233    /// In the future, managing secrets might be possible via Admin UI
234    /// and defaults will be provided via `FM_DEFAULT_API_SECRETS`.
235    #[arg(long, env = FM_FORCE_API_SECRETS_ENV, default_value = "")]
236    force_api_secrets: ApiSecrets,
237
238    /// Maximum number of concurrent Iroh API connections
239    #[arg(long = "iroh-api-max-connections", env = FM_IROH_API_MAX_CONNECTIONS_ENV, default_value = "1000")]
240    iroh_api_max_connections: usize,
241
242    /// Maximum number of parallel requests per Iroh API connection
243    #[arg(long = "iroh-api-max-requests-per-connection", env = FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, default_value = "50")]
244    iroh_api_max_requests_per_connection: usize,
245}
246
247impl ServerOpts {
248    pub async fn get_bitcoind_url_and_password(&self) -> anyhow::Result<(SafeUrl, String)> {
249        let url = self
250            .bitcoind_url
251            .clone()
252            .ok_or_else(|| anyhow::anyhow!("No bitcoind url set"))?;
253        if let Some(password_file) = self.bitcoind_url_password_file.as_ref() {
254            let password = tokio::fs::read_to_string(password_file)
255                .await
256                .context("Failed to read the password")?
257                .trim()
258                .to_owned();
259            Ok((url, password))
260        } else {
261            let password = self
262                .bitcoind_password
263                .clone()
264                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_PASSWORD is not");
265            Ok((url, password))
266        }
267    }
268}
269
270/// Block the thread and run a Fedimintd server
271///
272/// # Arguments
273///
274/// * `module_init_registry` - The registry of available modules.
275///
276/// * `code_version_hash` - The git hash of the code that the `fedimintd` binary
277///   is being built from. This is used mostly for information purposes
278///   (`fedimintd version-hash`). See `fedimint-build` crate for easy way to
279///   obtain it.
280///
281/// * `code_version_vendor_suffix` - An optional suffix that will be appended to
282///   the internal fedimint release version, to distinguish binaries built by
283///   different vendors, usually with a different set of modules. The suffix is
284///   informational in setup/DKG: compatibility and consensus config generation
285///   use the normalized `x.y.z` release version.
286#[allow(clippy::too_many_lines)]
287pub async fn run(
288    module_init_registry: ServerModuleInitRegistry,
289    code_version_hash: &str,
290    code_version_vendor_suffix: Option<&str>,
291) -> anyhow::Result<Infallible> {
292    assert_eq!(
293        env!("FEDIMINT_BUILD_CODE_VERSION").len(),
294        code_version_hash.len(),
295        "version_hash must have an expected length"
296    );
297
298    handle_version_hash_command(code_version_hash);
299
300    let fedimint_version = env!("CARGO_PKG_VERSION");
301
302    APP_START_TS
303        .with_label_values(&[fedimint_version, code_version_hash])
304        .set(fedimint_core::time::duration_since_epoch().as_secs() as i64);
305
306    let server_opts = {
307        // Collect env vars from all registered modules and append them to the
308        // long-help text so operators can discover them via `fedimintd --help`.
309        let mut module_env_help = String::from("\nModule environment variables:\n");
310        for (_kind, module_init) in module_init_registry.iter() {
311            for doc in module_init.get_documented_env_vars() {
312                let _ = writeln!(module_env_help, "  {:40}  {}", doc.name, doc.description);
313            }
314        }
315        let matches = ServerOpts::command()
316            .after_long_help(module_env_help)
317            .get_matches();
318        ServerOpts::from_arg_matches(&matches)
319            .expect("clap arg matches must be valid after parsing")
320    };
321
322    let mut tracing_builder = TracingSetup::default();
323
324    tracing_builder
325        .tokio_console_bind(server_opts.bind_tokio_console)
326        .with_jaeger(server_opts.with_jaeger);
327
328    tracing_builder.init().unwrap();
329
330    info!("Starting fedimintd (version: {fedimint_version} version_hash: {code_version_hash})");
331
332    #[cfg(all(
333        not(feature = "jemalloc"),
334        not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
335    ))]
336    warn!(
337        target: LOG_SERVER,
338        "fedimintd was built without the `jemalloc` feature. rocksdb is prone to memory \
339         fragmentation with the default allocator; consider rebuilding with `--features jemalloc`."
340    );
341
342    debug!(
343        target: LOG_SERVER,
344        core_consensus = %CORE_CONSENSUS_VERSION,
345        "Supported core consensus version",
346    );
347    for (kind, module) in module_init_registry.iter() {
348        let supported = module.supported_api_versions();
349        debug!(
350            target: LOG_SERVER,
351            module = %kind,
352            supported = %supported,
353            "Supported module versions",
354        );
355    }
356    let code_version_str = code_version_vendor_suffix.map_or_else(
357        || fedimint_version.to_string(),
358        |suffix| format!("{fedimint_version}+{suffix}"),
359    );
360
361    let timing_total_runtime = timing::TimeReporter::new("total-runtime").info();
362
363    let root_task_group = TaskGroup::new();
364
365    if let Some(bind_metrics) = server_opts.bind_metrics.as_ref() {
366        info!(
367            target: LOG_SERVER,
368            url = %format!("http://{}/metrics", bind_metrics),
369            "Initializing metrics server",
370        );
371        fedimint_metrics::spawn_api_server(*bind_metrics, root_task_group.clone()).await?;
372    }
373
374    let settings = ConfigGenSettings {
375        p2p_bind: server_opts.bind_p2p,
376        api_bind: server_opts.bind_api,
377        ui_bind: server_opts.bind_ui,
378        p2p_url: server_opts.p2p_url.clone(),
379        api_url: server_opts.api_url.clone(),
380        enable_iroh: server_opts.enable_iroh.unwrap_or(!is_running_in_test_env()),
381        iroh_dns: server_opts.iroh_dns.clone(),
382        iroh_relays: server_opts.iroh_relays.clone(),
383        network: server_opts.bitcoin_network,
384        available_modules: module_init_registry.kinds(),
385        default_modules: module_init_registry.default_modules(),
386    };
387
388    let db = Database::new(
389        RocksDb::build(server_opts.data_dir.join(DB_FILE))
390            .open()
391            .await
392            .unwrap(),
393        ModuleRegistry::default(),
394    );
395
396    let dyn_server_bitcoin_rpc = match (
397        server_opts.bitcoind_url.as_ref(),
398        server_opts.esplora_url.as_ref(),
399    ) {
400        (Some(_), None) => {
401            let bitcoind_username = server_opts
402                .bitcoind_username
403                .clone()
404                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
405            let (bitcoind_url, bitcoind_password) = server_opts
406                .get_bitcoind_url_and_password()
407                .await
408                .expect("Failed to get bitcoind url");
409            BitcoindClient::new(bitcoind_username, bitcoind_password, &bitcoind_url)
410                .unwrap()
411                .into_dyn()
412        }
413        (None, Some(url)) => EsploraClient::new(url).unwrap().into_dyn(),
414        (Some(_), Some(esplora_url)) => {
415            let bitcoind_username = server_opts
416                .bitcoind_username
417                .clone()
418                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
419            let (bitcoind_url, bitcoind_password) = server_opts
420                .get_bitcoind_url_and_password()
421                .await
422                .expect("Failed to get bitcoind url");
423            BitcoindClientWithFallback::new(
424                bitcoind_username,
425                bitcoind_password,
426                &bitcoind_url,
427                esplora_url,
428            )
429            .unwrap()
430            .into_dyn()
431        }
432        _ => unreachable!("ArgGroup already enforced XOR relation"),
433    };
434    let dyn_server_bitcoin_rpc =
435        ServerBitcoinRpcTracked::new(dyn_server_bitcoin_rpc, "server").into_dyn();
436
437    root_task_group.install_kill_handler();
438
439    install_crypto_provider().await;
440
441    // The UI password falls back to the legacy password.private file on disk
442    // if its env var is not set, since the UI defaults to a loopback bind.
443    // The API password never falls back: the public admin API is always
444    // network-reachable, so it must be enabled explicitly via its env var or
445    // else admin RPCs return 401. Absence of a password for a given plane opts
446    // that plane into passwordless mode (UI) or disabled admin RPCs (API).
447    let password_file = std::fs::read_to_string(server_opts.data_dir.join(PLAINTEXT_PASSWORD))
448        .ok()
449        .map(|s| s.trim().to_owned());
450
451    let auth_ui = server_opts.password_ui.or(password_file).map(ApiAuth::new);
452    let auth_api = server_opts.password_api.map(ApiAuth::new);
453
454    let task_group = root_task_group.clone();
455    let code_version_hash = code_version_hash.to_string();
456    root_task_group.spawn_cancellable("main", async move {
457        fedimint_server::run(
458            server_opts.data_dir,
459            auth_ui,
460            auth_api,
461            server_opts.force_api_secrets,
462            settings,
463            db,
464            code_version_str,
465            code_version_hash,
466            module_init_registry,
467            task_group,
468            dyn_server_bitcoin_rpc,
469            Box::new(fedimint_server_ui::setup::router),
470            Box::new(fedimint_server_ui::dashboard::router),
471            server_opts.db_checkpoint_retention,
472            Duration::from_secs(server_opts.session_timeout_secs),
473            fedimint_server::ConnectionLimits::new(
474                server_opts.iroh_api_max_connections,
475                server_opts.iroh_api_max_requests_per_connection,
476            ),
477        )
478        .await
479        .unwrap_or_else(|err| panic!("Main task returned error: {}", err.fmt_compact_anyhow()));
480    });
481
482    let shutdown_future = root_task_group
483        .make_handle()
484        .make_shutdown_rx()
485        .then(|()| async {
486            info!(target: LOG_CORE, "Shutdown called");
487        });
488
489    shutdown_future.await;
490
491    debug!(target: LOG_CORE, "Terminating main task");
492
493    if let Err(err) = root_task_group.join_all(Some(SHUTDOWN_TIMEOUT)).await {
494        error!(target: LOG_CORE, err = %err.fmt_compact_anyhow(), "Error while shutting down task group");
495    }
496
497    debug!(target: LOG_CORE, "Shutdown complete");
498
499    fedimint_logging::shutdown();
500
501    drop(timing_total_runtime);
502
503    std::process::exit(-1);
504}
505
506pub fn default_modules() -> ServerModuleInitRegistry {
507    let mut server_gens = ServerModuleInitRegistry::new();
508
509    server_gens.attach(MintInit);
510    server_gens.attach(fedimint_mintv2_server::MintInit);
511
512    server_gens.attach(WalletInit);
513    server_gens.attach(fedimint_walletv2_server::WalletInit);
514
515    server_gens.attach(LightningInit);
516    server_gens.attach(fedimint_lnv2_server::LightningInit);
517
518    if !is_env_var_set(FM_DISABLE_META_MODULE_ENV) {
519        server_gens.attach(MetaInit);
520    }
521
522    if is_env_var_set(FM_USE_UNKNOWN_MODULE_ENV) {
523        server_gens.attach(UnknownInit);
524    }
525
526    server_gens
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn parse_server_opts_with_enable_iroh_env(value: &str) -> ServerOpts {
534        let previous = std::env::var_os(FM_ENABLE_IROH_ENV);
535        // This test does not spawn threads while mutating the process
536        // environment.
537        unsafe {
538            std::env::set_var(FM_ENABLE_IROH_ENV, value);
539        }
540
541        let opts = ServerOpts::try_parse_from([
542            "fedimintd",
543            "--data-dir",
544            "/tmp/fedimintd-test",
545            "--bitcoind-url",
546            "http://127.0.0.1:18443",
547            "--bitcoind-username",
548            "user",
549            "--bitcoind-password",
550            "pass",
551        ]);
552
553        // This test does not spawn threads while mutating the process
554        // environment.
555        unsafe {
556            if let Some(previous) = previous {
557                std::env::set_var(FM_ENABLE_IROH_ENV, previous);
558            } else {
559                std::env::remove_var(FM_ENABLE_IROH_ENV);
560            }
561        }
562
563        opts.expect("server opts should parse")
564    }
565
566    #[test]
567    fn enable_iroh_env_accepts_numeric_booleans() {
568        assert_eq!(
569            parse_server_opts_with_enable_iroh_env("1").enable_iroh,
570            Some(true)
571        );
572        assert_eq!(
573            parse_server_opts_with_enable_iroh_env("0").enable_iroh,
574            Some(false)
575        );
576    }
577}