Skip to main content

hashtree_cli/
fips_transport.rs

1//! Hashtree daemon integration for the embedded FIPS endpoint API.
2//!
3//! This starts `fips_core::FipsEndpoint` inside the htree process; it does not
4//! depend on or talk to an external FIPS daemon.
5
6use 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    // A configured/followed peer may be WebRTC-only. Let this endpoint dial
95    // discovered WebRTC adverts so connectivity does not depend on the remote
96    // side winning the offer race.
97    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}