Skip to main content

fedimint_server/config/
setup.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::Read as _;
4use std::iter::once;
5use std::mem::discriminant;
6use std::path::{Component, Path, PathBuf};
7use std::str::FromStr as _;
8use std::sync::Arc;
9
10use anyhow::{Context, ensure};
11use async_trait::async_trait;
12use fedimint_core::admin_client::{SetLocalParamsRequest, SetupStatus};
13use fedimint_core::base32::FEDIMINT_PREFIX;
14use fedimint_core::config::META_FEDERATION_NAME_KEY;
15use fedimint_core::core::{ModuleInstanceId, ModuleKind};
16use fedimint_core::db::Database;
17use fedimint_core::endpoint_constants::{
18    ADD_PEER_SETUP_CODE_ENDPOINT, GET_SETUP_CODE_ENDPOINT, RESET_PEER_SETUP_CODES_ENDPOINT,
19    SET_LOCAL_PARAMS_ENDPOINT, SETUP_STATUS_ENDPOINT, START_DKG_ENDPOINT,
20};
21use fedimint_core::envs::{
22    FM_DISABLE_BASE_FEES_ENV, FM_IROH_API_SECRET_KEY_OVERRIDE_ENV,
23    FM_IROH_P2P_SECRET_KEY_OVERRIDE_ENV, is_env_var_set,
24};
25use fedimint_core::module::{
26    ApiAuth, ApiEndpoint, ApiEndpointContext, ApiError, ApiRequestErased, ApiVersion, api_endpoint,
27};
28use fedimint_core::net::auth::check_auth;
29use fedimint_core::setup_code::PeerEndpoints;
30use fedimint_core::util::{FmtCompact as _, write_new};
31use fedimint_core::{PeerId, base32, runtime};
32use fedimint_logging::LOG_CONSENSUS;
33use fedimint_server_core::setup_ui::ISetupApi;
34use iroh::SecretKey;
35use rand::rngs::OsRng;
36use tokio::sync::mpsc::Sender;
37use tokio::sync::{Mutex, oneshot};
38use tokio_rustls::rustls;
39use tracing::warn;
40
41use crate::config::io::{
42    CONSENSUS_CONFIG, ENCRYPTED_EXT, JSON_EXT, LOCAL_CONFIG, PLAINTEXT_PASSWORD, PRIVATE_CONFIG,
43    SALT_FILE, read_server_config,
44};
45use crate::config::{ConfigGenParams, ConfigGenSettings, PeerSetupCode, ServerConfig};
46use crate::net::api::HasApiContext;
47use crate::net::p2p_connector::gen_cert_and_key;
48
49/// Restored guardian configuration staged in a temporary directory.
50///
51/// Restore is intentionally split into two phases. First the uploaded backup is
52/// unpacked into `restore_dir` and parsed into `cfg` without touching the live
53/// config files. Later, after the caller performs cross-checks that need the
54/// full server context, [`RestoredGuardianConfig::install`] moves the staged
55/// files into the real data directory.
56pub struct RestoredGuardianConfig {
57    /// Parsed server config read from the staged files.
58    pub cfg: ServerConfig,
59    /// Temporary directory holding the exact files that will be installed.
60    restore_dir: PathBuf,
61}
62
63impl RestoredGuardianConfig {
64    /// Best-effort cleanup of the staged restore directory.
65    ///
66    /// This is used on validation and handoff failures before the staged files
67    /// become live config. Cleanup failures are only logged because callers are
68    /// already handling another restore error.
69    pub fn cleanup(&self) {
70        if let Err(e) = fs::remove_dir_all(&self.restore_dir) {
71            warn!(
72                target: LOG_CONSENSUS,
73                path = %self.restore_dir.display(),
74                err = %e.fmt_compact(),
75                "Failed to clean up restore directory"
76            );
77        }
78    }
79
80    /// Install the staged restored config into `data_dir`.
81    ///
82    /// This consumes `self` because after install there must be no separate
83    /// staged restore state. The method refuses to overwrite existing live
84    /// files, moves each expected file from `restore_dir` into `data_dir`,
85    /// and tries to roll back already moved files if a later move fails. On
86    /// success the temp directory is removed and the parsed
87    /// [`ServerConfig`] is returned for consensus startup.
88    pub fn install(self, data_dir: &Path) -> anyhow::Result<ServerConfig> {
89        let install_paths = [
90            PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
91            PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
92            PathBuf::from(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT),
93            PathBuf::from(SALT_FILE),
94            PathBuf::from(PLAINTEXT_PASSWORD),
95        ];
96
97        for path in &install_paths {
98            let destination = data_dir.join(path);
99            if destination.exists() {
100                self.cleanup();
101                anyhow::bail!("Refusing to overwrite existing {}", path.display());
102            }
103        }
104
105        let mut installed_paths: Vec<PathBuf> = Vec::new();
106        for path in &install_paths {
107            let destination = data_dir.join(path);
108            if let Err(e) = fs::rename(self.restore_dir.join(path), &destination) {
109                for installed_path in installed_paths.iter().rev() {
110                    if let Err(rollback_err) = fs::rename(
111                        data_dir.join(installed_path),
112                        self.restore_dir.join(installed_path),
113                    ) {
114                        warn!(
115                            target: LOG_CONSENSUS,
116                            path = %installed_path.display(),
117                            err = %rollback_err.fmt_compact(),
118                            "Failed to roll back installed path during restore failure"
119                        );
120                    }
121                }
122                self.cleanup();
123                return Err(e).with_context(|| format!("Installing restored {}", path.display()));
124            }
125            installed_paths.push(path.clone());
126        }
127
128        fs::remove_dir_all(&self.restore_dir).context("Removing restore directory")?;
129        Ok(self.cfg)
130    }
131}
132
133/// Result sent from the setup API task to the main setup driver.
134///
135/// Normal DKG sends generated params directly. Restore sends staged config plus
136/// a oneshot acknowledgement so the HTTP handler can report success only after
137/// the main setup driver validates and installs the restored files.
138pub enum ConfigGenOutcome {
139    Generated(Box<ConfigGenParams>),
140    Restored(
141        Box<RestoredGuardianConfig>,
142        oneshot::Sender<Result<(), String>>,
143    ),
144}
145
146/// State held by the API after receiving a `ConfigGenConnectionsRequest`
147#[derive(Debug, Clone, Default)]
148pub struct SetupState {
149    /// Our local connection
150    local_params: Option<LocalParams>,
151    /// Connection info received from other guardians
152    setup_codes: BTreeSet<PeerSetupCode>,
153    /// Set while a backup restore is being processed
154    restore_in_progress: bool,
155}
156
157#[derive(Clone, Debug)]
158/// Connection information sent between peers in order to start config gen
159pub struct LocalParams {
160    /// Our auth string
161    auth: ApiAuth,
162    /// Our TLS private key
163    tls_key: Option<Arc<rustls::pki_types::PrivateKeyDer<'static>>>,
164    /// Optional secret key for our iroh api endpoint
165    iroh_api_sk: Option<iroh::SecretKey>,
166    /// Optional secret key for our iroh p2p endpoint
167    iroh_p2p_sk: Option<iroh::SecretKey>,
168    /// Our api and p2p endpoint
169    endpoints: PeerEndpoints,
170    /// Name of the peer, used in TLS auth
171    name: String,
172    /// Federation name set by the leader
173    federation_name: Option<String>,
174    /// Whether to disable base fees, set by the leader
175    disable_base_fees: Option<bool>,
176    /// Modules enabled by the leader (if None, all available modules are
177    /// enabled)
178    enabled_modules: Option<BTreeSet<ModuleKind>>,
179    /// Total number of guardians (including the one who sets this), set by the
180    /// leader
181    federation_size: Option<u32>,
182    /// Bitcoin network configured locally
183    network: bitcoin::Network,
184}
185
186impl LocalParams {
187    pub fn setup_code(&self) -> PeerSetupCode {
188        PeerSetupCode {
189            name: self.name.clone(),
190            endpoints: self.endpoints.clone(),
191            federation_name: self.federation_name.clone(),
192            disable_base_fees: self.disable_base_fees,
193            enabled_modules: self.enabled_modules.clone(),
194            federation_size: self.federation_size,
195            network: self.network,
196        }
197    }
198}
199
200/// Serves the config gen API endpoints
201#[derive(Clone)]
202pub struct SetupApi {
203    /// Our config gen settings configured locally
204    settings: ConfigGenSettings,
205    /// In-memory state machine
206    state: Arc<Mutex<SetupState>>,
207    /// DB not really used
208    db: Database,
209    /// Directory containing guardian config files
210    data_dir: PathBuf,
211    /// Triggers config generation or config restore
212    sender: Sender<ConfigGenOutcome>,
213    /// Version of the running fedimintd binary
214    code_version_str: String,
215    /// Git hash of the running fedimintd binary
216    code_version_hash: String,
217}
218
219impl SetupApi {
220    pub fn new(
221        settings: ConfigGenSettings,
222        db: Database,
223        data_dir: PathBuf,
224        sender: Sender<ConfigGenOutcome>,
225        code_version_str: String,
226        code_version_hash: String,
227    ) -> Self {
228        Self {
229            settings,
230            state: Arc::new(Mutex::new(SetupState::default())),
231            db,
232            data_dir,
233            sender,
234            code_version_str,
235            code_version_hash,
236        }
237    }
238
239    pub async fn setup_status(&self) -> SetupStatus {
240        match self.state.lock().await.local_params {
241            Some(..) => SetupStatus::SharingConnectionCodes,
242            None => SetupStatus::AwaitingLocalParams,
243        }
244    }
245}
246
247fn is_expected_backup_path(path: &Path) -> bool {
248    let expected_paths = [
249        PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
250        PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
251        PathBuf::from(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT),
252        PathBuf::from(SALT_FILE),
253    ];
254
255    expected_paths.iter().any(|expected| expected == path)
256}
257
258/// Unpack a guardian backup tar into `restore_dir` and parse it.
259///
260/// This function only stages files. It validates archive paths, rejects missing
261/// or duplicate required files, verifies the password against the restored API
262/// auth, and writes the plaintext password file into the staged directory. The
263/// live data directory is not modified here.
264fn unpack_backup_to_restore_dir(
265    backup: &[u8],
266    password: &str,
267    restore_dir: &Path,
268) -> anyhow::Result<RestoredGuardianConfig> {
269    let mut archive = tar::Archive::new(backup);
270    let mut restored_paths = BTreeSet::new();
271
272    for entry in archive.entries().context("Reading backup archive")? {
273        let mut entry = entry.context("Reading backup archive entry")?;
274        let path = entry
275            .path()
276            .context("Reading backup archive entry path")?
277            .into_owned();
278        ensure!(
279            path.components()
280                .all(|component| matches!(component, Component::Normal(_))),
281            "Backup archive contains an invalid path"
282        );
283        ensure!(
284            is_expected_backup_path(&path),
285            "Backup archive contains unexpected file {}",
286            path.display()
287        );
288        ensure!(
289            entry.header().entry_type().is_file(),
290            "Backup archive contains non-file entry {}",
291            path.display()
292        );
293        ensure!(
294            restored_paths.insert(path.clone()),
295            "Backup archive contains duplicate file {}",
296            path.display()
297        );
298
299        let mut bytes = Vec::new();
300        entry
301            .read_to_end(&mut bytes)
302            .context("Reading backup archive entry contents")?;
303        write_new(restore_dir.join(&path), bytes).context("Writing restored config file")?;
304    }
305
306    for path in [
307        PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
308        PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
309        PathBuf::from(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT),
310        PathBuf::from(SALT_FILE),
311    ] {
312        ensure!(
313            restored_paths.contains(&path),
314            "Backup archive is missing {}",
315            path.display()
316        );
317    }
318
319    let cfg = read_server_config(password, restore_dir).context("Reading restored config")?;
320    ensure!(
321        cfg.private.api_auth.verify(password),
322        "The backup password does not match the restored guardian auth"
323    );
324    write_new(restore_dir.join(PLAINTEXT_PASSWORD), password)
325        .context("Writing restored password file")?;
326
327    Ok(RestoredGuardianConfig {
328        cfg,
329        restore_dir: restore_dir.to_path_buf(),
330    })
331}
332
333/// Create a fresh restore staging directory under `data_dir` and unpack into
334/// it.
335///
336/// On success the caller receives a [`RestoredGuardianConfig`] whose files are
337/// still staged in `restore.tmp`. On failure the staging directory is removed
338/// so a later restore attempt can retry cleanly.
339fn restore_backup_to_dir(
340    backup: &[u8],
341    password: &str,
342    data_dir: &Path,
343) -> anyhow::Result<RestoredGuardianConfig> {
344    let restore_dir = data_dir.join("restore.tmp");
345    if restore_dir.exists() {
346        fs::remove_dir_all(&restore_dir).context("Removing stale restore directory")?;
347    }
348    fs::create_dir(&restore_dir).context("Creating restore directory")?;
349
350    let restore_result = unpack_backup_to_restore_dir(backup, password, &restore_dir);
351
352    if restore_result.is_err()
353        && restore_dir.exists()
354        && let Err(e) = fs::remove_dir_all(&restore_dir)
355    {
356        warn!(
357            target: LOG_CONSENSUS,
358            path = %restore_dir.display(),
359            err = %e.fmt_compact(),
360            "Failed to clean up restore directory after restore failure"
361        );
362    }
363
364    restore_result
365}
366
367#[async_trait]
368impl ISetupApi for SetupApi {
369    async fn setup_code(&self) -> Option<String> {
370        self.state
371            .lock()
372            .await
373            .local_params
374            .as_ref()
375            .map(|lp| base32::encode_prefixed(FEDIMINT_PREFIX, &lp.setup_code()))
376    }
377
378    async fn guardian_name(&self) -> Option<String> {
379        self.state
380            .lock()
381            .await
382            .local_params
383            .as_ref()
384            .map(|lp| lp.name.clone())
385    }
386
387    async fn auth(&self) -> Option<ApiAuth> {
388        self.state
389            .lock()
390            .await
391            .local_params
392            .as_ref()
393            .map(|lp| lp.auth.clone())
394    }
395
396    async fn connected_peers(&self) -> Vec<String> {
397        self.state
398            .lock()
399            .await
400            .setup_codes
401            .clone()
402            .into_iter()
403            .map(|info| info.name)
404            .collect()
405    }
406
407    fn available_modules(&self) -> BTreeSet<ModuleKind> {
408        self.settings.available_modules.clone()
409    }
410
411    fn default_modules(&self) -> BTreeSet<ModuleKind> {
412        self.settings.default_modules.clone()
413    }
414
415    async fn reset_setup_codes(&self) {
416        self.state.lock().await.setup_codes.clear();
417    }
418
419    async fn set_local_parameters(
420        &self,
421        auth: ApiAuth,
422        name: String,
423        federation_name: Option<String>,
424        disable_base_fees: Option<bool>,
425        enabled_modules: Option<BTreeSet<ModuleKind>>,
426        federation_size: Option<u32>,
427    ) -> anyhow::Result<String> {
428        if let Some(existing_local_parameters) = self.state.lock().await.local_params.clone()
429            && existing_local_parameters.auth.as_str() == auth.as_str()
430            && existing_local_parameters.name == name
431            && existing_local_parameters.federation_name == federation_name
432            && existing_local_parameters.disable_base_fees == disable_base_fees
433            && existing_local_parameters.enabled_modules == enabled_modules
434            && existing_local_parameters.federation_size == federation_size
435        {
436            return Ok(base32::encode_prefixed(
437                FEDIMINT_PREFIX,
438                &existing_local_parameters.setup_code(),
439            ));
440        }
441
442        ensure!(!name.is_empty(), "The guardian name is empty");
443
444        ensure!(!auth.as_str().is_empty(), "The password is empty");
445
446        ensure!(
447            auth.as_str().trim() == auth.as_str(),
448            "The password contains leading/trailing whitespace",
449        );
450
451        if let Some(federation_name) = federation_name.as_ref() {
452            ensure!(!federation_name.is_empty(), "The federation name is empty");
453        }
454
455        if federation_name.is_some() {
456            ensure!(
457                federation_size.is_some(),
458                "The leader must set the federation size"
459            );
460        }
461
462        if let Some(size) = federation_size {
463            ensure!(
464                size == 1 || 4 <= size,
465                "Federation size must be 1 or at least 4"
466            );
467        }
468
469        let mut state = self.state.lock().await;
470
471        ensure!(
472            state.local_params.is_none(),
473            "Local parameters have already been set"
474        );
475
476        ensure!(
477            !state.restore_in_progress,
478            "A restore is already in progress"
479        );
480
481        let lp = if self.settings.enable_iroh {
482            let iroh_api_sk = if let Ok(var) = std::env::var(FM_IROH_API_SECRET_KEY_OVERRIDE_ENV) {
483                SecretKey::from_str(&var)
484                    .with_context(|| format!("Parsing {FM_IROH_API_SECRET_KEY_OVERRIDE_ENV}"))?
485            } else {
486                SecretKey::generate(&mut OsRng)
487            };
488
489            let iroh_p2p_sk = if let Ok(var) = std::env::var(FM_IROH_P2P_SECRET_KEY_OVERRIDE_ENV) {
490                SecretKey::from_str(&var)
491                    .with_context(|| format!("Parsing {FM_IROH_P2P_SECRET_KEY_OVERRIDE_ENV}"))?
492            } else {
493                SecretKey::generate(&mut OsRng)
494            };
495
496            LocalParams {
497                auth,
498                tls_key: None,
499                iroh_api_sk: Some(iroh_api_sk.clone()),
500                iroh_p2p_sk: Some(iroh_p2p_sk.clone()),
501                endpoints: PeerEndpoints::Iroh {
502                    api_pk: iroh_api_sk.public(),
503                    p2p_pk: iroh_p2p_sk.public(),
504                },
505                name,
506                federation_name,
507                disable_base_fees,
508                enabled_modules,
509                federation_size,
510                network: self.settings.network,
511            }
512        } else {
513            let (tls_cert, tls_key) =
514                gen_cert_and_key(&name).expect("Failed to generate TLS for given guardian name");
515
516            LocalParams {
517                auth,
518                tls_key: Some(tls_key),
519                iroh_api_sk: None,
520                iroh_p2p_sk: None,
521                endpoints: PeerEndpoints::Tcp {
522                    api_url: self
523                        .settings
524                        .api_url
525                        .clone()
526                        .ok_or_else(|| anyhow::format_err!("Api URL must be configured"))?,
527                    p2p_url: self
528                        .settings
529                        .p2p_url
530                        .clone()
531                        .ok_or_else(|| anyhow::format_err!("P2P URL must be configured"))?,
532
533                    cert: tls_cert.as_ref().to_vec(),
534                },
535                name,
536                federation_name,
537                disable_base_fees,
538                enabled_modules,
539                federation_size,
540                network: self.settings.network,
541            }
542        };
543
544        state.local_params = Some(lp.clone());
545
546        Ok(base32::encode_prefixed(FEDIMINT_PREFIX, &lp.setup_code()))
547    }
548
549    async fn add_peer_setup_code(&self, info: String) -> anyhow::Result<String> {
550        let info = base32::decode_prefixed(FEDIMINT_PREFIX, &info)?;
551
552        let mut state = self.state.lock().await;
553
554        if state.setup_codes.contains(&info) {
555            return Ok(info.name.clone());
556        }
557
558        ensure!(
559            !state.restore_in_progress,
560            "A restore is already in progress"
561        );
562
563        let local_params = state
564            .local_params
565            .clone()
566            .expect("The endpoint is authenticated.");
567
568        ensure!(
569            info != local_params.setup_code(),
570            "You cannot add your own setup code"
571        );
572
573        ensure!(
574            discriminant(&info.endpoints) == discriminant(&local_params.endpoints),
575            "Guardian has different endpoint variant (TCP/Iroh) than us.",
576        );
577
578        ensure!(
579            info.network == local_params.network,
580            "Guardian uses Bitcoin network {} but we use {}",
581            info.network,
582            local_params.network,
583        );
584
585        if let Some(federation_name) = state
586            .setup_codes
587            .iter()
588            .chain(once(&local_params.setup_code()))
589            .find_map(|info| info.federation_name.clone())
590        {
591            ensure!(
592                info.federation_name.is_none(),
593                "Federation name has already been set to {federation_name}"
594            );
595        }
596
597        if let Some(disable_base_fees) = state
598            .setup_codes
599            .iter()
600            .chain(once(&local_params.setup_code()))
601            .find_map(|info| info.disable_base_fees)
602        {
603            ensure!(
604                info.disable_base_fees.is_none(),
605                "Base fees setting has already been configured to disabled={disable_base_fees}"
606            );
607        }
608
609        if state
610            .setup_codes
611            .iter()
612            .chain(once(&local_params.setup_code()))
613            .any(|info| info.enabled_modules.is_some())
614        {
615            ensure!(
616                info.enabled_modules.is_none(),
617                "Enabled modules have already been configured by another guardian"
618            );
619        }
620
621        if let Some(federation_size) = state
622            .setup_codes
623            .iter()
624            .chain(once(&local_params.setup_code()))
625            .find_map(|info| info.federation_size)
626        {
627            ensure!(
628                info.federation_size.is_none(),
629                "Federation size has already been set to {federation_size}"
630            );
631        }
632
633        state.setup_codes.insert(info.clone());
634
635        Ok(info.name)
636    }
637
638    async fn start_dkg(&self) -> anyhow::Result<()> {
639        let mut state = self.state.lock().await.clone();
640
641        ensure!(
642            !state.restore_in_progress,
643            "A restore is already in progress"
644        );
645
646        let local_params = state
647            .local_params
648            .clone()
649            .expect("The endpoint is authenticated.");
650
651        let our_setup_code = local_params.setup_code();
652
653        state.setup_codes.insert(our_setup_code.clone());
654
655        ensure!(
656            state.setup_codes.len() == 1 || 4 <= state.setup_codes.len(),
657            "The number of guardians is invalid"
658        );
659
660        if let Some(federation_size) = state
661            .setup_codes
662            .iter()
663            .find_map(|info| info.federation_size)
664        {
665            ensure!(
666                state.setup_codes.len() == federation_size as usize,
667                "Expected {federation_size} guardians but got {}",
668                state.setup_codes.len()
669            );
670        }
671
672        let federation_name = state
673            .setup_codes
674            .iter()
675            .find_map(|info| info.federation_name.clone())
676            .context("We need one guardian to configure the federations name")?;
677
678        let disable_base_fees = state
679            .setup_codes
680            .iter()
681            .find_map(|info| info.disable_base_fees)
682            .unwrap_or(is_env_var_set(FM_DISABLE_BASE_FEES_ENV));
683
684        let enabled_modules = state
685            .setup_codes
686            .iter()
687            .find_map(|info| info.enabled_modules.clone())
688            .unwrap_or_else(|| self.settings.default_modules.clone());
689
690        let our_id = state
691            .setup_codes
692            .iter()
693            .position(|info| info == &our_setup_code)
694            .expect("We inserted the key above.");
695
696        let params = ConfigGenParams {
697            identity: PeerId::from(our_id as u16),
698            tls_key: local_params.tls_key,
699            iroh_api_sk: local_params.iroh_api_sk,
700            iroh_p2p_sk: local_params.iroh_p2p_sk,
701            api_auth: local_params.auth,
702            peers: (0..)
703                .map(|i| PeerId::from(i as u16))
704                .zip(state.setup_codes.clone())
705                .collect(),
706            meta: BTreeMap::from_iter(vec![(
707                META_FEDERATION_NAME_KEY.to_string(),
708                federation_name,
709            )]),
710            disable_base_fees,
711            enabled_modules,
712            network: local_params.network,
713        };
714
715        self.sender
716            .send(ConfigGenOutcome::Generated(Box::new(params)))
717            .await
718            .context("Failed to send config gen params")?;
719
720        Ok(())
721    }
722
723    async fn restore_from_backup(&self, password: String, backup: Vec<u8>) -> anyhow::Result<()> {
724        ensure!(!password.is_empty(), "The password is empty");
725        ensure!(
726            password.trim() == password,
727            "The password contains leading/trailing whitespace",
728        );
729        {
730            let mut state = self.state.lock().await;
731            ensure!(
732                state.local_params.is_none(),
733                "Local parameters have already been set"
734            );
735            ensure!(
736                !state.restore_in_progress,
737                "A restore is already in progress"
738            );
739            state.restore_in_progress = true;
740        }
741
742        let state = self.state.clone();
743        let sender = self.sender.clone();
744        let data_dir = self.data_dir.clone();
745        runtime::spawn("restore guardian backup", async move {
746            let result = async {
747                let cfg = tokio::task::spawn_blocking(move || {
748                    restore_backup_to_dir(&backup, &password, &data_dir)
749                })
750                .await
751                .context("Restore backup task panicked")??;
752                let (restore_result_sender, restore_result_receiver) = oneshot::channel();
753                let restored = ConfigGenOutcome::Restored(Box::new(cfg), restore_result_sender);
754                if let Err(e) = sender.send(restored).await {
755                    if let ConfigGenOutcome::Restored(restored, _) = e.0 {
756                        restored.cleanup();
757                    }
758                    return Err(anyhow::format_err!("Failed to send restored config"));
759                }
760                restore_result_receiver
761                    .await
762                    .context("Restore result sender dropped")?
763                    .map_err(anyhow::Error::msg)?;
764                Ok(())
765            }
766            .await;
767
768            if result.is_err() {
769                state.lock().await.restore_in_progress = false;
770            }
771            // On success, the setup task consumes the restored config and exits setup mode,
772            // so there is no setup API left that could observe or reset
773            // `restore_in_progress`.
774
775            result
776        })
777        .await
778        .context("Restore task panicked")?
779    }
780
781    async fn federation_size(&self) -> Option<u32> {
782        let state = self.state.lock().await;
783        let local_setup_code = state.local_params.as_ref().map(LocalParams::setup_code);
784        state
785            .setup_codes
786            .iter()
787            .chain(local_setup_code.iter())
788            .find_map(|info| info.federation_size)
789    }
790
791    async fn cfg_federation_name(&self) -> Option<String> {
792        let state = self.state.lock().await;
793        let local_setup_code = state.local_params.as_ref().map(LocalParams::setup_code);
794        state
795            .setup_codes
796            .iter()
797            .chain(local_setup_code.iter())
798            .find_map(|info| info.federation_name.clone())
799    }
800
801    async fn cfg_base_fees_disabled(&self) -> Option<bool> {
802        let state = self.state.lock().await;
803        let local_setup_code = state.local_params.as_ref().map(LocalParams::setup_code);
804        state
805            .setup_codes
806            .iter()
807            .chain(local_setup_code.iter())
808            .find_map(|info| info.disable_base_fees)
809    }
810
811    async fn cfg_enabled_modules(&self) -> Option<BTreeSet<ModuleKind>> {
812        let state = self.state.lock().await;
813        let local_setup_code = state.local_params.as_ref().map(LocalParams::setup_code);
814        state
815            .setup_codes
816            .iter()
817            .chain(local_setup_code.iter())
818            .find_map(|info| info.enabled_modules.clone())
819    }
820
821    async fn fedimintd_version(&self) -> String {
822        self.code_version_str.clone()
823    }
824
825    async fn fedimintd_version_hash(&self) -> Option<String> {
826        fedimint_core::version::non_zero_version_hash(&self.code_version_hash).map(str::to_owned)
827    }
828}
829
830#[async_trait]
831impl HasApiContext<SetupApi> for SetupApi {
832    async fn context(
833        &self,
834        request: &ApiRequestErased,
835        id: Option<ModuleInstanceId>,
836    ) -> (&SetupApi, ApiEndpointContext) {
837        assert!(id.is_none());
838
839        let db = self.db.clone();
840
841        let is_authenticated = match self.state.lock().await.local_params {
842            None => false,
843            Some(ref params) => match request.auth.as_ref() {
844                Some(auth) => params.auth.verify(auth.as_str()),
845                None => false,
846            },
847        };
848
849        let context = ApiEndpointContext::new(db, is_authenticated, request.auth.clone());
850
851        (self, context)
852    }
853}
854
855pub fn server_endpoints() -> Vec<ApiEndpoint<SetupApi>> {
856    vec![
857        api_endpoint! {
858            SETUP_STATUS_ENDPOINT,
859            ApiVersion::new(0, 0),
860            async |config: &SetupApi, _c, _v: ()| -> SetupStatus {
861                Ok(config.setup_status().await)
862            }
863        },
864        api_endpoint! {
865            SET_LOCAL_PARAMS_ENDPOINT,
866            ApiVersion::new(0, 0),
867            async |config: &SetupApi, context, request: SetLocalParamsRequest| -> String {
868                let auth = context
869                    .request_auth()
870                    .ok_or(ApiError::bad_request("Missing password".to_string()))?;
871
872                 config.set_local_parameters(auth, request.name, request.federation_name, request.disable_base_fees, request.enabled_modules, request.federation_size)
873                    .await
874                    .map_err(|e| ApiError::bad_request(e.to_string()))
875            }
876        },
877        api_endpoint! {
878            ADD_PEER_SETUP_CODE_ENDPOINT,
879            ApiVersion::new(0, 0),
880            async |config: &SetupApi, context, info: String| -> String {
881                check_auth(context)?;
882
883                config.add_peer_setup_code(info.clone())
884                    .await
885                    .map_err(|e|ApiError::bad_request(e.to_string()))
886            }
887        },
888        api_endpoint! {
889            RESET_PEER_SETUP_CODES_ENDPOINT,
890            ApiVersion::new(0, 0),
891            async |config: &SetupApi, context, _v: ()| -> () {
892                check_auth(context)?;
893
894                config.reset_setup_codes().await;
895
896                Ok(())
897            }
898        },
899        api_endpoint! {
900            GET_SETUP_CODE_ENDPOINT,
901            ApiVersion::new(0, 0),
902            async |config: &SetupApi, context, _request: ()| -> Option<String> {
903                check_auth(context)?;
904
905                Ok(config.setup_code().await)
906            }
907        },
908        api_endpoint! {
909            START_DKG_ENDPOINT,
910            ApiVersion::new(0, 0),
911            async |config: &SetupApi, context, _v: ()| -> () {
912                check_auth(context)?;
913
914                config.start_dkg().await.map_err(|e| ApiError::server_error(e.to_string()))
915            }
916        },
917    ]
918}
919
920#[cfg(test)]
921mod tests {
922    use std::collections::BTreeSet;
923    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
924
925    use base64::Engine as _;
926    use bitcoin::Network;
927    use fedimint_core::db::IRawDatabaseExt;
928    use fedimint_core::db::mem_impl::MemDatabase;
929    use fedimint_core::module::ApiAuth;
930    use tokio::sync::mpsc;
931
932    use super::*;
933
934    fn setup_api(network: Network) -> SetupApi {
935        let (sender, _receiver) = mpsc::channel(1);
936        let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
937
938        SetupApi::new(
939            ConfigGenSettings {
940                p2p_bind: bind,
941                api_bind: bind,
942                ui_bind: bind,
943                p2p_url: None,
944                api_url: None,
945                enable_iroh: true,
946                iroh_dns: None,
947                iroh_relays: Vec::new(),
948                network,
949                available_modules: BTreeSet::new(),
950                default_modules: BTreeSet::new(),
951            },
952            MemDatabase::new().into_database(),
953            std::env::temp_dir(),
954            sender,
955            String::new(),
956            String::new(),
957        )
958    }
959
960    const INVALID_RESTORE_BACKUP_FIXTURE_B64: &str =
961        include_str!("../test_fixtures/guardian-backup-invalid-config.tar.b64");
962
963    async fn setup_code(api: &SetupApi, name: &str) -> String {
964        api.set_local_parameters(
965            ApiAuth::new(format!("{name}-password")),
966            name.to_string(),
967            None,
968            None,
969            None,
970            None,
971        )
972        .await
973        .expect("setting local parameters should succeed")
974    }
975
976    #[tokio::test]
977    async fn accepts_peer_setup_code_with_matching_network() {
978        let api = setup_api(Network::Regtest);
979        let peer_api = setup_api(Network::Regtest);
980
981        setup_code(&api, "local").await;
982        let peer_code = setup_code(&peer_api, "peer").await;
983
984        let added_peer = api
985            .add_peer_setup_code(peer_code)
986            .await
987            .expect("peer setup code with matching network should be accepted");
988
989        assert_eq!(added_peer, "peer");
990    }
991
992    #[test]
993    fn checked_in_backup_fixture_reaches_config_validation() {
994        let tempdir = tempfile::tempdir().expect("creating temp dir should succeed");
995
996        let backup = base64::prelude::BASE64_STANDARD
997            .decode(INVALID_RESTORE_BACKUP_FIXTURE_B64.trim())
998            .expect("checked-in backup fixture base64 should decode");
999        let Err(err) = restore_backup_to_dir(&backup, "pass", tempdir.path()) else {
1000            panic!("invalid checked-in backup fixture should not restore");
1001        };
1002
1003        assert!(
1004            err.to_string().contains("Reading restored config"),
1005            "unexpected restore error: {err:#}"
1006        );
1007        assert!(
1008            !tempdir.path().join("restore.tmp").exists(),
1009            "failed restore should clean up staging dir"
1010        );
1011    }
1012
1013    #[test]
1014    fn backup_restore_rejects_non_file_entries() {
1015        let tempdir = tempfile::tempdir().expect("creating temp dir should succeed");
1016        let mut backup = Vec::new();
1017        {
1018            let mut archive = tar::Builder::new(&mut backup);
1019            let mut header = tar::Header::new_gnu();
1020            header.set_entry_type(tar::EntryType::Directory);
1021            header.set_size(0);
1022            header.set_cksum();
1023            archive
1024                .append_data(
1025                    &mut header,
1026                    PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
1027                    std::io::empty(),
1028                )
1029                .expect("writing tar entry should succeed");
1030            archive.finish().expect("finishing tar should succeed");
1031        }
1032
1033        let Err(err) = restore_backup_to_dir(&backup, "pass", tempdir.path()) else {
1034            panic!("non-file backup entries should be rejected");
1035        };
1036
1037        assert!(
1038            err.to_string().contains("non-file entry"),
1039            "unexpected restore error: {err:#}"
1040        );
1041    }
1042
1043    #[tokio::test]
1044    async fn rejects_peer_setup_code_with_different_network() {
1045        let api = setup_api(Network::Regtest);
1046        let peer_api = setup_api(Network::Signet);
1047
1048        setup_code(&api, "local").await;
1049        let peer_code = setup_code(&peer_api, "peer").await;
1050
1051        let err = api
1052            .add_peer_setup_code(peer_code)
1053            .await
1054            .expect_err("peer setup code with different network should be rejected");
1055
1056        assert!(
1057            err.to_string()
1058                .contains("Guardian uses Bitcoin network signet but we use regtest")
1059        );
1060    }
1061}