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