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
49pub struct RestoredGuardianConfig {
57 pub cfg: ServerConfig,
59 restore_dir: PathBuf,
61}
62
63impl RestoredGuardianConfig {
64 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 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
133pub enum ConfigGenOutcome {
139 Generated(Box<ConfigGenParams>),
140 Restored(
141 Box<RestoredGuardianConfig>,
142 oneshot::Sender<Result<(), String>>,
143 ),
144}
145
146#[derive(Debug, Clone, Default)]
148pub struct SetupState {
149 local_params: Option<LocalParams>,
151 setup_codes: BTreeSet<PeerSetupCode>,
153 restore_in_progress: bool,
155}
156
157#[derive(Clone, Debug)]
158pub struct LocalParams {
160 auth: ApiAuth,
162 tls_key: Option<Arc<rustls::pki_types::PrivateKeyDer<'static>>>,
164 iroh_api_sk: Option<iroh::SecretKey>,
166 iroh_p2p_sk: Option<iroh::SecretKey>,
168 endpoints: PeerEndpoints,
170 name: String,
172 federation_name: Option<String>,
174 disable_base_fees: Option<bool>,
176 enabled_modules: Option<BTreeSet<ModuleKind>>,
179 federation_size: Option<u32>,
182 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#[derive(Clone)]
202pub struct SetupApi {
203 settings: ConfigGenSettings,
205 state: Arc<Mutex<SetupState>>,
207 db: Database,
209 data_dir: PathBuf,
211 sender: Sender<ConfigGenOutcome>,
213 code_version_str: String,
215 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
258fn 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
333fn 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 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}