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