1use anyhow::{Context, Result};
2use nostr::nips::nip19::{FromBech32, ToBech32};
3use nostr::{Keys, SecretKey};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::net::{IpAddr, SocketAddr};
7use std::path::Path;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11 #[serde(default)]
12 pub server: ServerConfig,
13 #[serde(default)]
14 pub storage: StorageConfig,
15 #[serde(default)]
16 pub nostr: NostrConfig,
17 #[serde(default)]
18 pub blossom: BlossomConfig,
19 #[serde(default)]
20 pub sync: SyncConfig,
21 #[serde(default)]
22 pub cashu: CashuConfig,
23 #[serde(default)]
24 pub updater: UpdaterConfig,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct UpdaterConfig {
34 #[serde(default = "default_auto_check")]
35 pub auto_check: bool,
36 #[serde(default)]
37 pub auto_install: bool,
38 #[serde(default = "default_check_interval_hours")]
39 pub check_interval_hours: u32,
40}
41
42impl Default for UpdaterConfig {
43 fn default() -> Self {
44 Self {
45 auto_check: default_auto_check(),
46 auto_install: false,
47 check_interval_hours: default_check_interval_hours(),
48 }
49 }
50}
51
52fn default_auto_check() -> bool {
53 true
54}
55
56fn default_check_interval_hours() -> u32 {
57 24
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum ServerMode {
63 #[default]
64 Normal,
65 #[serde(alias = "signal-only")]
66 Assist,
67}
68
69impl ServerMode {
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::Normal => "normal",
73 Self::Assist => "assist",
74 }
75 }
76
77 pub const fn hash_get_enabled(self) -> bool {
78 matches!(self, Self::Normal)
79 }
80
81 pub const fn background_services_enabled(self) -> bool {
82 matches!(self, Self::Normal)
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ServerConfig {
88 #[serde(default)]
89 pub mode: ServerMode,
90 #[serde(default = "default_bind_address")]
91 pub bind_address: String,
92 #[serde(default = "default_enable_auth")]
93 pub enable_auth: bool,
94 #[serde(default = "default_enable_fips")]
96 pub enable_fips: bool,
97 #[serde(default = "default_fips_discovery_scope")]
99 pub fips_discovery_scope: String,
100 #[serde(default)]
103 pub fips_open_discovery_max_pending: usize,
104 #[serde(default)]
107 pub fips_local_rendezvous_addr: Option<String>,
108 #[serde(default = "default_enable_fips_lan_discovery")]
110 pub enable_fips_lan_discovery: bool,
111 #[serde(default)]
115 pub fips_relays: Option<Vec<String>>,
116 #[serde(
119 default,
120 alias = "preconfigured_fips_peers",
121 alias = "fips_static_peers"
122 )]
123 pub fips_peers: Vec<ConfiguredFipsPeer>,
124 #[serde(default = "default_enable_fips_udp")]
126 pub enable_fips_udp: bool,
127 #[serde(default)]
129 pub fips_udp_bind_addr: Option<String>,
130 #[serde(default)]
132 pub fips_udp_public: bool,
133 #[serde(default)]
135 pub fips_udp_external_addr: Option<String>,
136 #[serde(default = "default_enable_fips_webrtc")]
138 pub enable_fips_webrtc: bool,
139 #[serde(default)]
141 pub fips_ethernet_interfaces: Vec<String>,
142 #[serde(default = "default_fetch_from_fips_peers", alias = "http_fips_fetch")]
144 pub fetch_from_fips_peers: bool,
145 #[serde(default = "default_fips_request_timeout_ms")]
147 pub fips_request_timeout_ms: u64,
148 #[serde(default = "default_public_writes")]
151 pub public_writes: bool,
152 #[serde(default = "default_public_plaintext_reads")]
155 pub public_plaintext_reads: bool,
156 #[serde(default = "default_socialgraph_snapshot_public")]
158 pub socialgraph_snapshot_public: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ConfiguredFipsPeer {
163 pub npub: String,
164 #[serde(default)]
165 pub udp_addresses: Vec<String>,
166}
167
168fn default_public_writes() -> bool {
169 true
170}
171
172fn default_public_plaintext_reads() -> bool {
173 false
174}
175
176fn default_socialgraph_snapshot_public() -> bool {
177 false
178}
179
180impl ServerConfig {
181 pub fn resolved_fips_relays(&self, active_nostr_relays: &[String]) -> Vec<String> {
182 match &self.fips_relays {
183 Some(relays) => normalize_fips_signal_relays(relays),
187 None => merge_fips_signal_relays(active_nostr_relays),
188 }
189 }
190}
191
192const DEFAULT_FIPS_SIGNAL_RELAYS: [&str; 2] = ["wss://temp.iris.to", "wss://relay.primal.net"];
193
194fn merge_fips_signal_relays(configured: &[String]) -> Vec<String> {
195 normalize_fips_signal_relays(
196 &configured
197 .iter()
198 .cloned()
199 .chain(DEFAULT_FIPS_SIGNAL_RELAYS.into_iter().map(str::to_string))
200 .collect::<Vec<_>>(),
201 )
202}
203
204fn normalize_fips_signal_relays(configured: &[String]) -> Vec<String> {
205 let mut relays = Vec::new();
206 for relay in configured {
207 let normalized = relay.trim().trim_end_matches('/').to_string();
208 if normalized.is_empty() || relays.contains(&normalized) {
209 continue;
210 }
211 relays.push(normalized);
212 }
213 relays
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct StorageConfig {
218 #[serde(default = "default_data_dir")]
219 pub data_dir: String,
220 #[serde(default = "default_max_size_gb")]
221 pub max_size_gb: u64,
222 #[serde(default = "default_storage_evict_orphans")]
223 pub evict_orphans: bool,
224 #[serde(default)]
226 pub s3: Option<S3Config>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct S3Config {
232 pub endpoint: String,
234 pub bucket: String,
236 #[serde(default)]
238 pub prefix: Option<String>,
239 #[serde(default = "default_s3_region")]
241 pub region: String,
242 #[serde(default)]
244 pub access_key: Option<String>,
245 #[serde(default)]
247 pub secret_key: Option<String>,
248 #[serde(default)]
250 pub public_url: Option<String>,
251}
252
253fn default_s3_region() -> String {
254 "auto".to_string()
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
258#[serde(rename_all = "kebab-case")]
259pub enum NostrEventTransport {
260 #[default]
261 Relay,
262 FipsLocalOnly,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct NostrConfig {
267 #[serde(default = "default_nostr_enabled")]
268 pub enabled: bool,
269 #[serde(default = "default_relays")]
270 pub relays: Vec<String>,
271 #[serde(default)]
273 pub event_transport: NostrEventTransport,
274 #[serde(default)]
276 pub allowed_npubs: Vec<String>,
277 #[serde(default)]
279 pub socialgraph_root: Option<String>,
280 #[serde(default = "default_nostr_bootstrap_follows")]
283 pub bootstrap_follows: Vec<String>,
284 #[serde(default = "default_social_graph_crawl_depth", alias = "crawl_depth")]
286 pub social_graph_crawl_depth: u32,
287 #[serde(default)]
290 pub mirror_max_follow_distance: Option<u32>,
291 #[serde(default = "default_max_write_distance")]
293 pub max_write_distance: u32,
294 #[serde(default = "default_nostr_db_max_size_gb")]
296 pub db_max_size_gb: u64,
297 #[serde(default = "default_nostr_spambox_max_size_gb")]
300 pub spambox_max_size_gb: u64,
301 #[serde(default)]
303 pub negentropy_only: bool,
304 #[serde(default = "default_nostr_overmute_threshold")]
306 pub overmute_threshold: f64,
307 #[serde(default = "default_nostr_mirror_kinds")]
309 pub mirror_kinds: Vec<u16>,
310 #[serde(default = "default_nostr_history_sync_author_chunk_size")]
313 pub history_sync_author_chunk_size: usize,
314 #[serde(default = "default_nostr_history_sync_per_author_event_limit")]
316 pub history_sync_per_author_event_limit: usize,
317 #[serde(default = "default_nostr_history_sync_on_reconnect")]
319 pub history_sync_on_reconnect: bool,
320 #[serde(default = "default_nostr_full_text_note_history_follow_distance")]
323 pub full_text_note_history_follow_distance: Option<u32>,
324 #[serde(default = "default_nostr_full_text_note_history_max_relay_pages")]
327 pub full_text_note_history_max_relay_pages: usize,
328 #[serde(default = "default_nostr_archive_history_follow_distance")]
333 pub archive_history_follow_distance: Option<u32>,
334 #[serde(default = "default_nostr_archive_history_max_relay_pages")]
337 pub archive_history_max_relay_pages: usize,
338 #[serde(default, alias = "relayless_pubsub")]
341 pub decentralized_pubsub: bool,
342 #[serde(default = "default_nostr_decentralized_pubsub_max_event_bytes")]
345 pub decentralized_pubsub_max_event_bytes: usize,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct BlossomConfig {
350 #[serde(default = "default_blossom_enabled")]
351 pub enabled: bool,
352 #[serde(default)]
354 pub servers: Vec<String>,
355 #[serde(default = "default_read_servers")]
357 pub read_servers: Vec<String>,
358 #[serde(default = "default_write_servers")]
360 pub write_servers: Vec<String>,
361 #[serde(default = "default_max_upload_mb")]
363 pub max_upload_mb: u64,
364 #[serde(default = "default_require_random_untrusted_ingest")]
366 pub require_random_untrusted_ingest: bool,
367 #[serde(default = "default_optimistic_uploads")]
370 pub optimistic_uploads: bool,
371 #[serde(
375 default,
376 alias = "write_behind_servers",
377 alias = "mirror_write_servers"
378 )]
379 pub replicate_servers: Vec<String>,
380 #[serde(default = "default_replicate_queue_mb")]
382 pub replicate_queue_mb: u64,
383}
384
385impl BlossomConfig {
386 pub fn all_read_servers(&self) -> Vec<String> {
387 if !self.enabled {
388 return Vec::new();
389 }
390 let mut servers = self.servers.clone();
391 servers.extend(self.read_servers.clone());
392 servers.extend(self.write_servers.clone());
393 if servers.is_empty() {
394 servers = default_read_servers();
395 servers.extend(default_write_servers());
396 }
397 servers.sort();
398 servers.dedup();
399 servers
400 }
401
402 pub fn upstream_read_servers(&self, bind_address: &str) -> Vec<String> {
407 let Some((bind_host, bind_port)) = parse_bind_authority(bind_address) else {
408 return self.all_read_servers();
409 };
410
411 self.all_read_servers()
412 .into_iter()
413 .filter(|server| !is_bound_http_server(server, &bind_host, bind_port))
414 .collect()
415 }
416
417 pub fn all_write_servers(&self) -> Vec<String> {
418 if !self.enabled {
419 return Vec::new();
420 }
421 let mut servers = self.servers.clone();
422 servers.extend(self.write_servers.clone());
423 if servers.is_empty() {
424 servers = default_write_servers();
425 }
426 servers.sort();
427 servers.dedup();
428 servers
429 }
430}
431
432fn parse_bind_authority(bind_address: &str) -> Option<(String, u16)> {
433 if let Ok(address) = bind_address.parse::<SocketAddr>() {
434 return Some((address.ip().to_string(), address.port()));
435 }
436
437 let url = reqwest::Url::parse(&format!("http://{bind_address}")).ok()?;
438 Some((url.host_str()?.to_string(), url.port_or_known_default()?))
439}
440
441fn is_bound_http_server(server: &str, bind_host: &str, bind_port: u16) -> bool {
442 let Ok(url) = reqwest::Url::parse(server) else {
443 return false;
444 };
445 if url.scheme() != "http" || url.port_or_known_default() != Some(bind_port) {
446 return false;
447 }
448 let Some(upstream_host) = url.host_str() else {
449 return false;
450 };
451 let upstream_host = upstream_host
452 .strip_prefix('[')
453 .and_then(|host| host.strip_suffix(']'))
454 .unwrap_or(upstream_host);
455 if upstream_host.eq_ignore_ascii_case(bind_host) {
456 return true;
457 }
458
459 let bind_ip = bind_host.parse::<IpAddr>().ok();
460 let upstream_ip = upstream_host.parse::<IpAddr>().ok();
461 let upstream_is_localhost = upstream_host.eq_ignore_ascii_case("localhost");
462 match bind_ip {
463 Some(ip) if ip.is_unspecified() => {
464 upstream_is_localhost
465 || upstream_ip
466 .is_some_and(|candidate| candidate.is_loopback() || candidate.is_unspecified())
467 }
468 Some(ip) if ip.is_loopback() => {
469 upstream_is_localhost || upstream_ip.is_some_and(|candidate| candidate.is_loopback())
470 }
471 _ => false,
472 }
473}
474
475impl NostrConfig {
476 pub fn active_relays(&self) -> Vec<String> {
477 if self.enabled && self.event_transport == NostrEventTransport::Relay {
478 self.relays.clone()
479 } else {
480 Vec::new()
481 }
482 }
483
484 pub fn decentralized_pubsub_enabled(&self) -> bool {
485 self.enabled
486 && self.decentralized_pubsub
487 && cfg!(feature = "experimental-decentralized-pubsub")
488 }
489}
490
491fn default_nostr_decentralized_pubsub_max_event_bytes() -> usize {
492 nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES
493}
494
495fn default_read_servers() -> Vec<String> {
497 let mut servers = vec![
498 "https://blossom.primal.net".to_string(),
499 "https://cdn.iris.to".to_string(),
500 ];
501 servers.sort();
502 servers
503}
504
505fn default_write_servers() -> Vec<String> {
506 vec!["https://upload.iris.to".to_string()]
507}
508
509fn default_max_upload_mb() -> u64 {
510 5
511}
512
513fn default_require_random_untrusted_ingest() -> bool {
514 true
515}
516
517fn default_optimistic_uploads() -> bool {
518 false
519}
520
521fn default_replicate_queue_mb() -> u64 {
522 256
523}
524
525fn default_nostr_enabled() -> bool {
526 true
527}
528
529fn default_blossom_enabled() -> bool {
530 true
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct SyncConfig {
535 #[serde(default = "default_sync_enabled")]
537 pub enabled: bool,
538 #[serde(default = "default_sync_own")]
540 pub sync_own: bool,
541 #[serde(default = "default_sync_followed")]
543 pub sync_followed: bool,
544 #[serde(default = "default_max_concurrent")]
546 pub max_concurrent: usize,
547 #[serde(default = "default_blossom_timeout_ms")]
549 pub blossom_timeout_ms: u64,
550}
551
552#[derive(Debug, Clone, Default, Serialize, Deserialize)]
553pub struct CashuConfig {
554 #[serde(default)]
556 pub accepted_mints: Vec<String>,
557 #[serde(default)]
559 pub default_mint: Option<String>,
560}
561
562fn default_sync_enabled() -> bool {
563 true
564}
565
566fn default_sync_own() -> bool {
567 true
568}
569
570fn default_sync_followed() -> bool {
571 true
572}
573
574fn default_max_concurrent() -> usize {
575 3
576}
577
578fn default_blossom_timeout_ms() -> u64 {
579 10000
580}
581
582fn default_social_graph_crawl_depth() -> u32 {
583 2
584}
585
586fn default_nostr_bootstrap_follows() -> Vec<String> {
587 vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
588}
589
590fn default_max_write_distance() -> u32 {
591 3
592}
593
594fn default_nostr_db_max_size_gb() -> u64 {
595 10
596}
597
598fn default_nostr_spambox_max_size_gb() -> u64 {
599 1
600}
601
602fn default_nostr_history_sync_on_reconnect() -> bool {
603 true
604}
605
606fn default_nostr_overmute_threshold() -> f64 {
607 1.0
608}
609
610fn default_nostr_mirror_kinds() -> Vec<u16> {
611 vec![
612 0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023,
613 ]
614}
615
616fn default_nostr_history_sync_author_chunk_size() -> usize {
617 5_000
618}
619
620fn default_nostr_history_sync_per_author_event_limit() -> usize {
621 256
622}
623
624fn default_nostr_full_text_note_history_follow_distance() -> Option<u32> {
625 Some(2)
626}
627
628fn default_nostr_full_text_note_history_max_relay_pages() -> usize {
629 0
630}
631
632fn default_nostr_archive_history_follow_distance() -> Option<u32> {
633 Some(2)
634}
635
636fn default_nostr_archive_history_max_relay_pages() -> usize {
637 0
638}
639
640fn default_relays() -> Vec<String> {
641 vec![
642 "wss://nos.lol".to_string(),
643 "wss://relay.snort.social".to_string(),
644 "wss://temp.iris.to".to_string(),
645 ]
646}
647
648fn default_bind_address() -> String {
649 "127.0.0.1:8080".to_string()
650}
651
652fn default_enable_auth() -> bool {
653 true
654}
655
656fn default_enable_fips() -> bool {
657 true
658}
659
660fn default_enable_fips_lan_discovery() -> bool {
661 true
662}
663
664fn default_fips_discovery_scope() -> String {
665 hashtree_fips_transport::DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
666}
667
668fn default_enable_fips_udp() -> bool {
669 true
670}
671
672fn default_enable_fips_webrtc() -> bool {
673 cfg!(feature = "fips-webrtc")
674}
675
676fn default_fetch_from_fips_peers() -> bool {
677 true
678}
679
680fn default_fips_request_timeout_ms() -> u64 {
681 5_500
682}
683
684fn default_data_dir() -> String {
685 hashtree_config::get_hashtree_dir()
686 .join("data")
687 .to_string_lossy()
688 .to_string()
689}
690
691fn default_max_size_gb() -> u64 {
692 10
693}
694
695fn default_storage_evict_orphans() -> bool {
696 true
697}
698
699impl Default for ServerConfig {
700 fn default() -> Self {
701 Self {
702 mode: ServerMode::default(),
703 bind_address: default_bind_address(),
704 enable_auth: default_enable_auth(),
705 enable_fips: default_enable_fips(),
706 fips_discovery_scope: default_fips_discovery_scope(),
707 fips_open_discovery_max_pending: 0,
708 fips_local_rendezvous_addr: None,
709 enable_fips_lan_discovery: default_enable_fips_lan_discovery(),
710 fips_relays: None,
711 fips_peers: Vec::new(),
712 enable_fips_udp: default_enable_fips_udp(),
713 fips_udp_bind_addr: None,
714 fips_udp_public: false,
715 fips_udp_external_addr: None,
716 enable_fips_webrtc: default_enable_fips_webrtc(),
717 fips_ethernet_interfaces: Vec::new(),
718 fetch_from_fips_peers: default_fetch_from_fips_peers(),
719 fips_request_timeout_ms: default_fips_request_timeout_ms(),
720 public_writes: default_public_writes(),
721 public_plaintext_reads: default_public_plaintext_reads(),
722 socialgraph_snapshot_public: default_socialgraph_snapshot_public(),
723 }
724 }
725}
726
727impl Default for StorageConfig {
728 fn default() -> Self {
729 Self {
730 data_dir: default_data_dir(),
731 max_size_gb: default_max_size_gb(),
732 evict_orphans: default_storage_evict_orphans(),
733 s3: None,
734 }
735 }
736}
737
738impl Default for NostrConfig {
739 fn default() -> Self {
740 Self {
741 enabled: default_nostr_enabled(),
742 relays: default_relays(),
743 event_transport: NostrEventTransport::default(),
744 allowed_npubs: Vec::new(),
745 socialgraph_root: None,
746 bootstrap_follows: default_nostr_bootstrap_follows(),
747 social_graph_crawl_depth: default_social_graph_crawl_depth(),
748 mirror_max_follow_distance: None,
749 max_write_distance: default_max_write_distance(),
750 db_max_size_gb: default_nostr_db_max_size_gb(),
751 spambox_max_size_gb: default_nostr_spambox_max_size_gb(),
752 negentropy_only: false,
753 overmute_threshold: default_nostr_overmute_threshold(),
754 mirror_kinds: default_nostr_mirror_kinds(),
755 history_sync_author_chunk_size: default_nostr_history_sync_author_chunk_size(),
756 history_sync_per_author_event_limit: default_nostr_history_sync_per_author_event_limit(
757 ),
758 history_sync_on_reconnect: default_nostr_history_sync_on_reconnect(),
759 full_text_note_history_follow_distance:
760 default_nostr_full_text_note_history_follow_distance(),
761 full_text_note_history_max_relay_pages:
762 default_nostr_full_text_note_history_max_relay_pages(),
763 archive_history_follow_distance: default_nostr_archive_history_follow_distance(),
764 archive_history_max_relay_pages: default_nostr_archive_history_max_relay_pages(),
765 decentralized_pubsub: false,
766 decentralized_pubsub_max_event_bytes:
767 default_nostr_decentralized_pubsub_max_event_bytes(),
768 }
769 }
770}
771
772impl Default for BlossomConfig {
773 fn default() -> Self {
774 Self {
775 enabled: default_blossom_enabled(),
776 servers: Vec::new(),
777 read_servers: default_read_servers(),
778 write_servers: default_write_servers(),
779 max_upload_mb: default_max_upload_mb(),
780 require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
781 optimistic_uploads: default_optimistic_uploads(),
782 replicate_servers: Vec::new(),
783 replicate_queue_mb: default_replicate_queue_mb(),
784 }
785 }
786}
787
788impl Default for SyncConfig {
789 fn default() -> Self {
790 Self {
791 enabled: default_sync_enabled(),
792 sync_own: default_sync_own(),
793 sync_followed: default_sync_followed(),
794 max_concurrent: default_max_concurrent(),
795 blossom_timeout_ms: default_blossom_timeout_ms(),
796 }
797 }
798}
799
800impl Config {
801 pub fn load() -> Result<Self> {
803 let config_path = get_config_path();
804
805 if config_path.exists() {
806 let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
807 toml::from_str(&content).context("Failed to parse config file")
808 } else {
809 let config = Config::default();
810 config.save()?;
811 Ok(config)
812 }
813 }
814
815 pub fn save(&self) -> Result<()> {
817 let config_path = get_config_path();
818
819 if let Some(parent) = config_path.parent() {
821 fs::create_dir_all(parent)?;
822 }
823
824 let content = toml::to_string_pretty(self)?;
825 fs::write(&config_path, content)?;
826
827 Ok(())
828 }
829}
830
831pub use hashtree_config::{get_auth_cookie_path, get_config_path, get_hashtree_dir, get_keys_path};
833
834fn read_keys_from_path(keys_path: &Path) -> Result<Keys> {
835 let content = fs::read_to_string(keys_path).context("Failed to read keys file")?;
836 let entries = hashtree_config::parse_keys_file(&content);
837 let nsec_str = entries
838 .into_iter()
839 .next()
840 .map(|e| e.secret)
841 .context("Keys file is empty")?;
842 let secret_key = SecretKey::from_bech32(&nsec_str).context("Invalid nsec format")?;
843 Ok(Keys::new(secret_key))
844}
845
846fn seed_identity_defaults_if_needed(data_dir: Option<&Path>, config: Option<&Config>) {
847 if let (Some(data_dir), Some(config)) = (data_dir, config) {
848 let _ = crate::bootstrap::seed_identity_defaults(data_dir, config);
849 }
850}
851
852fn write_keys_to_path(keys_path: &Path, keys: &Keys) -> Result<()> {
853 if let Some(parent) = keys_path.parent() {
854 fs::create_dir_all(parent)?;
855 }
856
857 let nsec = keys
858 .secret_key()
859 .to_bech32()
860 .context("Failed to encode nsec")?;
861 fs::write(keys_path, &nsec)?;
862
863 #[cfg(unix)]
864 {
865 use std::os::unix::fs::PermissionsExt;
866 let perms = fs::Permissions::from_mode(0o600);
867 fs::set_permissions(keys_path, perms)?;
868 }
869
870 Ok(())
871}
872
873pub fn ensure_auth_cookie() -> Result<(String, String)> {
875 let cookie_path = get_auth_cookie_path();
876
877 if cookie_path.exists() {
878 read_auth_cookie()
879 } else {
880 generate_auth_cookie()
881 }
882}
883
884pub fn read_auth_cookie() -> Result<(String, String)> {
886 let cookie_path = get_auth_cookie_path();
887 let content = fs::read_to_string(&cookie_path).context("Failed to read auth cookie")?;
888
889 let parts: Vec<&str> = content.trim().split(':').collect();
890 if parts.len() != 2 {
891 anyhow::bail!("Invalid auth cookie format");
892 }
893
894 Ok((parts[0].to_string(), parts[1].to_string()))
895}
896
897pub fn ensure_keys() -> Result<(Keys, bool)> {
900 let config_dir = get_hashtree_dir();
901 let config = Config::load().ok();
902 let data_dir = config
903 .as_ref()
904 .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
905 ensure_keys_in(&config_dir, data_dir, config.as_ref())
906}
907
908pub fn ensure_keys_in(
911 config_dir: &Path,
912 data_dir: Option<&Path>,
913 config: Option<&Config>,
914) -> Result<(Keys, bool)> {
915 let keys_path = config_dir.join("keys");
916
917 if keys_path.exists() {
918 Ok((read_keys_from_path(&keys_path)?, false))
919 } else {
920 let keys = generate_keys_in(config_dir, data_dir, config)?;
921 Ok((keys, true))
922 }
923}
924
925pub fn read_keys() -> Result<Keys> {
927 read_keys_in(&get_hashtree_dir())
928}
929
930pub fn read_keys_in(config_dir: &Path) -> Result<Keys> {
932 read_keys_from_path(&config_dir.join("keys"))
933}
934
935pub fn ensure_keys_string() -> Result<(String, bool)> {
938 let config_dir = get_hashtree_dir();
939 let config = Config::load().ok();
940 let data_dir = config
941 .as_ref()
942 .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
943 ensure_keys_string_in(&config_dir, data_dir, config.as_ref())
944}
945
946pub fn ensure_keys_string_in(
949 config_dir: &Path,
950 data_dir: Option<&Path>,
951 config: Option<&Config>,
952) -> Result<(String, bool)> {
953 let keys_path = config_dir.join("keys");
954
955 if keys_path.exists() {
956 let content = fs::read_to_string(&keys_path).context("Failed to read keys file")?;
957 let entries = hashtree_config::parse_keys_file(&content);
958 let nsec_str = entries
959 .into_iter()
960 .next()
961 .map(|e| e.secret)
962 .context("Keys file is empty")?;
963 Ok((nsec_str, false))
964 } else {
965 let keys = generate_keys_in(config_dir, data_dir, config)?;
966 let nsec = keys
967 .secret_key()
968 .to_bech32()
969 .context("Failed to encode nsec")?;
970 Ok((nsec, true))
971 }
972}
973
974pub fn generate_keys() -> Result<Keys> {
976 let config_dir = get_hashtree_dir();
977 let config = Config::load().ok();
978 let data_dir = config
979 .as_ref()
980 .map(|cfg| Path::new(cfg.storage.data_dir.as_str()));
981 generate_keys_in(&config_dir, data_dir, config.as_ref())
982}
983
984pub fn generate_keys_in(
987 config_dir: &Path,
988 data_dir: Option<&Path>,
989 config: Option<&Config>,
990) -> Result<Keys> {
991 let keys = Keys::generate();
992 write_keys_to_path(&config_dir.join("keys"), &keys)?;
993 seed_identity_defaults_if_needed(data_dir, config);
994 Ok(keys)
995}
996
997pub fn pubkey_bytes(keys: &Keys) -> [u8; 32] {
999 keys.public_key().to_bytes()
1000}
1001
1002pub fn parse_npub(npub: &str) -> Result<[u8; 32]> {
1004 use nostr::PublicKey;
1005 let pk = PublicKey::from_bech32(npub).context("Invalid npub format")?;
1006 Ok(pk.to_bytes())
1007}
1008
1009pub fn generate_auth_cookie() -> Result<(String, String)> {
1011 use rand::Rng;
1012
1013 let cookie_path = get_auth_cookie_path();
1014
1015 if let Some(parent) = cookie_path.parent() {
1017 fs::create_dir_all(parent)?;
1018 }
1019
1020 let mut rng = rand::thread_rng();
1022 let username = format!("htree_{}", rng.gen::<u32>());
1023 let password: String = (0..32)
1024 .map(|_| {
1025 let idx = rng.gen_range(0..62);
1026 match idx {
1027 0..=25 => (b'a' + idx) as char,
1028 26..=51 => (b'A' + (idx - 26)) as char,
1029 _ => (b'0' + (idx - 52)) as char,
1030 }
1031 })
1032 .collect();
1033
1034 let content = format!("{}:{}", username, password);
1036 fs::write(&cookie_path, content)?;
1037
1038 #[cfg(unix)]
1040 {
1041 use std::os::unix::fs::PermissionsExt;
1042 let perms = fs::Permissions::from_mode(0o600);
1043 fs::set_permissions(&cookie_path, perms)?;
1044 }
1045
1046 Ok((username, password))
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use crate::test_support::{test_env_lock, EnvVarGuard};
1053 use tempfile::TempDir;
1054
1055 #[test]
1056 fn test_config_default() {
1057 let config = Config::default();
1058 assert_eq!(config.server.bind_address, "127.0.0.1:8080");
1059 assert!(config.server.enable_auth);
1060 assert!(!config.server.public_plaintext_reads);
1061 assert_eq!(config.storage.max_size_gb, 10);
1062 assert!(config.storage.evict_orphans);
1063 assert!(config.nostr.enabled);
1064 assert!(config
1065 .nostr
1066 .relays
1067 .contains(&"wss://temp.iris.to".to_string()));
1068 assert!(config.blossom.enabled);
1069 assert!(!config.blossom.optimistic_uploads);
1070 assert!(config.blossom.replicate_servers.is_empty());
1071 assert_eq!(config.blossom.replicate_queue_mb, 256);
1072 assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1073 assert_eq!(config.nostr.mirror_max_follow_distance, None);
1074 assert_eq!(config.nostr.max_write_distance, 3);
1075 assert_eq!(config.nostr.db_max_size_gb, 10);
1076 assert_eq!(config.nostr.spambox_max_size_gb, 1);
1077 assert!(!config.nostr.negentropy_only);
1078 assert_eq!(config.nostr.overmute_threshold, 1.0);
1079 assert_eq!(
1080 config.nostr.mirror_kinds,
1081 vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1082 );
1083 assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1084 assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1085 assert!(config.nostr.history_sync_on_reconnect);
1086 assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1087 assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1088 assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1089 assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1090 assert!(config.nostr.socialgraph_root.is_none());
1091 assert_eq!(
1092 config.nostr.bootstrap_follows,
1093 vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1094 );
1095 assert!(!config.server.socialgraph_snapshot_public);
1096 assert!(config.cashu.accepted_mints.is_empty());
1097 assert!(config.cashu.default_mint.is_none());
1098 }
1099
1100 #[test]
1101 fn test_blossom_optimistic_uploads_deserialize() {
1102 let toml_str = r#"
1103[blossom]
1104optimistic_uploads = true
1105"#;
1106 let config: Config = toml::from_str(toml_str).unwrap();
1107 assert!(config.blossom.optimistic_uploads);
1108 assert!(config.blossom.require_random_untrusted_ingest);
1109 }
1110
1111 #[test]
1112 fn test_blossom_replication_deserialize() {
1113 let toml_str = r#"
1114[blossom]
1115replicate_servers = ["http://127.0.0.1:8081"]
1116replicate_queue_mb = 128
1117"#;
1118 let config: Config = toml::from_str(toml_str).unwrap();
1119 assert_eq!(config.blossom.replicate_servers, ["http://127.0.0.1:8081"]);
1120 assert_eq!(config.blossom.replicate_queue_mb, 128);
1121 }
1122
1123 #[test]
1124 fn test_server_public_plaintext_reads_deserialize() {
1125 let toml_str = r#"
1126[server]
1127public_plaintext_reads = false
1128"#;
1129 let config: Config = toml::from_str(toml_str).unwrap();
1130 assert!(!config.server.public_plaintext_reads);
1131 assert!(config.server.public_writes);
1132 }
1133
1134 #[test]
1135 fn test_nostr_config_deserialize_with_defaults() {
1136 let toml_str = r#"
1137[nostr]
1138relays = ["wss://relay.damus.io"]
1139"#;
1140 let config: Config = toml::from_str(toml_str).unwrap();
1141 assert!(config.nostr.enabled);
1142 assert_eq!(config.nostr.relays, vec!["wss://relay.damus.io"]);
1143 assert!(config.storage.evict_orphans);
1144 assert_eq!(config.nostr.social_graph_crawl_depth, 2);
1145 assert_eq!(config.nostr.mirror_max_follow_distance, None);
1146 assert_eq!(config.nostr.max_write_distance, 3);
1147 assert_eq!(config.nostr.db_max_size_gb, 10);
1148 assert_eq!(config.nostr.spambox_max_size_gb, 1);
1149 assert!(!config.nostr.negentropy_only);
1150 assert_eq!(config.nostr.overmute_threshold, 1.0);
1151 assert_eq!(
1152 config.nostr.mirror_kinds,
1153 vec![0, 1, 3, 5, 6, 7, 16, 20, 1_111, 9_735, 10_000, 30_000, 30_023]
1154 );
1155 assert_eq!(config.nostr.history_sync_author_chunk_size, 5_000);
1156 assert_eq!(config.nostr.history_sync_per_author_event_limit, 256);
1157 assert!(config.nostr.history_sync_on_reconnect);
1158 assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(2));
1159 assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 0);
1160 assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1161 assert_eq!(config.nostr.archive_history_max_relay_pages, 0);
1162 assert!(config.nostr.socialgraph_root.is_none());
1163 assert_eq!(
1164 config.nostr.bootstrap_follows,
1165 vec![hashtree_config::DEFAULT_SOCIALGRAPH_ENTRYPOINT_NPUB.to_string()]
1166 );
1167 }
1168
1169 #[test]
1170 fn test_nostr_config_deserialize_with_socialgraph() {
1171 let toml_str = r#"
1172[nostr]
1173relays = ["wss://relay.damus.io"]
1174socialgraph_root = "npub1test"
1175bootstrap_follows = []
1176social_graph_crawl_depth = 3
1177mirror_max_follow_distance = 2
1178max_write_distance = 5
1179negentropy_only = true
1180overmute_threshold = 2.5
1181mirror_kinds = [0, 10000]
1182history_sync_author_chunk_size = 250
1183history_sync_per_author_event_limit = 128
1184history_sync_on_reconnect = false
1185full_text_note_history_follow_distance = 1
1186full_text_note_history_max_relay_pages = 64
1187archive_history_follow_distance = 2
1188archive_history_max_relay_pages = 32
1189"#;
1190 let config: Config = toml::from_str(toml_str).unwrap();
1191 assert!(config.nostr.enabled);
1192 assert!(config.storage.evict_orphans);
1193 assert_eq!(config.nostr.socialgraph_root, Some("npub1test".to_string()));
1194 assert!(config.nostr.bootstrap_follows.is_empty());
1195 assert_eq!(config.nostr.social_graph_crawl_depth, 3);
1196 assert_eq!(config.nostr.mirror_max_follow_distance, Some(2));
1197 assert_eq!(config.nostr.max_write_distance, 5);
1198 assert_eq!(config.nostr.db_max_size_gb, 10);
1199 assert_eq!(config.nostr.spambox_max_size_gb, 1);
1200 assert!(config.nostr.negentropy_only);
1201 assert_eq!(config.nostr.overmute_threshold, 2.5);
1202 assert_eq!(config.nostr.mirror_kinds, vec![0, 10_000]);
1203 assert_eq!(config.nostr.history_sync_author_chunk_size, 250);
1204 assert_eq!(config.nostr.history_sync_per_author_event_limit, 128);
1205 assert!(!config.nostr.history_sync_on_reconnect);
1206 assert_eq!(config.nostr.full_text_note_history_follow_distance, Some(1));
1207 assert_eq!(config.nostr.full_text_note_history_max_relay_pages, 64);
1208 assert_eq!(config.nostr.archive_history_follow_distance, Some(2));
1209 assert_eq!(config.nostr.archive_history_max_relay_pages, 32);
1210 }
1211
1212 #[test]
1213 fn test_nostr_config_deserialize_legacy_crawl_depth_alias() {
1214 let toml_str = r#"
1215[nostr]
1216relays = ["wss://relay.damus.io"]
1217crawl_depth = 4
1218"#;
1219 let config: Config = toml::from_str(toml_str).unwrap();
1220 assert_eq!(config.nostr.social_graph_crawl_depth, 4);
1221 }
1222
1223 #[test]
1224 fn test_storage_config_disables_orphan_eviction_when_requested() {
1225 let toml_str = r#"
1226[storage]
1227evict_orphans = false
1228"#;
1229 let config: Config = toml::from_str(toml_str).unwrap();
1230 assert!(!config.storage.evict_orphans);
1231 }
1232
1233 #[test]
1234 fn test_cashu_config_keeps_wallet_mints_and_ignores_removed_paid_blob_settings() {
1235 let toml_str = r#"
1236[cashu]
1237accepted_mints = ["https://mint1.example", "http://127.0.0.1:3338"]
1238default_mint = "https://mint1.example"
1239quote_payment_offer_sat = 5
1240quote_ttl_ms = 2500
1241settlement_timeout_ms = 7000
1242mint_failure_block_threshold = 3
1243peer_suggested_mint_base_cap_sat = 4
1244peer_suggested_mint_success_step_sat = 2
1245peer_suggested_mint_receipt_step_sat = 3
1246peer_suggested_mint_max_cap_sat = 34
1247payment_default_block_threshold = 2
1248chunk_target_bytes = 65536
1249"#;
1250 let config: Config = toml::from_str(toml_str).unwrap();
1251 assert_eq!(
1252 config.cashu.accepted_mints,
1253 vec![
1254 "https://mint1.example".to_string(),
1255 "http://127.0.0.1:3338".to_string()
1256 ]
1257 );
1258 assert_eq!(
1259 config.cashu.default_mint,
1260 Some("https://mint1.example".to_string())
1261 );
1262 }
1263
1264 #[test]
1265 fn test_auth_cookie_generation() -> Result<()> {
1266 let _lock = test_env_lock().blocking_lock();
1267 let temp_dir = TempDir::new()?;
1268 let _guard = EnvVarGuard::set("HTREE_CONFIG_DIR", temp_dir.path());
1269
1270 let (username, password) = generate_auth_cookie()?;
1271
1272 assert!(username.starts_with("htree_"));
1273 assert_eq!(password.len(), 32);
1274
1275 let cookie_path = get_auth_cookie_path();
1277 assert!(cookie_path.exists());
1278
1279 let (u2, p2) = read_auth_cookie()?;
1281 assert_eq!(username, u2);
1282 assert_eq!(password, p2);
1283
1284 Ok(())
1285 }
1286
1287 #[test]
1288 fn test_blossom_read_servers_include_write_only_servers_as_fresh_fallbacks() {
1289 let config = BlossomConfig {
1290 servers: vec!["https://legacy.server".to_string()],
1291 ..BlossomConfig::default()
1292 };
1293
1294 let read = config.all_read_servers();
1295 assert!(read.contains(&"https://legacy.server".to_string()));
1296 assert!(read.contains(&"https://cdn.iris.to".to_string()));
1297 assert!(read.contains(&"https://blossom.primal.net".to_string()));
1298 assert!(read.contains(&"https://upload.iris.to".to_string()));
1299
1300 let write = config.all_write_servers();
1301 assert!(write.contains(&"https://legacy.server".to_string()));
1302 assert!(write.contains(&"https://upload.iris.to".to_string()));
1303 }
1304
1305 #[test]
1306 fn daemon_blossom_upstreams_exclude_its_own_loopback_http_endpoint() {
1307 let config = BlossomConfig {
1308 servers: Vec::new(),
1309 read_servers: vec![
1310 "http://127.0.0.1:19092".to_string(),
1311 "http://localhost:19092/".to_string(),
1312 "http://127.0.0.1:19093".to_string(),
1313 "https://127.0.0.1:19092".to_string(),
1314 "https://read.example".to_string(),
1315 ],
1316 write_servers: Vec::new(),
1317 ..BlossomConfig::default()
1318 };
1319
1320 let upstreams = config.upstream_read_servers("127.0.0.1:19092");
1321
1322 assert!(!upstreams
1323 .iter()
1324 .any(|server| server == "http://127.0.0.1:19092"));
1325 assert!(!upstreams
1326 .iter()
1327 .any(|server| server == "http://localhost:19092/"));
1328 assert!(upstreams
1329 .iter()
1330 .any(|server| server == "http://127.0.0.1:19093"));
1331 assert!(upstreams
1332 .iter()
1333 .any(|server| server == "https://127.0.0.1:19092"));
1334 assert!(upstreams
1335 .iter()
1336 .any(|server| server == "https://read.example"));
1337 }
1338
1339 #[test]
1340 fn wildcard_daemon_bind_excludes_loopback_self_but_keeps_remote_upstreams() {
1341 let config = BlossomConfig {
1342 servers: Vec::new(),
1343 read_servers: vec![
1344 "http://localhost:8080".to_string(),
1345 "http://[::1]:8080".to_string(),
1346 "http://192.0.2.10:8080".to_string(),
1347 ],
1348 write_servers: Vec::new(),
1349 ..BlossomConfig::default()
1350 };
1351
1352 let upstreams = config.upstream_read_servers("0.0.0.0:8080");
1353
1354 assert!(!upstreams
1355 .iter()
1356 .any(|server| server == "http://localhost:8080"));
1357 assert!(!upstreams.iter().any(|server| server == "http://[::1]:8080"));
1358 assert!(upstreams
1359 .iter()
1360 .any(|server| server == "http://192.0.2.10:8080"));
1361 }
1362
1363 #[test]
1364 fn test_blossom_servers_fall_back_to_defaults_when_explicitly_empty() {
1365 let config = BlossomConfig {
1366 enabled: true,
1367 servers: Vec::new(),
1368 read_servers: Vec::new(),
1369 write_servers: Vec::new(),
1370 max_upload_mb: default_max_upload_mb(),
1371 require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1372 optimistic_uploads: default_optimistic_uploads(),
1373 replicate_servers: Vec::new(),
1374 replicate_queue_mb: default_replicate_queue_mb(),
1375 };
1376
1377 let read = config.all_read_servers();
1378 let mut expected = default_read_servers();
1379 expected.extend(default_write_servers());
1380 expected.sort();
1381 expected.dedup();
1382 assert_eq!(read, expected);
1383
1384 let write = config.all_write_servers();
1385 assert_eq!(write, default_write_servers());
1386 }
1387
1388 #[test]
1389 fn test_disabled_sources_preserve_lists_but_return_no_active_endpoints() {
1390 let nostr = NostrConfig {
1391 enabled: false,
1392 relays: vec!["wss://relay.example".to_string()],
1393 ..NostrConfig::default()
1394 };
1395 assert!(nostr.active_relays().is_empty());
1396
1397 let blossom = BlossomConfig {
1398 enabled: false,
1399 servers: vec!["https://legacy.server".to_string()],
1400 read_servers: vec!["https://read.example".to_string()],
1401 write_servers: vec!["https://write.example".to_string()],
1402 max_upload_mb: default_max_upload_mb(),
1403 require_random_untrusted_ingest: default_require_random_untrusted_ingest(),
1404 optimistic_uploads: default_optimistic_uploads(),
1405 replicate_servers: Vec::new(),
1406 replicate_queue_mb: default_replicate_queue_mb(),
1407 };
1408 assert!(blossom.all_read_servers().is_empty());
1409 assert!(blossom.all_write_servers().is_empty());
1410 }
1411
1412 #[test]
1413 fn fips_local_only_event_transport_never_exposes_direct_relays() {
1414 let nostr = NostrConfig {
1415 relays: vec!["wss://must-not-open.example".to_string()],
1416 event_transport: NostrEventTransport::FipsLocalOnly,
1417 ..NostrConfig::default()
1418 };
1419
1420 assert!(nostr.active_relays().is_empty());
1421 }
1422
1423 #[test]
1424 fn nostr_decentralized_pubsub_requires_config_and_feature() {
1425 let default_nostr = NostrConfig::default();
1426 assert!(!default_nostr.decentralized_pubsub);
1427 assert!(!default_nostr.decentralized_pubsub_enabled());
1428
1429 let enabled: NostrConfig = toml::from_str("decentralized_pubsub = true")
1430 .expect("parse decentralized pubsub nostr config");
1431 assert!(enabled.decentralized_pubsub);
1432 assert_eq!(
1433 enabled.decentralized_pubsub_max_event_bytes,
1434 nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES
1435 );
1436 assert_eq!(
1437 enabled.decentralized_pubsub_enabled(),
1438 cfg!(feature = "experimental-decentralized-pubsub")
1439 );
1440
1441 let disabled: NostrConfig = toml::from_str(
1442 r#"
1443enabled = false
1444decentralized_pubsub = true
1445"#,
1446 )
1447 .expect("parse disabled decentralized pubsub nostr config");
1448 assert!(!disabled.decentralized_pubsub_enabled());
1449
1450 let alias: NostrConfig =
1451 toml::from_str("relayless_pubsub = true").expect("parse compatibility pubsub alias");
1452 assert!(alias.decentralized_pubsub);
1453
1454 let tuned: NostrConfig = toml::from_str(
1455 r#"
1456decentralized_pubsub = true
1457decentralized_pubsub_max_event_bytes = 4096
1458"#,
1459 )
1460 .expect("parse tuned decentralized pubsub config");
1461 assert_eq!(tuned.decentralized_pubsub_max_event_bytes, 4096);
1462 }
1463
1464 #[test]
1465 fn server_defaults_enable_fips_udp_and_feature_gated_webrtc() {
1466 let server = ServerConfig::default();
1467
1468 assert!(server.enable_fips);
1469 assert!(server.enable_fips_udp);
1470 assert!(server.fips_udp_bind_addr.is_none());
1471 assert!(!server.fips_udp_public);
1472 assert!(server.fips_udp_external_addr.is_none());
1473 assert_eq!(server.enable_fips_webrtc, cfg!(feature = "fips-webrtc"));
1474 assert!(server.fips_ethernet_interfaces.is_empty());
1475 assert!(server.fetch_from_fips_peers);
1476 assert!(server.fips_relays.is_none());
1477 assert!(server.fips_peers.is_empty());
1478 assert_eq!(server.fips_discovery_scope, "fips-overlay-v1");
1479 assert_eq!(server.fips_open_discovery_max_pending, 0);
1480 assert!(server.fips_local_rendezvous_addr.is_none());
1481 assert!(server.enable_fips_lan_discovery);
1482 assert_eq!(server.fips_request_timeout_ms, 5_500);
1483 }
1484
1485 #[test]
1486 fn server_config_reads_fips_overrides() {
1487 let config: Config = toml::from_str(
1488 r#"
1489[server]
1490enable_fips = true
1491fips_discovery_scope = "test-hashtree"
1492fips_open_discovery_max_pending = 32
1493fips_local_rendezvous_addr = "127.0.0.1:32111"
1494enable_fips_lan_discovery = false
1495fips_relays = ["wss://fips.example"]
1496fips_peers = [
1497 { npub = "npub1origin", udp_addresses = ["udp:192.0.2.10:2121"] },
1498 { npub = "npub1cache" },
1499]
1500enable_fips_udp = false
1501fips_udp_bind_addr = "0.0.0.0:2121"
1502fips_udp_public = true
1503fips_udp_external_addr = "198.19.77.10:2121"
1504enable_fips_webrtc = true
1505fips_ethernet_interfaces = ["eth0"]
1506fetch_from_fips_peers = false
1507fips_request_timeout_ms = 42
1508"#,
1509 )
1510 .unwrap();
1511
1512 assert!(config.server.enable_fips);
1513 assert_eq!(config.server.fips_discovery_scope, "test-hashtree");
1514 assert_eq!(config.server.fips_open_discovery_max_pending, 32);
1515 assert_eq!(
1516 config.server.fips_local_rendezvous_addr.as_deref(),
1517 Some("127.0.0.1:32111")
1518 );
1519 assert!(!config.server.enable_fips_lan_discovery);
1520 assert_eq!(
1521 config.server.fips_relays,
1522 Some(vec!["wss://fips.example".to_string()])
1523 );
1524 assert_eq!(
1525 config.server.fips_peers,
1526 [
1527 ConfiguredFipsPeer {
1528 npub: "npub1origin".to_string(),
1529 udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
1530 },
1531 ConfiguredFipsPeer {
1532 npub: "npub1cache".to_string(),
1533 udp_addresses: Vec::new(),
1534 },
1535 ]
1536 );
1537 assert!(!config.server.enable_fips_udp);
1538 assert_eq!(
1539 config.server.fips_udp_bind_addr.as_deref(),
1540 Some("0.0.0.0:2121")
1541 );
1542 assert!(config.server.fips_udp_public);
1543 assert_eq!(
1544 config.server.fips_udp_external_addr.as_deref(),
1545 Some("198.19.77.10:2121")
1546 );
1547 assert!(config.server.enable_fips_webrtc);
1548 assert_eq!(config.server.fips_ethernet_interfaces, ["eth0"]);
1549 assert!(!config.server.fetch_from_fips_peers);
1550 assert_eq!(config.server.fips_request_timeout_ms, 42);
1551 }
1552
1553 #[test]
1554 fn server_config_accepts_legacy_http_fips_fetch_name() {
1555 let config: Config = toml::from_str(
1556 r#"
1557[server]
1558http_fips_fetch = false
1559"#,
1560 )
1561 .unwrap();
1562
1563 assert!(!config.server.fetch_from_fips_peers);
1564 }
1565
1566 #[test]
1567 fn fips_relay_resolution_prefers_fips_relays_then_nostr() {
1568 let active_nostr = vec!["wss://nostr.example".to_string()];
1569 let mut server = ServerConfig::default();
1570
1571 assert_eq!(
1572 server.resolved_fips_relays(&active_nostr),
1573 [
1574 "wss://nostr.example",
1575 "wss://temp.iris.to",
1576 "wss://relay.primal.net"
1577 ]
1578 );
1579
1580 server.fips_relays = Some(vec!["wss://fips.example".to_string()]);
1581 assert_eq!(
1582 server.resolved_fips_relays(&["wss://ignored.example".to_string()]),
1583 ["wss://fips.example"]
1584 );
1585 }
1586
1587 #[test]
1588 fn explicit_fips_relay_resolution_is_exact_and_normalized() {
1589 let server = ServerConfig {
1590 fips_relays: Some(vec![
1591 "wss://temp.iris.to/".to_string(),
1592 " wss://relay.primal.net ".to_string(),
1593 "wss://temp.iris.to".to_string(),
1594 "wss://extra.example".to_string(),
1595 ]),
1596 ..ServerConfig::default()
1597 };
1598
1599 assert_eq!(
1600 server.resolved_fips_relays(&[]),
1601 [
1602 "wss://temp.iris.to",
1603 "wss://relay.primal.net",
1604 "wss://extra.example"
1605 ]
1606 );
1607 }
1608
1609 #[test]
1610 fn explicit_empty_fips_relays_disable_all_relay_discovery() {
1611 let config: Config = toml::from_str(
1612 r#"
1613[server]
1614enable_fips = true
1615enable_fips_udp = false
1616enable_fips_webrtc = false
1617fips_ethernet_interfaces = ["eth0"]
1618fips_relays = []
1619
1620[nostr]
1621relays = ["wss://must-not-open.example"]
1622event_transport = "fips-local-only"
1623"#,
1624 )
1625 .unwrap();
1626
1627 assert_eq!(config.server.fips_relays, Some(Vec::new()));
1628 assert_eq!(
1629 config.nostr.event_transport,
1630 NostrEventTransport::FipsLocalOnly
1631 );
1632 assert!(config.nostr.active_relays().is_empty());
1633 assert!(config
1634 .server
1635 .resolved_fips_relays(&["wss://must-not-open.example".to_string()])
1636 .is_empty());
1637 }
1638}