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, 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    // A configured/followed peer may be WebRTC-only. Let this endpoint dial
86    // discovered WebRTC adverts so connectivity does not depend on the remote
87    // side winning the offer race.
88    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}