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_core::BlobRoute;
12use hashtree_fips_transport::{
13    bind_fips_endpoint, bind_fips_endpoint_at_local_rendezvous, set_fips_peer_configs,
14    BoundFipsEndpoint, FipsEndpoint, FipsEndpointOptions, FipsPeerConfig, PeerIdentity,
15    TcpBlobTransport, TcpBlobTransportConfig, DEFAULT_FIPS_DISCOVERY_SCOPE,
16};
17use hashtree_network::{
18    blob_resolver, BlobResolver, MeshReadSource, MeshRoutingConfig, NamedBlobRoute,
19};
20use nostr::nips::nip19::ToBech32;
21use nostr::PublicKey;
22#[cfg(feature = "experimental-decentralized-pubsub")]
23use nostr_pubsub::{EventBus, EventSource, Filter, VerifiedEvent};
24use nostr_pubsub_fips::{FipsPubsubClient, FipsPubsubClientOptions};
25use std::collections::HashSet;
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28#[cfg(feature = "experimental-decentralized-pubsub")]
29use tokio::sync::mpsc;
30#[cfg(feature = "experimental-decentralized-pubsub")]
31use tokio::task::JoinHandle;
32
33pub type DaemonBlobResolver = BlobResolver<StorageRouter>;
34type DaemonBlobTransport = TcpBlobTransport<StorageRouter>;
35
36const DAEMON_SAME_HOST_PROVIDER_PRIORITY: i16 = 100;
37const BLOB_RESOLVER_REPLY_MARGIN: Duration = Duration::from_secs(1);
38
39pub struct DaemonFipsHandle {
40    pub endpoint: Arc<FipsEndpoint>,
41    pub endpoint_npub: String,
42    pub discovery_scope: String,
43    pub nostr_provider: Option<Arc<dyn nostr_pubsub::PubsubProvider>>,
44    pub pubsub_client: Option<Arc<FipsPubsubClient>>,
45    pub blob_resolver: Arc<DaemonBlobResolver>,
46    blob_transport: Mutex<Option<Arc<DaemonBlobTransport>>>,
47}
48
49impl DaemonFipsHandle {
50    pub async fn shutdown(&self) {
51        if let Ok(mut transport) = self.blob_transport.lock() {
52            transport.take();
53        }
54        if let Err(err) = self.endpoint.shutdown().await {
55            tracing::warn!("failed to stop embedded FIPS endpoint: {err}");
56        }
57    }
58}
59
60#[cfg(feature = "experimental-decentralized-pubsub")]
61pub struct DaemonNostrPubsubHandle {
62    _client: Arc<FipsPubsubClient>,
63    relay: Arc<NostrRelay>,
64    ingest_task: JoinHandle<()>,
65    outbound_task: JoinHandle<()>,
66}
67
68#[cfg(feature = "experimental-decentralized-pubsub")]
69impl DaemonNostrPubsubHandle {
70    pub fn shutdown(&self) {
71        self.relay.set_decentralized_pubsub_sender(None);
72        self.ingest_task.abort();
73        self.outbound_task.abort();
74    }
75}
76
77pub async fn start_daemon_fips_transport(
78    config: &Config,
79    keys: &nostr::Keys,
80    store: Arc<HashtreeStore>,
81    peer_ids: Vec<String>,
82) -> Result<Option<DaemonFipsHandle>> {
83    if !config.server.enable_fips || !config.server.mode.hash_get_enabled() {
84        return Ok(None);
85    }
86
87    let active_relays = config.nostr.active_relays();
88    let relays = config.server.resolved_fips_relays(&active_relays);
89    let discovery_scope = normalized_discovery_scope(&config.server.fips_discovery_scope);
90    let peer_configs = daemon_fips_peer_configs(config, peer_ids);
91    let identity_nsec = keys
92        .secret_key()
93        .to_bech32()
94        .context("Failed to encode daemon identity for FIPS endpoint")?;
95    let mut options = FipsEndpointOptions::new(identity_nsec);
96    options.discovery_scope = discovery_scope.clone();
97    options.relays = relays;
98    options.enable_udp = config.server.enable_fips_udp;
99    options.enable_webrtc = config.server.enable_fips_webrtc;
100    options.enable_local_rendezvous = true;
101    options.enable_lan_discovery = config.server.enable_fips_lan_discovery;
102    options.ethernet_interfaces = config.server.fips_ethernet_interfaces.clone();
103    options.udp_bind_addr = config.server.fips_udp_bind_addr.clone();
104    options.udp_public = config.server.fips_udp_public;
105    options.udp_external_addr = config.server.fips_udp_external_addr.clone();
106    // A configured/followed peer may be WebRTC-only. Let this endpoint dial
107    // discovered WebRTC adverts so connectivity does not depend on the remote
108    // side winning the offer race.
109    options.webrtc_auto_connect = options.enable_webrtc && !peer_configs.is_empty();
110    options.webrtc_max_connections = hashtree_fips_transport::DEFAULT_FIPS_WEBRTC_MAX_CONNECTIONS;
111    options.open_discovery_max_pending = 0;
112    options.packet_channel_capacity = 1024;
113    let endpoint = if let Some(raw) = config.server.fips_local_rendezvous_addr.as_deref() {
114        let rendezvous_addr = raw
115            .parse()
116            .context("server.fips_local_rendezvous_addr must be an IPv4 socket address")?;
117        bind_fips_endpoint_at_local_rendezvous(options, rendezvous_addr).await
118    } else {
119        bind_fips_endpoint(options).await
120    }
121    .context("Failed to start FIPS endpoint")?;
122
123    let request_timeout = Duration::from_millis(config.server.fips_request_timeout_ms.max(1));
124    let pubsub_client = if daemon_fips_pubsub_required(config) {
125        let options = FipsPubsubClientOptions {
126            query_timeout: request_timeout,
127            max_frame_bytes: config
128                .nostr
129                .decentralized_pubsub_max_event_bytes
130                .min(nostr_pubsub_fips::FIPS_NOSTR_PUBSUB_MAX_DATAGRAM_BYTES),
131            ..Default::default()
132        };
133        Some(Arc::new(
134            FipsPubsubClient::start(endpoint.native_endpoint.clone(), options)
135                .await
136                .context("Failed to start FIPS Nostr pubsub provider")?,
137        ))
138    } else {
139        None
140    };
141    let nostr_provider =
142        (config.nostr.event_transport == NostrEventTransport::FipsLocalOnly).then(|| {
143            pubsub_client
144                .as_ref()
145                .expect("FIPS local-only transport starts the shared pubsub client")
146                .clone() as Arc<dyn nostr_pubsub::PubsubProvider>
147        });
148    if !peer_configs.is_empty() {
149        set_fips_peer_configs(endpoint.native_endpoint.as_ref(), peer_configs.clone())
150            .await
151            .context("Failed to configure FIPS peers")?;
152    }
153    let (blob_resolver, blob_transport) =
154        bind_daemon_blob_resolver(&endpoint, store.store_arc(), &peer_configs, request_timeout)
155            .await?;
156
157    Ok(Some(DaemonFipsHandle {
158        endpoint: endpoint.native_endpoint,
159        endpoint_npub: endpoint.local_peer_id,
160        discovery_scope: endpoint.discovery_scope,
161        nostr_provider,
162        pubsub_client,
163        blob_resolver,
164        blob_transport: Mutex::new(Some(blob_transport)),
165    }))
166}
167
168fn daemon_fips_pubsub_required(config: &Config) -> bool {
169    config.nostr.event_transport == NostrEventTransport::FipsLocalOnly
170        || config.nostr.decentralized_pubsub_enabled()
171}
172
173async fn bind_daemon_blob_resolver(
174    endpoint: &BoundFipsEndpoint,
175    store: Arc<StorageRouter>,
176    peers: &[FipsPeerConfig],
177    request_timeout: Duration,
178) -> Result<(Arc<DaemonBlobResolver>, Arc<DaemonBlobTransport>)> {
179    let resolver = Arc::new(blob_resolver(
180        store.clone(),
181        endpoint.local_peer_id.clone(),
182        request_timeout,
183        MeshRoutingConfig::default(),
184    ));
185    let transport = Arc::new(
186        TcpBlobTransport::bind_advertised_route_with_config(
187            endpoint.native_endpoint.clone(),
188            store,
189            resolver.clone(),
190            blob_transport_config(request_timeout),
191            DAEMON_SAME_HOST_PROVIDER_PRIORITY,
192        )
193        .await
194        .context("Failed to advertise htree's blob resolver")?,
195    );
196    let routes: Vec<Arc<dyn MeshReadSource>> = peers
197        .iter()
198        .filter_map(|peer| {
199            let identity = PeerIdentity::from_npub(&peer.npub).ok()?;
200            let route: Arc<dyn BlobRoute> = Arc::new(transport.weak_route_to(identity));
201            Some(
202                Arc::new(NamedBlobRoute::mesh_peer(peer.npub.clone(), route))
203                    as Arc<dyn MeshReadSource>,
204            )
205        })
206        .collect();
207    resolver.set_read_sources(routes).await;
208    Ok((resolver, transport))
209}
210
211fn blob_transport_config(resolver_timeout: Duration) -> TcpBlobTransportConfig {
212    TcpBlobTransportConfig {
213        idle_timeout: resolver_timeout.saturating_add(BLOB_RESOLVER_REPLY_MARGIN),
214    }
215}
216
217pub async fn start_daemon_nostr_provider(
218    config: &Config,
219    fips_handle: Option<&DaemonFipsHandle>,
220) -> Result<Option<Arc<dyn nostr_pubsub::PubsubProvider>>> {
221    match config.nostr.event_transport {
222        NostrEventTransport::Relay => {
223            let relays = config.nostr.active_relays();
224            if relays.is_empty() {
225                return Ok(None);
226            }
227            let event_bus = nostr_pubsub_relay::RelayEventBus::new(
228                relays,
229                Duration::from_millis(config.server.fips_request_timeout_ms.max(1)),
230            )
231            .await
232            .context("Failed to start Nostr relay event provider")?;
233            Ok(Some(Arc::new(event_bus)))
234        }
235        NostrEventTransport::FipsLocalOnly => fips_handle
236            .and_then(|handle| handle.nostr_provider.clone())
237            .map(Some)
238            .ok_or_else(|| {
239                anyhow::anyhow!(
240                    "nostr.event_transport=fips-local-only requires the local FIPS Nostr pubsub provider"
241                )
242            }),
243    }
244}
245
246#[cfg(feature = "experimental-decentralized-pubsub")]
247pub async fn start_daemon_nostr_pubsub(
248    config: &Config,
249    fips_handle: Option<&DaemonFipsHandle>,
250    relay: Option<Arc<NostrRelay>>,
251) -> Result<Option<Arc<DaemonNostrPubsubHandle>>> {
252    if !daemon_decentralized_pubsub_ready(config, fips_handle.is_some(), relay.is_some()) {
253        return Ok(None);
254    }
255
256    let fips_handle = fips_handle.expect("checked by daemon_decentralized_pubsub_ready");
257    let relay = relay.expect("checked by daemon_decentralized_pubsub_ready");
258    let client = fips_handle.pubsub_client.clone().ok_or_else(|| {
259        anyhow::anyhow!("decentralized Nostr pubsub requires the shared FIPS pubsub client")
260    })?;
261
262    let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
263    relay.set_decentralized_pubsub_sender(Some(outbound_tx));
264    let ingest_task = spawn_daemon_nostr_pubsub_ingest(
265        Arc::clone(&client),
266        Arc::clone(&fips_handle.endpoint),
267        Arc::clone(&relay),
268    );
269    let outbound_task = spawn_daemon_nostr_pubsub_outbound(Arc::clone(&client), outbound_rx);
270
271    Ok(Some(Arc::new(DaemonNostrPubsubHandle {
272        _client: client,
273        relay,
274        ingest_task,
275        outbound_task,
276    })))
277}
278
279#[cfg(feature = "experimental-decentralized-pubsub")]
280fn daemon_decentralized_pubsub_ready(config: &Config, has_fips: bool, has_relay: bool) -> bool {
281    config.nostr.decentralized_pubsub_enabled() && has_fips && has_relay
282}
283
284#[cfg(feature = "experimental-decentralized-pubsub")]
285fn spawn_daemon_nostr_pubsub_ingest(
286    client: Arc<FipsPubsubClient>,
287    endpoint: Arc<FipsEndpoint>,
288    relay: Arc<NostrRelay>,
289) -> JoinHandle<()> {
290    tokio::spawn(async move {
291        loop {
292            let subscribed_peers = connected_fips_peer_ids(&endpoint).await;
293            if subscribed_peers.is_empty() {
294                tokio::time::sleep(Duration::from_millis(250)).await;
295                continue;
296            }
297            let mut subscription = match client.subscribe(vec![Filter::new()]).await {
298                Ok(subscription) => subscription,
299                Err(err) => {
300                    tracing::debug!("waiting to subscribe to FIPS Nostr peers: {err}");
301                    tokio::time::sleep(Duration::from_millis(250)).await;
302                    continue;
303                }
304            };
305            loop {
306                tokio::select! {
307                    delivery = subscription.recv() => {
308                        let Some(delivery) = delivery else {
309                            break;
310                        };
311                        let source = delivery.source.id.as_str().to_string();
312                        let event = delivery.event.into_event();
313                        let event_id = event.id.to_hex();
314                        match relay.ingest_peer_event_silent(event).await {
315                            Ok(true) => tracing::debug!(
316                                source,
317                                event_id,
318                                "ingested decentralized Nostr pubsub event"
319                            ),
320                            Ok(false) => {}
321                            Err(err) => tracing::warn!(
322                                source,
323                                event_id,
324                                "nostr decentralized pubsub ingest failed: {err:#}"
325                            ),
326                        }
327                    }
328                    () = tokio::time::sleep(Duration::from_millis(500)) => {
329                        if connected_fips_peer_ids(&endpoint).await != subscribed_peers {
330                            break;
331                        }
332                    }
333                }
334            }
335        }
336    })
337}
338
339#[cfg(feature = "experimental-decentralized-pubsub")]
340async fn connected_fips_peer_ids(endpoint: &FipsEndpoint) -> Vec<String> {
341    let mut peers = endpoint
342        .peers()
343        .await
344        .unwrap_or_default()
345        .into_iter()
346        .filter(|peer| peer.connected)
347        .map(|peer| peer.npub)
348        .collect::<Vec<_>>();
349    peers.sort_unstable();
350    peers.dedup();
351    peers
352}
353
354#[cfg(feature = "experimental-decentralized-pubsub")]
355fn spawn_daemon_nostr_pubsub_outbound(
356    client: Arc<FipsPubsubClient>,
357    mut outbound_rx: mpsc::UnboundedReceiver<nostr::Event>,
358) -> JoinHandle<()> {
359    tokio::spawn(async move {
360        while let Some(event) = outbound_rx.recv().await {
361            let event_id = event.id.to_hex();
362            let verified = match VerifiedEvent::try_from(event) {
363                Ok(event) => event,
364                Err(err) => {
365                    tracing::warn!(event_id, "invalid outbound Nostr event: {err}");
366                    continue;
367                }
368            };
369            match client
370                .publish(verified, EventSource::local_index("htree-relay"))
371                .await
372            {
373                Ok(report) => tracing::debug!(
374                    event_id,
375                    accepted = report.accepted,
376                    "published Nostr event over decentralized pubsub"
377                ),
378                Err(err) => {
379                    tracing::warn!(event_id, "nostr decentralized pubsub publish failed: {err}")
380                }
381            }
382        }
383    })
384}
385
386pub fn fips_peer_ids_from_pubkeys(pubkeys: Vec<[u8; 32]>) -> Vec<String> {
387    pubkeys
388        .into_iter()
389        .filter_map(|pubkey| PublicKey::from_slice(&pubkey).ok())
390        .filter_map(|pubkey| pubkey.to_bech32().ok())
391        .collect()
392}
393
394pub fn daemon_fips_peer_configs(
395    config: &Config,
396    discovered_peer_ids: Vec<String>,
397) -> Vec<FipsPeerConfig> {
398    let mut seen = HashSet::new();
399    let mut peers = Vec::new();
400
401    for peer in &config.server.fips_peers {
402        let npub = peer.npub.trim().to_string();
403        if npub.is_empty() || !seen.insert(npub.clone()) {
404            continue;
405        }
406        let udp_addresses = peer
407            .udp_addresses
408            .iter()
409            .map(|addr| addr.trim().to_string())
410            .filter(|addr| !addr.is_empty())
411            .collect();
412        peers.push(FipsPeerConfig {
413            npub,
414            udp_addresses,
415        });
416    }
417
418    for peer_id in discovered_peer_ids {
419        let npub = peer_id.trim().to_string();
420        if npub.is_empty() || !seen.insert(npub.clone()) {
421            continue;
422        }
423        peers.push(FipsPeerConfig::new(npub));
424    }
425
426    peers
427}
428
429fn normalized_discovery_scope(scope: &str) -> String {
430    let scope = scope.trim();
431    if scope.is_empty() {
432        DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
433    } else {
434        scope.to_string()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use hashtree_core::Store;
442    #[cfg(feature = "experimental-decentralized-pubsub")]
443    use nostr::{EventBuilder, Kind, Tag};
444    #[cfg(feature = "experimental-decentralized-pubsub")]
445    use nostr_pubsub::{PubsubProviderMode, QueryOptions};
446    use sha2::{Digest, Sha256};
447    use tokio::time::timeout;
448
449    #[test]
450    fn fips_peer_ids_from_pubkeys_encodes_npbus() {
451        let keys = nostr::Keys::generate();
452        let expected = keys.public_key().to_bech32().unwrap();
453
454        assert_eq!(
455            fips_peer_ids_from_pubkeys(vec![keys.public_key().to_bytes()]),
456            vec![expected]
457        );
458    }
459
460    #[test]
461    fn empty_discovery_scope_uses_hashtree_default() {
462        assert_eq!(
463            normalized_discovery_scope("  "),
464            DEFAULT_FIPS_DISCOVERY_SCOPE.to_string()
465        );
466    }
467
468    #[tokio::test]
469    async fn fips_local_only_event_transport_requires_local_provider() {
470        let mut config = Config::default();
471        config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
472
473        let error = start_daemon_nostr_provider(&config, None)
474            .await
475            .err()
476            .expect("missing local FIPS provider must fail");
477
478        assert!(error.to_string().contains("fips-local-only"));
479        assert!(error.to_string().contains("requires"));
480    }
481
482    #[cfg(feature = "experimental-decentralized-pubsub")]
483    #[tokio::test]
484    async fn one_native_endpoint_serves_roots_and_trusted_decentralized_events() {
485        let scope = format!("htree-native-pubsub-{}", uuid::Uuid::new_v4());
486        let daemon_addr = reserve_udp_addr();
487        let rendezvous_addr = reserve_udp_addr();
488        let (remote_endpoint, remote_addr) = udp_endpoint(&scope).await;
489        let daemon_keys = nostr::Keys::generate();
490        let trusted_keys = nostr::Keys::generate();
491        let untrusted_keys = nostr::Keys::generate();
492        let outbound_keys = nostr::Keys::generate();
493        let temp = tempfile::tempdir().unwrap();
494        let store = Arc::new(HashtreeStore::new(temp.path().join("blobs")).unwrap());
495        let relay = trusted_test_relay(
496            temp.path(),
497            &[trusted_keys.public_key(), outbound_keys.public_key()],
498        )
499        .await;
500
501        let mut config = Config::default();
502        config.server.fips_discovery_scope = scope;
503        config.server.fips_relays = Some(Vec::new());
504        config.server.fips_udp_bind_addr = Some(daemon_addr.clone());
505        config.server.fips_local_rendezvous_addr = Some(rendezvous_addr);
506        config.server.enable_fips_udp = true;
507        config.server.enable_fips_webrtc = false;
508        config.server.fips_request_timeout_ms = 2_000;
509        config.server.fips_peers = vec![crate::config::ConfiguredFipsPeer {
510            npub: remote_endpoint.local_peer_id.clone(),
511            udp_addresses: vec![remote_addr],
512        }];
513        config.nostr.enabled = true;
514        config.nostr.event_transport = NostrEventTransport::FipsLocalOnly;
515        config.nostr.decentralized_pubsub = true;
516
517        let daemon = start_daemon_fips_transport(&config, &daemon_keys, store, Vec::new())
518            .await
519            .unwrap()
520            .expect("daemon FIPS endpoint");
521        let provider = start_daemon_nostr_provider(&config, Some(&daemon))
522            .await
523            .unwrap()
524            .expect("FIPS root provider");
525        assert_eq!(provider.mode(), PubsubProviderMode::LocalOnly);
526        let decentralized = start_daemon_nostr_pubsub(&config, Some(&daemon), Some(relay.clone()))
527            .await
528            .unwrap()
529            .expect("decentralized pubsub");
530
531        set_fips_peer_configs(
532            remote_endpoint.native_endpoint.as_ref(),
533            vec![FipsPeerConfig {
534                npub: daemon.endpoint_npub.clone(),
535                udp_addresses: vec![daemon_addr],
536            }],
537        )
538        .await
539        .unwrap();
540        let remote_client = Arc::new(
541            FipsPubsubClient::start(
542                remote_endpoint.native_endpoint.clone(),
543                FipsPubsubClientOptions {
544                    query_timeout: Duration::from_secs(2),
545                    ..Default::default()
546                },
547            )
548            .await
549            .unwrap(),
550        );
551
552        wait_for_native_peer(&daemon.endpoint, &remote_endpoint.local_peer_id).await;
553        wait_for_peer(&remote_endpoint, &daemon.endpoint_npub).await;
554        timeout(Duration::from_secs(5), async {
555            while remote_client.peer_subscription_count().unwrap() == 0 {
556                tokio::time::sleep(Duration::from_millis(20)).await;
557            }
558        })
559        .await
560        .expect("daemon did not subscribe to the authenticated peer");
561
562        // FipsLocalOnly root publication and resolution use the same client
563        // that the decentralized relay bridge already owns.
564        let published_root = root_event(&outbound_keys, "published-root", "aa");
565        let mut published_root_rx = remote_client
566            .subscribe(vec![Filter::new().id(published_root.id)])
567            .await
568            .unwrap();
569        provider
570            .publish(
571                VerifiedEvent::try_from(published_root.clone()).unwrap(),
572                EventSource::local_index("root-publisher"),
573            )
574            .await
575            .unwrap();
576        let delivered_root = timeout(Duration::from_secs(5), published_root_rx.recv())
577            .await
578            .unwrap()
579            .expect("published root delivery");
580        assert_eq!(delivered_root.event.as_event().id, published_root.id);
581
582        let resolved_root = root_event(&trusted_keys, "resolved-root", "bb");
583        remote_client
584            .publish(
585                VerifiedEvent::try_from(resolved_root.clone()).unwrap(),
586                EventSource::local_index("remote-root"),
587            )
588            .await
589            .unwrap();
590        let resolution = provider
591            .query(
592                vec![Filter::new().id(resolved_root.id)],
593                QueryOptions { limit: Some(1) },
594            )
595            .await
596            .unwrap();
597        assert_eq!(resolution.events.len(), 1);
598        assert_eq!(resolution.events[0].event.as_event().id, resolved_root.id);
599
600        // The normal Nostr relay policy remains authoritative after wire-level
601        // signature verification: an untrusted author is not indexed, while a
602        // later trusted event proves the receive path advanced past it.
603        let rejected = EventBuilder::new(Kind::TextNote, "untrusted")
604            .sign_with_keys(&untrusted_keys)
605            .unwrap();
606        remote_client
607            .publish(
608                VerifiedEvent::try_from(rejected.clone()).unwrap(),
609                EventSource::local_index("remote-untrusted"),
610            )
611            .await
612            .unwrap();
613        let accepted = EventBuilder::new(Kind::TextNote, "trusted")
614            .sign_with_keys(&trusted_keys)
615            .unwrap();
616        remote_client
617            .publish(
618                VerifiedEvent::try_from(accepted.clone()).unwrap(),
619                EventSource::local_index("remote-trusted"),
620            )
621            .await
622            .unwrap();
623        wait_for_relay_event(&relay, accepted.id).await;
624        assert!(relay
625            .query_events(&Filter::new().id(rejected.id), 1)
626            .await
627            .is_empty());
628
629        // A trusted local relay publication travels back over the same FIPS
630        // event bus without opening another service receiver.
631        let outbound = EventBuilder::new(Kind::TextNote, "relay outbound")
632            .sign_with_keys(&outbound_keys)
633            .unwrap();
634        let mut outbound_rx = remote_client
635            .subscribe(vec![Filter::new().id(outbound.id)])
636            .await
637            .unwrap();
638        let (client_tx, _client_rx) = mpsc::unbounded_channel();
639        relay.register_client(1, client_tx, None).await;
640        relay
641            .handle_client_message(1, nostr::ClientMessage::event(outbound.clone()))
642            .await;
643        let delivered = timeout(Duration::from_secs(5), outbound_rx.recv())
644            .await
645            .unwrap()
646            .expect("outbound relay publication");
647        assert_eq!(delivered.event.as_event().id, outbound.id);
648
649        decentralized.shutdown();
650        daemon.shutdown().await;
651        remote_endpoint.native_endpoint.shutdown().await.unwrap();
652    }
653
654    #[tokio::test]
655    async fn daemon_blob_resolver_advertises_and_serves_storage_router() {
656        let provider_endpoint = local_only_endpoint("htree-provider-test").await;
657        let observer_endpoint = local_only_endpoint("iris-drive-observer-test").await;
658        let temp = tempfile::tempdir().unwrap();
659        let store = Arc::new(HashtreeStore::new(temp.path()).unwrap());
660        let data = b"served directly from htree StorageRouter".to_vec();
661        let hash = Sha256::digest(&data).into();
662        store.store_arc().put(hash, data.clone()).await.unwrap();
663        let (blob_resolver, provider) = bind_daemon_blob_resolver(
664            &provider_endpoint,
665            store.store_arc(),
666            &[],
667            Duration::from_secs(1),
668        )
669        .await
670        .expect("bind htree provider");
671        let handle = DaemonFipsHandle {
672            endpoint: provider_endpoint.native_endpoint.clone(),
673            endpoint_npub: provider_endpoint.local_peer_id.clone(),
674            discovery_scope: provider_endpoint.discovery_scope.clone(),
675            nostr_provider: None,
676            pubsub_client: None,
677            blob_resolver,
678            blob_transport: Mutex::new(Some(provider)),
679        };
680
681        timeout(Duration::from_secs(10), async {
682            loop {
683                if provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
684                    break;
685                }
686                tokio::time::sleep(Duration::from_millis(20)).await;
687            }
688        })
689        .await
690        .expect("htree provider capability did not converge");
691
692        let requester = hashtree_fips_transport::SameHostBlobStore::bind(
693            observer_endpoint.native_endpoint.clone(),
694            Arc::new(hashtree_core::MemoryStore::new()),
695            None,
696            hashtree_fips_transport::SameHostBlobStoreConfig::default(),
697        )
698        .await
699        .unwrap();
700        assert_eq!(requester.get(&hash).await.unwrap(), Some(data));
701
702        drop(requester);
703        handle.shutdown().await;
704        timeout(Duration::from_secs(10), async {
705            while provider_advertised(&observer_endpoint, &provider_endpoint.local_peer_id) {
706                tokio::time::sleep(Duration::from_millis(20)).await;
707            }
708        })
709        .await
710        .expect("htree provider capability survived daemon shutdown");
711        drop(handle);
712        observer_endpoint.native_endpoint.shutdown().await.unwrap();
713    }
714
715    #[tokio::test]
716    async fn daemon_blob_resolvers_honor_multi_hop_htl_and_cache() {
717        let (source_endpoint, source_addr) = udp_endpoint("htree-source-test").await;
718        let (middle_endpoint, middle_addr) = udp_endpoint("htree-middle-test").await;
719        let (requester_endpoint, requester_addr) = udp_endpoint("htree-requester-test").await;
720        let source_dir = tempfile::tempdir().unwrap();
721        let middle_dir = tempfile::tempdir().unwrap();
722        let requester_dir = tempfile::tempdir().unwrap();
723        let source_store = Arc::new(HashtreeStore::new(source_dir.path()).unwrap());
724        let middle_store = Arc::new(HashtreeStore::new(middle_dir.path()).unwrap());
725        let requester_store = Arc::new(HashtreeStore::new(requester_dir.path()).unwrap());
726        let data = b"canonical multi-hop blob route".to_vec();
727        let hash = Sha256::digest(&data).into();
728        source_store
729            .store_arc()
730            .put(hash, data.clone())
731            .await
732            .unwrap();
733
734        set_fips_peer_configs(
735            source_endpoint.native_endpoint.as_ref(),
736            vec![FipsPeerConfig {
737                npub: middle_endpoint.local_peer_id.clone(),
738                udp_addresses: vec![middle_addr.clone()],
739            }],
740        )
741        .await
742        .unwrap();
743        set_fips_peer_configs(
744            middle_endpoint.native_endpoint.as_ref(),
745            vec![
746                FipsPeerConfig {
747                    npub: source_endpoint.local_peer_id.clone(),
748                    udp_addresses: vec![source_addr.clone()],
749                },
750                FipsPeerConfig {
751                    npub: requester_endpoint.local_peer_id.clone(),
752                    udp_addresses: vec![requester_addr],
753                },
754            ],
755        )
756        .await
757        .unwrap();
758        set_fips_peer_configs(
759            requester_endpoint.native_endpoint.as_ref(),
760            vec![FipsPeerConfig {
761                npub: middle_endpoint.local_peer_id.clone(),
762                udp_addresses: vec![middle_addr.clone()],
763            }],
764        )
765        .await
766        .unwrap();
767
768        let (_source_resolver, source_transport) = bind_daemon_blob_resolver(
769            &source_endpoint,
770            source_store.store_arc(),
771            &[],
772            Duration::from_secs(2),
773        )
774        .await
775        .unwrap();
776        let (middle_resolver, middle_transport) = bind_daemon_blob_resolver(
777            &middle_endpoint,
778            middle_store.store_arc(),
779            &[FipsPeerConfig {
780                npub: source_endpoint.local_peer_id.clone(),
781                udp_addresses: vec![source_addr],
782            }],
783            Duration::from_secs(2),
784        )
785        .await
786        .unwrap();
787        let (requester_resolver, requester_transport) = bind_daemon_blob_resolver(
788            &requester_endpoint,
789            requester_store.store_arc(),
790            &[FipsPeerConfig {
791                npub: middle_endpoint.local_peer_id.clone(),
792                udp_addresses: vec![middle_addr],
793            }],
794            Duration::from_secs(2),
795        )
796        .await
797        .unwrap();
798
799        wait_for_peer(&requester_endpoint, &middle_endpoint.local_peer_id).await;
800        wait_for_peer(&middle_endpoint, &source_endpoint.local_peer_id).await;
801
802        assert_eq!(
803            requester_resolver
804                .route(hashtree_core::BlobRequest { hash, htl: 1 })
805                .await
806                .expect("exhausted search"),
807            hashtree_core::BlobReply::NoResult,
808        );
809        assert_eq!(
810            requester_resolver
811                .route(hashtree_core::BlobRequest { hash, htl: 2 })
812                .await
813                .expect("two-hop search"),
814            hashtree_core::BlobReply::Data(data.clone()),
815        );
816        assert_eq!(
817            middle_store.store_arc().get(&hash).await.unwrap(),
818            Some(data.clone())
819        );
820        assert_eq!(
821            requester_store.store_arc().get(&hash).await.unwrap(),
822            Some(data)
823        );
824
825        drop(requester_transport);
826        drop(middle_transport);
827        drop(source_transport);
828        requester_endpoint.native_endpoint.shutdown().await.unwrap();
829        middle_endpoint.native_endpoint.shutdown().await.unwrap();
830        source_endpoint.native_endpoint.shutdown().await.unwrap();
831        drop(middle_resolver);
832    }
833
834    fn provider_advertised(endpoint: &BoundFipsEndpoint, npub: &str) -> bool {
835        endpoint
836            .native_endpoint
837            .local_instance_advertisements()
838            .unwrap()
839            .iter()
840            .any(|advert| {
841                advert.npub == npub
842                    && advert
843                        .capability(hashtree_fips_transport::TCP_BLOB_CAPABILITY)
844                        .and_then(|capability| capability.fsp_port)
845                        == Some(hashtree_fips_transport::TCP_BLOB_SERVICE_PORT)
846            })
847    }
848
849    async fn local_only_endpoint(scope: &str) -> hashtree_fips_transport::BoundFipsEndpoint {
850        let keys = nostr::Keys::generate();
851        let mut options = FipsEndpointOptions::new(keys.secret_key().to_bech32().unwrap());
852        options.discovery_scope = scope.to_string();
853        options.enable_udp = false;
854        options.enable_webrtc = false;
855        options.enable_local_rendezvous = true;
856        options.enable_lan_discovery = false;
857        options.share_local_candidates = false;
858        bind_fips_endpoint(options).await.unwrap()
859    }
860
861    async fn udp_endpoint(scope: &str) -> (BoundFipsEndpoint, String) {
862        let addr = reserve_udp_addr();
863        let keys = nostr::Keys::generate();
864        let mut options = FipsEndpointOptions::new(keys.secret_key().to_bech32().unwrap());
865        options.discovery_scope = scope.to_string();
866        options.enable_udp = true;
867        options.udp_bind_addr = Some(addr.clone());
868        options.enable_webrtc = false;
869        options.enable_local_rendezvous = false;
870        options.enable_lan_discovery = false;
871        options.share_local_candidates = false;
872        let endpoint = bind_fips_endpoint(options).await.unwrap();
873        (endpoint, addr)
874    }
875
876    fn reserve_udp_addr() -> String {
877        let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
878        socket.local_addr().unwrap().to_string()
879    }
880
881    async fn wait_for_peer(endpoint: &BoundFipsEndpoint, npub: &str) {
882        wait_for_native_peer(endpoint.native_endpoint.as_ref(), npub).await;
883    }
884
885    async fn wait_for_native_peer(endpoint: &FipsEndpoint, npub: &str) {
886        let result = timeout(Duration::from_secs(10), async {
887            loop {
888                if endpoint
889                    .peers()
890                    .await
891                    .unwrap()
892                    .iter()
893                    .any(|peer| peer.npub == npub && peer.connected)
894                {
895                    break;
896                }
897                tokio::time::sleep(Duration::from_millis(20)).await;
898            }
899        })
900        .await;
901        if result.is_err() {
902            panic!(
903                "FIPS peer {npub} did not connect; peers={:?}",
904                endpoint.peers().await
905            );
906        }
907    }
908
909    #[cfg(feature = "experimental-decentralized-pubsub")]
910    async fn trusted_test_relay(
911        root: &std::path::Path,
912        allowed: &[nostr::PublicKey],
913    ) -> Arc<NostrRelay> {
914        let graph_dir = root.join("graph");
915        let data_dir = root.join("nostr");
916        std::fs::create_dir_all(&data_dir).unwrap();
917        let graph = {
918            let _guard = crate::socialgraph::test_lock().await;
919            crate::socialgraph::open_test_social_graph_store_with_mapsize(
920                &graph_dir,
921                Some(128 * 1024 * 1024),
922            )
923            .unwrap()
924        };
925        let backend: Arc<dyn crate::socialgraph::SocialGraphBackend> = graph;
926        let allowed = allowed
927            .iter()
928            .map(|pubkey| pubkey.to_hex())
929            .collect::<HashSet<_>>();
930        let access = Arc::new(crate::socialgraph::SocialGraphAccessControl::new(
931            Arc::clone(&backend),
932            0,
933            allowed.clone(),
934        ));
935        Arc::new(
936            NostrRelay::new(
937                backend,
938                data_dir,
939                allowed,
940                Some(access),
941                crate::nostr_relay::NostrRelayConfig {
942                    spambox_db_max_bytes: 0,
943                    ..Default::default()
944                },
945            )
946            .unwrap(),
947        )
948    }
949
950    #[cfg(feature = "experimental-decentralized-pubsub")]
951    fn root_event(keys: &nostr::Keys, name: &str, hash: &str) -> nostr::Event {
952        EventBuilder::new(Kind::Custom(30_078), hash)
953            .tags([
954                Tag::parse(["d", name]).unwrap(),
955                Tag::parse(["l", "hashtree"]).unwrap(),
956                Tag::parse(["hash", hash]).unwrap(),
957            ])
958            .sign_with_keys(keys)
959            .unwrap()
960    }
961
962    #[cfg(feature = "experimental-decentralized-pubsub")]
963    async fn wait_for_relay_event(relay: &NostrRelay, id: nostr::EventId) {
964        timeout(Duration::from_secs(5), async {
965            loop {
966                if relay
967                    .query_events(&Filter::new().id(id), 1)
968                    .await
969                    .iter()
970                    .any(|event| event.id == id)
971                {
972                    break;
973                }
974                tokio::time::sleep(Duration::from_millis(20)).await;
975            }
976        })
977        .await
978        .expect("trusted decentralized event was not indexed");
979    }
980
981    #[test]
982    fn daemon_fips_peer_configs_prefer_configured_peers() {
983        let mut config = Config::default();
984        config.server.fips_peers = vec![
985            crate::config::ConfiguredFipsPeer {
986                npub: " origin ".to_string(),
987                udp_addresses: vec![" udp:192.0.2.10:2121 ".to_string(), " ".to_string()],
988            },
989            crate::config::ConfiguredFipsPeer {
990                npub: "origin".to_string(),
991                udp_addresses: vec!["udp:ignored:2121".to_string()],
992            },
993        ];
994
995        let peers = daemon_fips_peer_configs(
996            &config,
997            vec![
998                "origin".to_string(),
999                " followed ".to_string(),
1000                " ".to_string(),
1001            ],
1002        );
1003
1004        assert_eq!(
1005            peers,
1006            vec![
1007                FipsPeerConfig {
1008                    npub: "origin".to_string(),
1009                    udp_addresses: vec!["udp:192.0.2.10:2121".to_string()],
1010                },
1011                FipsPeerConfig::new("followed"),
1012            ]
1013        );
1014    }
1015
1016    #[test]
1017    fn blob_carrier_deadline_outlives_the_complete_resolver_budget() {
1018        for resolver_timeout in [Duration::from_millis(1), Duration::from_secs(20)] {
1019            assert!(blob_transport_config(resolver_timeout).idle_timeout > resolver_timeout);
1020        }
1021    }
1022
1023    #[cfg(feature = "experimental-decentralized-pubsub")]
1024    #[test]
1025    fn daemon_decentralized_pubsub_requires_config_fips_and_relay() {
1026        let mut config = Config::default();
1027        assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
1028
1029        config.nostr.decentralized_pubsub = true;
1030        assert!(daemon_decentralized_pubsub_ready(&config, true, true));
1031        assert!(!daemon_decentralized_pubsub_ready(&config, false, true));
1032        assert!(!daemon_decentralized_pubsub_ready(&config, true, false));
1033
1034        config.nostr.enabled = false;
1035        assert!(!daemon_decentralized_pubsub_ready(&config, true, true));
1036    }
1037}