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, FipsEndpointOptions, FipsPeerConfig, HashtreeFipsTransport,
13 DEFAULT_FIPS_DISCOVERY_SCOPE,
14};
15use nostr::nips::nip19::ToBech32;
16use nostr::PublicKey;
17use std::collections::HashSet;
18use std::sync::Arc;
19use std::time::Duration;
20#[cfg(feature = "experimental-decentralized-pubsub")]
21use tokio::sync::mpsc;
22use tokio::task::JoinHandle;
23
24pub type DaemonFipsTransport = HashtreeFipsTransport<StorageRouter>;
25
26pub struct DaemonFipsHandle {
27 pub transport: Arc<DaemonFipsTransport>,
28 pub endpoint_npub: String,
29 pub discovery_scope: String,
30 pub nostr_provider: Option<Arc<dyn nostr_pubsub::PubsubProvider>>,
31 receiver_task: JoinHandle<()>,
32}
33
34impl DaemonFipsHandle {
35 pub fn shutdown(&self) {
36 self.receiver_task.abort();
37 }
38}
39
40#[cfg(feature = "experimental-decentralized-pubsub")]
41pub struct DaemonNostrPubsubHandle {
42 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
43 relay: Arc<NostrRelay>,
44 ingest_task: JoinHandle<()>,
45 outbound_task: JoinHandle<()>,
46}
47
48#[cfg(feature = "experimental-decentralized-pubsub")]
49impl DaemonNostrPubsubHandle {
50 pub fn shutdown(&self) {
51 self.relay.set_decentralized_pubsub_sender(None);
52 self.ingest_task.abort();
53 self.outbound_task.abort();
54 self.mesh.shutdown();
55 }
56}
57
58pub async fn start_daemon_fips_transport(
59 config: &Config,
60 keys: &nostr::Keys,
61 store: Arc<HashtreeStore>,
62 peer_ids: Vec<String>,
63) -> Result<Option<DaemonFipsHandle>> {
64 if !config.server.enable_fips || !config.server.mode.hash_get_enabled() {
65 return Ok(None);
66 }
67
68 let active_relays = config.nostr.active_relays();
69 let relays = config.server.resolved_fips_relays(&active_relays);
70 let discovery_scope = normalized_discovery_scope(&config.server.fips_discovery_scope);
71 let peer_configs = daemon_fips_peer_configs(config, peer_ids);
72 let identity_nsec = keys
73 .secret_key()
74 .to_bech32()
75 .context("Failed to encode daemon identity for FIPS endpoint")?;
76 let mut options = FipsEndpointOptions::new(identity_nsec);
77 options.discovery_scope = discovery_scope.clone();
78 options.relays = relays;
79 options.enable_udp = config.server.enable_fips_udp;
80 options.enable_webrtc = config.server.enable_fips_webrtc;
81 options.ethernet_interfaces = config.server.fips_ethernet_interfaces.clone();
82 options.udp_bind_addr = config.server.fips_udp_bind_addr.clone();
83 options.udp_public = config.server.fips_udp_public;
84 options.udp_external_addr = config.server.fips_udp_external_addr.clone();
85 options.webrtc_auto_connect = options.enable_webrtc && !peer_configs.is_empty();
89 options.webrtc_max_connections = hashtree_fips_transport::DEFAULT_FIPS_WEBRTC_MAX_CONNECTIONS;
90 options.open_discovery_max_pending = 0;
91 options.packet_channel_capacity = 1024;
92 let endpoint = bind_fips_endpoint(options)
93 .await
94 .context("Failed to start FIPS endpoint")?;
95
96 let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
97 let nostr_provider: Option<Arc<dyn nostr_pubsub::PubsubProvider>> =
98 if config.nostr.event_transport == NostrEventTransport::FipsLocalOnly {
99 let options = nostr_pubsub_fips::FipsPubsubClientOptions {
100 query_timeout: request_timeout,
101 ..Default::default()
102 };
103 let client = nostr_pubsub_fips::FipsPubsubClient::start(
104 endpoint.native_endpoint.clone(),
105 options,
106 )
107 .await
108 .context("Failed to start local-only FIPS Nostr pubsub provider")?;
109 Some(Arc::new(client))
110 } else {
111 None
112 };
113 let transport = Arc::new(
114 HashtreeFipsTransport::new(endpoint.endpoint, store.store_arc())
115 .with_request_timeout(request_timeout)
116 .with_cache_responses(false),
117 );
118 if !peer_configs.is_empty() {
119 transport.set_peer_configs(peer_configs).await;
120 }
121 let receiver_task = transport.start();
122
123 Ok(Some(DaemonFipsHandle {
124 transport,
125 endpoint_npub: endpoint.local_peer_id,
126 discovery_scope: endpoint.discovery_scope,
127 nostr_provider,
128 receiver_task,
129 }))
130}
131
132pub async fn start_daemon_nostr_provider(
133 config: &Config,
134 fips_handle: Option<&DaemonFipsHandle>,
135) -> Result<Option<Arc<dyn nostr_pubsub::PubsubProvider>>> {
136 match config.nostr.event_transport {
137 NostrEventTransport::Relay => {
138 let relays = config.nostr.active_relays();
139 if relays.is_empty() {
140 return Ok(None);
141 }
142 let event_bus = nostr_pubsub_relay::RelayEventBus::new(
143 relays,
144 Duration::from_millis(config.server.fips_request_timeout_ms.max(1)),
145 )
146 .await
147 .context("Failed to start Nostr relay event provider")?;
148 Ok(Some(Arc::new(event_bus)))
149 }
150 NostrEventTransport::FipsLocalOnly => fips_handle
151 .and_then(|handle| handle.nostr_provider.clone())
152 .map(Some)
153 .ok_or_else(|| {
154 anyhow::anyhow!(
155 "nostr.event_transport=fips-local-only requires the local FIPS Nostr pubsub provider"
156 )
157 }),
158 }
159}
160
161#[cfg(feature = "experimental-decentralized-pubsub")]
162pub async fn start_daemon_nostr_pubsub(
163 config: &Config,
164 fips_handle: Option<&DaemonFipsHandle>,
165 store: Arc<HashtreeStore>,
166 relay: Option<Arc<NostrRelay>>,
167) -> Result<Option<Arc<DaemonNostrPubsubHandle>>> {
168 if !daemon_decentralized_pubsub_ready(config, fips_handle.is_some(), relay.is_some()) {
169 return Ok(None);
170 }
171
172 let fips_handle = fips_handle.expect("checked by daemon_decentralized_pubsub_ready");
173 let relay = relay.expect("checked by daemon_decentralized_pubsub_ready");
174 let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
175 let mesh = Arc::new(
176 fips_handle
177 .transport
178 .start_mesh_pubsub_with_options(
179 store.store_arc(),
180 fips_handle.endpoint_npub.clone(),
181 request_timeout,
182 hashtree_fips_transport::FipsMeshPubsubOptions {
183 forwarding: config.nostr.decentralized_pubsub_forwarding,
184 fanout: config.nostr.decentralized_pubsub_fanout,
185 max_hops: config.nostr.decentralized_pubsub_max_hops,
186 },
187 )
188 .await
189 .context("Failed to start decentralized Nostr pubsub over FIPS")?,
190 );
191 let _ = mesh
192 .subscribe_pubsub(crate::nostr_pubsub::NOSTR_EVENT_PUBSUB_STREAM)
193 .await;
194
195 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
196 relay.set_decentralized_pubsub_sender(Some(outbound_tx));
197 let max_event_bytes = config.nostr.decentralized_pubsub_max_event_bytes;
198 let ingest_task =
199 spawn_daemon_nostr_pubsub_ingest(Arc::clone(&mesh), Arc::clone(&relay), max_event_bytes);
200 let outbound_task =
201 spawn_daemon_nostr_pubsub_outbound(Arc::clone(&mesh), outbound_rx, max_event_bytes);
202
203 Ok(Some(Arc::new(DaemonNostrPubsubHandle {
204 mesh,
205 relay,
206 ingest_task,
207 outbound_task,
208 })))
209}
210
211#[cfg(feature = "experimental-decentralized-pubsub")]
212fn daemon_decentralized_pubsub_ready(config: &Config, has_fips: bool, has_relay: bool) -> bool {
213 config.nostr.decentralized_pubsub_enabled() && has_fips && has_relay
214}
215
216#[cfg(feature = "experimental-decentralized-pubsub")]
217fn spawn_daemon_nostr_pubsub_ingest(
218 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
219 relay: Arc<NostrRelay>,
220 max_event_bytes: usize,
221) -> JoinHandle<()> {
222 tokio::spawn(async move {
223 loop {
224 let delivery = mesh.recv_pubsub_event().await;
225 let stream_id = delivery.stream_id.clone();
226 let origin_peer_id = delivery.origin_peer_id.clone();
227 match crate::nostr_pubsub::ingest_nostr_pubsub_payload_with_limit(
228 &relay,
229 &delivery.stream_id,
230 &delivery.payload,
231 max_event_bytes,
232 )
233 .await
234 {
235 Ok(Some(event)) => {
236 tracing::debug!(
237 stream_id,
238 origin_peer_id,
239 event_id = %event.id.to_hex(),
240 "ingested decentralized Nostr pubsub event"
241 );
242 }
243 Ok(None) => {}
244 Err(err) => {
245 tracing::warn!(
246 stream_id,
247 origin_peer_id,
248 "nostr decentralized pubsub ingest failed: {err:#}"
249 );
250 }
251 }
252 }
253 })
254}
255
256#[cfg(feature = "experimental-decentralized-pubsub")]
257fn spawn_daemon_nostr_pubsub_outbound(
258 mesh: Arc<hashtree_fips_transport::FipsMeshPubsub<StorageRouter>>,
259 mut outbound_rx: mpsc::UnboundedReceiver<nostr::Event>,
260 max_event_bytes: usize,
261) -> JoinHandle<()> {
262 tokio::spawn(async move {
263 let mut seq = 1u64;
264 while let Some(event) = outbound_rx.recv().await {
265 let event_id = event.id.to_hex();
266 match crate::nostr_pubsub::publish_fips_nostr_event_with_limit(
267 mesh.as_ref(),
268 seq,
269 &event,
270 max_event_bytes,
271 )
272 .await
273 {
274 Ok(stats) => {
275 tracing::debug!(
276 event_id,
277 seq,
278 selected_peers = stats.selected_peers,
279 sent_peers = stats.sent_peers,
280 sent_bytes = stats.sent_bytes,
281 "published Nostr event over decentralized pubsub"
282 );
283 }
284 Err(err) => {
285 tracing::warn!(
286 event_id,
287 seq,
288 "nostr decentralized pubsub publish failed: {err:#}"
289 );
290 }
291 }
292 seq = seq.wrapping_add(1);
293 if seq == 0 {
294 seq = 1;
295 }
296 }
297 })
298}
299
300pub fn fips_peer_ids_from_pubkeys(pubkeys: Vec<[u8; 32]>) -> Vec<String> {
301 pubkeys
302 .into_iter()
303 .filter_map(|pubkey| PublicKey::from_slice(&pubkey).ok())
304 .filter_map(|pubkey| pubkey.to_bech32().ok())
305 .collect()
306}
307
308pub fn daemon_fips_peer_configs(
309 config: &Config,
310 discovered_peer_ids: Vec<String>,
311) -> Vec<FipsPeerConfig> {
312 let mut seen = HashSet::new();
313 let mut peers = Vec::new();
314
315 for peer in &config.server.fips_peers {
316 let npub = peer.npub.trim().to_string();
317 if npub.is_empty() || !seen.insert(npub.clone()) {
318 continue;
319 }
320 let udp_addresses = peer
321 .udp_addresses
322 .iter()
323 .map(|addr| addr.trim().to_string())
324 .filter(|addr| !addr.is_empty())
325 .collect();
326 peers.push(FipsPeerConfig {
327 npub,
328 udp_addresses,
329 });
330 }
331
332 for peer_id in discovered_peer_ids {
333 let npub = peer_id.trim().to_string();
334 if npub.is_empty() || !seen.insert(npub.clone()) {
335 continue;
336 }
337 peers.push(FipsPeerConfig::new(npub));
338 }
339
340 peers
341}
342
343fn normalized_discovery_scope(scope: &str) -> String {
344 let scope = scope.trim();
345 if scope.is_empty() {
346 DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
347 } else {
348 scope.to_string()
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn fips_peer_ids_from_pubkeys_encodes_npbus() {
358 let keys = nostr::Keys::generate();
359 let expected = keys.public_key().to_bech32().unwrap();
360
361 assert_eq!(
362 fips_peer_ids_from_pubkeys(vec![keys.public_key().to_bytes()]),
363 vec![expected]
364 );
365 }
366
367 #[test]
368 fn empty_discovery_scope_uses_hashtree_default() {
369 assert_eq!(
370 normalized_discovery_scope(" "),
371 DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
372 );
373 }
374
375 #[tokio::test]
376 async fn fips_local_only_event_transport_requires_local_provider() {
377 let mut config = Config::default();
378 config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
379
380 let error = start_daemon_nostr_provider(&config, None)
381 .await
382 .err()
383 .expect("missing local FIPS provider must fail");
384
385 assert!(error.to_string().contains("fips-local-only"));
386 assert!(error.to_string().contains("requires"));
387 }
388
389 #[test]
390 fn daemon_fips_peer_configs_prefer_configured_peers() {
391 let mut config = Config::default();
392 config.server.fips_peers = vec![
393 crate::config::ConfiguredFipsPeer {
394 npub: " origin ".to_string(),
395 udp_addresses: vec![" udp:192.0.2.10:2121 ".to_string(), " ".to_string()],
396 },
397 crate::config::ConfiguredFipsPeer {
398 npub: "origin".to_string(),
399 udp_addresses: vec!["udp:ignored:2121".to_string()],
400 },
401 ];
402
403 let peers = daemon_fips_peer_configs(
404 &config,
405 vec![
406 "origin".to_string(),
407 " followed ".to_string(),
408 " ".to_string(),
409 ],
410 );
411
412 assert_eq!(
413 peers,
414 vec![
415 FipsPeerConfig {
416 npub: "origin".to_string(),
417 udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
418 },
419 FipsPeerConfig::new("followed"),
420 ]
421 );
422 }
423
424 #[cfg(feature = "experimental-decentralized-pubsub")]
425 #[test]
426 fn daemon_decentralized_pubsub_requires_config_fips_and_relay() {
427 let mut config = Config::default();
428 assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
429
430 config.nostr.decentralized_pubsub = true;
431 assert!(daemon_decentralized_pubsub_ready(&config, true, true));
432 assert!(!daemon_decentralized_pubsub_ready(&config, false, true));
433 assert!(!daemon_decentralized_pubsub_ready(&config, true, false));
434
435 config.nostr.enabled = false;
436 assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
437 }
438}