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