1use crate::config::{Config, NostrEventTransport};
7#[cfg(feature = "experimental-decentralized-pubsub")]
8use crate::nostr_relay::NostrRelay;
9use crate::storage::{HashtreeStore, StorageRouter};
10use anyhow::{Context, Result};
11use hashtree_fips_transport::{
12 bind_fips_endpoint, BoundFipsEndpoint, FipsEndpointOptions, FipsPeerConfig,
13 HashtreeFipsTransport, SameHostBlobStore, SameHostBlobStoreConfig,
14 DEFAULT_FIPS_DISCOVERY_SCOPE,
15};
16use nostr::nips::nip19::ToBech32;
17use nostr::PublicKey;
18use std::collections::HashSet;
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21#[cfg(feature = "experimental-decentralized-pubsub")]
22use tokio::sync::mpsc;
23use tokio::task::JoinHandle;
24
25pub type DaemonFipsTransport = HashtreeFipsTransport<StorageRouter>;
26type DaemonSameHostProvider = SameHostBlobStore<StorageRouter>;
27
28const DAEMON_SAME_HOST_PROVIDER_PRIORITY: i16 = 100;
29
30pub struct DaemonFipsHandle {
31 pub transport: Arc<DaemonFipsTransport>,
32 pub endpoint_npub: String,
33 pub discovery_scope: String,
34 pub nostr_provider: Option<Arc<dyn nostr_pubsub::PubsubProvider>>,
35 same_host_provider: Mutex<Option<DaemonSameHostProvider>>,
36 receiver_task: JoinHandle<()>,
37}
38
39impl DaemonFipsHandle {
40 pub fn shutdown(&self) {
41 self.receiver_task.abort();
42 if let Ok(mut provider) = self.same_host_provider.lock() {
43 provider.take();
44 }
45 }
46}
47
48#[cfg(feature = "experimental-decentralized-pubsub")]
49pub struct DaemonNostrPubsubHandle {
50 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
51 relay: Arc<NostrRelay>,
52 ingest_task: JoinHandle<()>,
53 outbound_task: JoinHandle<()>,
54}
55
56#[cfg(feature = "experimental-decentralized-pubsub")]
57impl DaemonNostrPubsubHandle {
58 pub fn shutdown(&self) {
59 self.relay.set_decentralized_pubsub_sender(None);
60 self.ingest_task.abort();
61 self.outbound_task.abort();
62 self.mesh.shutdown();
63 }
64}
65
66pub async fn start_daemon_fips_transport(
67 config: &Config,
68 keys: &nostr::Keys,
69 store: Arc<HashtreeStore>,
70 peer_ids: Vec<String>,
71) -> Result<Option<DaemonFipsHandle>> {
72 if !config.server.enable_fips || !config.server.mode.hash_get_enabled() {
73 return Ok(None);
74 }
75
76 let active_relays = config.nostr.active_relays();
77 let relays = config.server.resolved_fips_relays(&active_relays);
78 let discovery_scope = normalized_discovery_scope(&config.server.fips_discovery_scope);
79 let peer_configs = daemon_fips_peer_configs(config, peer_ids);
80 let identity_nsec = keys
81 .secret_key()
82 .to_bech32()
83 .context("Failed to encode daemon identity for FIPS endpoint")?;
84 let mut options = FipsEndpointOptions::new(identity_nsec);
85 options.discovery_scope = discovery_scope.clone();
86 options.relays = relays;
87 options.enable_udp = config.server.enable_fips_udp;
88 options.enable_webrtc = config.server.enable_fips_webrtc;
89 options.enable_local_rendezvous = true;
90 options.ethernet_interfaces = config.server.fips_ethernet_interfaces.clone();
91 options.udp_bind_addr = config.server.fips_udp_bind_addr.clone();
92 options.udp_public = config.server.fips_udp_public;
93 options.udp_external_addr = config.server.fips_udp_external_addr.clone();
94 options.webrtc_auto_connect = options.enable_webrtc && !peer_configs.is_empty();
98 options.webrtc_max_connections = hashtree_fips_transport::DEFAULT_FIPS_WEBRTC_MAX_CONNECTIONS;
99 options.open_discovery_max_pending = 0;
100 options.packet_channel_capacity = 1024;
101 let endpoint = bind_fips_endpoint(options)
102 .await
103 .context("Failed to start FIPS endpoint")?;
104
105 let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
106 let nostr_provider: Option<Arc<dyn nostr_pubsub::PubsubProvider>> =
107 if config.nostr.event_transport == NostrEventTransport::FipsLocalOnly {
108 let options = nostr_pubsub_fips::FipsPubsubClientOptions {
109 query_timeout: request_timeout,
110 ..Default::default()
111 };
112 let client = nostr_pubsub_fips::FipsPubsubClient::start(
113 endpoint.native_endpoint.clone(),
114 options,
115 )
116 .await
117 .context("Failed to start local-only FIPS Nostr pubsub provider")?;
118 Some(Arc::new(client))
119 } else {
120 None
121 };
122 let transport = Arc::new(
123 HashtreeFipsTransport::new(endpoint.endpoint.clone(), store.store_arc())
124 .with_request_timeout(request_timeout)
125 .with_cache_responses(false),
126 );
127 let same_host_provider = bind_daemon_same_host_provider(&endpoint, store.store_arc()).await?;
128 if !peer_configs.is_empty() {
129 transport.set_peer_configs(peer_configs).await;
130 }
131 let receiver_task = transport.start();
132
133 Ok(Some(DaemonFipsHandle {
134 transport,
135 endpoint_npub: endpoint.local_peer_id,
136 discovery_scope: endpoint.discovery_scope,
137 nostr_provider,
138 same_host_provider: Mutex::new(Some(same_host_provider)),
139 receiver_task,
140 }))
141}
142
143async fn bind_daemon_same_host_provider(
144 endpoint: &BoundFipsEndpoint,
145 store: Arc<StorageRouter>,
146) -> Result<DaemonSameHostProvider> {
147 SameHostBlobStore::bind(
148 endpoint.native_endpoint.clone(),
149 store,
150 None,
151 SameHostBlobStoreConfig::provider(DAEMON_SAME_HOST_PROVIDER_PRIORITY),
152 )
153 .await
154 .context("Failed to advertise htree's same-host blob provider")
155}
156
157pub async fn start_daemon_nostr_provider(
158 config: &Config,
159 fips_handle: Option<&DaemonFipsHandle>,
160) -> Result<Option<Arc<dyn nostr_pubsub::PubsubProvider>>> {
161 match config.nostr.event_transport {
162 NostrEventTransport::Relay => {
163 let relays = config.nostr.active_relays();
164 if relays.is_empty() {
165 return Ok(None);
166 }
167 let event_bus = nostr_pubsub_relay::RelayEventBus::new(
168 relays,
169 Duration::from_millis(config.server.fips_request_timeout_ms.max(1)),
170 )
171 .await
172 .context("Failed to start Nostr relay event provider")?;
173 Ok(Some(Arc::new(event_bus)))
174 }
175 NostrEventTransport::FipsLocalOnly => fips_handle
176 .and_then(|handle| handle.nostr_provider.clone())
177 .map(Some)
178 .ok_or_else(|| {
179 anyhow::anyhow!(
180 "nostr.event_transport=fips-local-only requires the local FIPS Nostr pubsub provider"
181 )
182 }),
183 }
184}
185
186#[cfg(feature = "experimental-decentralized-pubsub")]
187pub async fn start_daemon_nostr_pubsub(
188 config: &Config,
189 fips_handle: Option<&DaemonFipsHandle>,
190 store: Arc<HashtreeStore>,
191 relay: Option<Arc<NostrRelay>>,
192) -> Result<Option<Arc<DaemonNostrPubsubHandle>>> {
193 if !daemon_decentralized_pubsub_ready(config, fips_handle.is_some(), relay.is_some()) {
194 return Ok(None);
195 }
196
197 let fips_handle = fips_handle.expect("checked by daemon_decentralized_pubsub_ready");
198 let relay = relay.expect("checked by daemon_decentralized_pubsub_ready");
199 let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
200 let mesh = Arc::new(
201 fips_handle
202 .transport
203 .start_mesh_pubsub_with_options(
204 store.store_arc(),
205 fips_handle.endpoint_npub.clone(),
206 request_timeout,
207 hashtree_fips_transport::FipsMeshPubsubOptions {
208 forwarding: config.nostr.decentralized_pubsub_forwarding,
209 fanout: config.nostr.decentralized_pubsub_fanout,
210 max_hops: config.nostr.decentralized_pubsub_max_hops,
211 },
212 )
213 .await
214 .context("Failed to start decentralized Nostr pubsub over FIPS")?,
215 );
216 let _ = mesh
217 .subscribe_pubsub(crate::nostr_pubsub::NOSTR_EVENT_PUBSUB_STREAM)
218 .await;
219
220 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
221 relay.set_decentralized_pubsub_sender(Some(outbound_tx));
222 let max_event_bytes = config.nostr.decentralized_pubsub_max_event_bytes;
223 let ingest_task =
224 spawn_daemon_nostr_pubsub_ingest(Arc::clone(&mesh), Arc::clone(&relay), max_event_bytes);
225 let outbound_task =
226 spawn_daemon_nostr_pubsub_outbound(Arc::clone(&mesh), outbound_rx, max_event_bytes);
227
228 Ok(Some(Arc::new(DaemonNostrPubsubHandle {
229 mesh,
230 relay,
231 ingest_task,
232 outbound_task,
233 })))
234}
235
236#[cfg(feature = "experimental-decentralized-pubsub")]
237fn daemon_decentralized_pubsub_ready(config: &Config, has_fips: bool, has_relay: bool) -> bool {
238 config.nostr.decentralized_pubsub_enabled() && has_fips && has_relay
239}
240
241#[cfg(feature = "experimental-decentralized-pubsub")]
242fn spawn_daemon_nostr_pubsub_ingest(
243 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
244 relay: Arc<NostrRelay>,
245 max_event_bytes: usize,
246) -> JoinHandle<()> {
247 tokio::spawn(async move {
248 loop {
249 let delivery = mesh.recv_pubsub_event().await;
250 let stream_id = delivery.stream_id.clone();
251 let origin_peer_id = delivery.origin_peer_id.clone();
252 match crate::nostr_pubsub::ingest_nostr_pubsub_payload_with_limit(
253 &relay,
254 &delivery.stream_id,
255 &delivery.payload,
256 max_event_bytes,
257 )
258 .await
259 {
260 Ok(Some(event)) => {
261 tracing::debug!(
262 stream_id,
263 origin_peer_id,
264 event_id = %event.id.to_hex(),
265 "ingested decentralized Nostr pubsub event"
266 );
267 }
268 Ok(None) => {}
269 Err(err) => {
270 tracing::warn!(
271 stream_id,
272 origin_peer_id,
273 "nostr decentralized pubsub ingest failed: {err:#}"
274 );
275 }
276 }
277 }
278 })
279}
280
281#[cfg(feature = "experimental-decentralized-pubsub")]
282fn spawn_daemon_nostr_pubsub_outbound(
283 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
284 mut outbound_rx: mpsc::UnboundedReceiver<nostr::Event>,
285 max_event_bytes: usize,
286) -> JoinHandle<()> {
287 tokio::spawn(async move {
288 let mut seq = 1u64;
289 while let Some(event) = outbound_rx.recv().await {
290 let event_id = event.id.to_hex();
291 match crate::nostr_pubsub::publish_fips_nostr_event_with_limit(
292 mesh.as_ref(),
293 seq,
294 &event,
295 max_event_bytes,
296 )
297 .await
298 {
299 Ok(stats) => {
300 tracing::debug!(
301 event_id,
302 seq,
303 selected_peers = stats.selected_peers,
304 sent_peers = stats.sent_peers,
305 sent_bytes = stats.sent_bytes,
306 "published Nostr event over decentralized pubsub"
307 );
308 }
309 Err(err) => {
310 tracing::warn!(
311 event_id,
312 seq,
313 "nostr decentralized pubsub publish failed: {err:#}"
314 );
315 }
316 }
317 seq = seq.wrapping_add(1);
318 if seq == 0 {
319 seq = 1;
320 }
321 }
322 })
323}
324
325pub fn fips_peer_ids_from_pubkeys(pubkeys: Vec<[u8; 32]>) -> Vec<String> {
326 pubkeys
327 .into_iter()
328 .filter_map(|pubkey| PublicKey::from_slice(&pubkey).ok())
329 .filter_map(|pubkey| pubkey.to_bech32().ok())
330 .collect()
331}
332
333pub fn daemon_fips_peer_configs(
334 config: &Config,
335 discovered_peer_ids: Vec<String>,
336) -> Vec<FipsPeerConfig> {
337 let mut seen = HashSet::new();
338 let mut peers = Vec::new();
339
340 for peer in &config.server.fips_peers {
341 let npub = peer.npub.trim().to_string();
342 if npub.is_empty() || !seen.insert(npub.clone()) {
343 continue;
344 }
345 let udp_addresses = peer
346 .udp_addresses
347 .iter()
348 .map(|addr| addr.trim().to_string())
349 .filter(|addr| !addr.is_empty())
350 .collect();
351 peers.push(FipsPeerConfig {
352 npub,
353 udp_addresses,
354 });
355 }
356
357 for peer_id in discovered_peer_ids {
358 let npub = peer_id.trim().to_string();
359 if npub.is_empty() || !seen.insert(npub.clone()) {
360 continue;
361 }
362 peers.push(FipsPeerConfig::new(npub));
363 }
364
365 peers
366}
367
368fn normalized_discovery_scope(scope: &str) -> String {
369 let scope = scope.trim();
370 if scope.is_empty() {
371 DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
372 } else {
373 scope.to_string()
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use hashtree_core::Store;
381 use sha2::{Digest, Sha256};
382 use tokio::time::timeout;
383
384 #[test]
385 fn fips_peer_ids_from_pubkeys_encodes_npbus() {
386 let keys = nostr::Keys::generate();
387 let expected = keys.public_key().to_bech32().unwrap();
388
389 assert_eq!(
390 fips_peer_ids_from_pubkeys(vec![keys.public_key().to_bytes()]),
391 vec![expected]
392 );
393 }
394
395 #[test]
396 fn empty_discovery_scope_uses_hashtree_default() {
397 assert_eq!(
398 normalized_discovery_scope(" "),
399 DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
400 );
401 }
402
403 #[tokio::test]
404 async fn fips_local_only_event_transport_requires_local_provider() {
405 let mut config = Config::default();
406 config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
407
408 let error = start_daemon_nostr_provider(&config, None)
409 .await
410 .err()
411 .expect("missing local FIPS provider must fail");
412
413 assert!(error.to_string().contains("fips-local-only"));
414 assert!(error.to_string().contains("requires"));
415 }
416
417 #[tokio::test]
418 async fn daemon_same_host_provider_advertises_and_serves_storage_router() {
419 let provider_endpoint = local_only_endpoint("htree-provider-test").await;
420 let observer_endpoint = local_only_endpoint("iris-drive-observer-test").await;
421 let temp = tempfile::tempdir().unwrap();
422 let store = Arc::new(HashtreeStore::new(temp.path()).unwrap());
423 let data = b"served directly from htree StorageRouter".to_vec();
424 let hash = Sha256::digest(&data).into();
425 store.store_arc().put(hash, data.clone()).await.unwrap();
426 let provider = bind_daemon_same_host_provider(&provider_endpoint, store.store_arc())
427 .await
428 .expect("bind htree provider");
429 let transport = Arc::new(HashtreeFipsTransport::new(
430 provider_endpoint.endpoint.clone(),
431 store.store_arc(),
432 ));
433 let receiver_task = transport.start();
434 let handle = DaemonFipsHandle {
435 transport,
436 endpoint_npub: provider_endpoint.local_peer_id.clone(),
437 discovery_scope: provider_endpoint.discovery_scope.clone(),
438 nostr_provider: None,
439 same_host_provider: Mutex::new(Some(provider)),
440 receiver_task,
441 };
442
443 timeout(Duration::from_secs(10), async {
444 loop {
445 if provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
446 break;
447 }
448 tokio::time::sleep(Duration::from_millis(20)).await;
449 }
450 })
451 .await
452 .expect("htree provider capability did not converge");
453
454 let requester = hashtree_fips_transport::SameHostBlobStore::bind(
455 observer_endpoint.native_endpoint.clone(),
456 Arc::new(hashtree_core::MemoryStore::new()),
457 None,
458 hashtree_fips_transport::SameHostBlobStoreConfig::default(),
459 )
460 .await
461 .unwrap();
462 assert_eq!(requester.get(&hash).await.unwrap(), Some(data));
463
464 drop(requester);
465 handle.shutdown();
466 timeout(Duration::from_secs(10), async {
467 while provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
468 tokio::time::sleep(Duration::from_millis(20)).await;
469 }
470 })
471 .await
472 .expect("htree provider capability survived daemon shutdown");
473 drop(handle);
474 observer_endpoint.native_endpoint.shutdown().await.unwrap();
475 provider_endpoint.native_endpoint.shutdown().await.unwrap();
476 }
477
478 fn provider_advertised(endpoint: &BoundFipsEndpoint, npub: &str) -> bool {
479 endpoint
480 .native_endpoint
481 .local_instance_advertisements()
482 .unwrap()
483 .iter()
484 .any(|advert| {
485 advert.npub == npub
486 && advert
487 .capability(hashtree_fips_transport::TCP_BLOB_CAPABILITY)
488 .and_then(|capability| capability.fsp_port)
489 == Some(hashtree_fips_transport::TCP_BLOB_SERVICE_PORT)
490 })
491 }
492
493 async fn local_only_endpoint(scope: &str) -> hashtree_fips_transport::BoundFipsEndpoint {
494 let keys = nostr::Keys::generate();
495 let mut options = FipsEndpointOptions::new(keys.secret_key().to_bech32().unwrap());
496 options.discovery_scope = scope.to_string();
497 options.enable_udp = false;
498 options.enable_webrtc = false;
499 options.enable_local_rendezvous = true;
500 options.enable_lan_discovery = false;
501 options.share_local_candidates = false;
502 bind_fips_endpoint(options).await.unwrap()
503 }
504
505 #[test]
506 fn daemon_fips_peer_configs_prefer_configured_peers() {
507 let mut config = Config::default();
508 config.server.fips_peers = vec![
509 crate::config::ConfiguredFipsPeer {
510 npub: " origin ".to_string(),
511 udp_addresses: vec![" udp:192.0.2.10:2121 ".to_string(), " ".to_string()],
512 },
513 crate::config::ConfiguredFipsPeer {
514 npub: "origin".to_string(),
515 udp_addresses: vec!["udp:ignored:2121".to_string()],
516 },
517 ];
518
519 let peers = daemon_fips_peer_configs(
520 &config,
521 vec![
522 "origin".to_string(),
523 " followed ".to_string(),
524 " ".to_string(),
525 ],
526 );
527
528 assert_eq!(
529 peers,
530 vec![
531 FipsPeerConfig {
532 npub: "origin".to_string(),
533 udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
534 },
535 FipsPeerConfig::new("followed"),
536 ]
537 );
538 }
539
540 #[cfg(feature = "experimental-decentralized-pubsub")]
541 #[test]
542 fn daemon_decentralized_pubsub_requires_config_fips_and_relay() {
543 let mut config = Config::default();
544 assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
545
546 config.nostr.decentralized_pubsub = true;
547 assert!(daemon_decentralized_pubsub_ready(&config, true, true));
548 assert!(!daemon_decentralized_pubsub_ready(&config, false, true));
549 assert!(!daemon_decentralized_pubsub_ready(&config, true, false));
550
551 config.nostr.enabled = false;
552 assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
553 }
554}