Skip to main content

syncweb_core/node/
iroh_node.rs

1use iroh::address_lookup::memory::MemoryLookup;
2use iroh::protocol::Router;
3use iroh_blobs::BlobsProtocol;
4use iroh_docs::protocol::Docs;
5use iroh_gossip::net::Gossip;
6use std::collections::HashSet;
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9use std::time::Duration;
10
11use crate::error::{Result, SyncwebError};
12
13use super::beacon_lookup::{BeaconAddressLookup, DEFAULT_BEACON_PORT};
14use super::discovery::TopicTracker;
15use super::identity::IdentityManager;
16use super::membership_hook::MembershipHook;
17use super::{blob_store::BlobStore, docs_engine::DocsEngine, gossip_service::GossipService};
18
19#[non_exhaustive]
20#[derive(Clone, Debug)]
21pub enum RelayMode {
22    Default,
23    Custom { map: iroh::RelayMap, insecure: bool },
24    None,
25}
26
27/// Runtime configuration for the local discovery mechanisms attached to a node.
28///
29/// `scope` is a 16-byte token derived from the daemon's network name. When
30/// present it scopes both mDNS and the UDP beacon so that unrelated networks
31/// do not discover each other; `None` keeps the default iroh mDNS service name
32/// and a scope-less beacon (used by the network-agnostic default daemon).
33#[derive(Clone, Debug)]
34#[non_exhaustive]
35pub struct DiscoveryConfig {
36    /// Whether the mDNS address lookup is registered.
37    pub mdns: bool,
38    /// Whether the UDP beacon address lookup is registered.
39    pub beacon: bool,
40    /// Base UDP port the beacon spreads scopes over.
41    pub beacon_base_port: u16,
42    /// How often the beacon re-broadcasts its endpoint data.
43    pub beacon_interval: Duration,
44    /// Restrict the beacon to a single network interface by name, if any.
45    pub interface: Option<String>,
46    /// 16-byte discovery scope derived from the network name, if any.
47    pub scope: Option<[u8; 16]>,
48}
49
50impl Default for DiscoveryConfig {
51    fn default() -> Self {
52        Self {
53            mdns: true,
54            beacon: true,
55            beacon_base_port: DEFAULT_BEACON_PORT,
56            beacon_interval: Duration::from_secs(1),
57            interface: None,
58            scope: None,
59        }
60    }
61}
62
63impl DiscoveryConfig {
64    /// A discovery configuration with every mechanism disabled.
65    #[must_use]
66    pub const fn disabled() -> Self {
67        Self {
68            mdns: false,
69            beacon: false,
70            beacon_base_port: DEFAULT_BEACON_PORT,
71            beacon_interval: Duration::from_secs(1),
72            interface: None,
73            scope: None,
74        }
75    }
76}
77
78/// mDNS service name for a scope, or the default iroh name when unscoped.
79fn mdns_service_name(scope: Option<&[u8; 16]>) -> String {
80    scope.map_or_else(
81        || "irohv1".to_owned(),
82        |bytes| format!("syncweb-{}", hex::encode(&bytes[..8])),
83    )
84}
85
86/// Derive a discovery scope (first 16 bytes of the network id) from a network
87/// name. `None` for networks without a name, keeping the default daemon
88/// compatible with plain iroh peers on the LAN.
89#[must_use]
90pub fn discovery_scope(network: Option<&str>) -> Option<[u8; 16]> {
91    let name = network?;
92    let id = crate::net::NetworkId::from_name(name);
93    let bytes = id.as_bytes();
94    let mut scope = [0_u8; 16];
95    scope.copy_from_slice(&bytes[..16]);
96    Some(scope)
97}
98
99/// Registers the scoped mDNS and UDP beacon address lookups on an endpoint.
100fn register_discovery(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
101    if discovery.mdns {
102        register_mdns(endpoint, discovery);
103    }
104    if discovery.beacon {
105        register_beacon(endpoint, discovery);
106    }
107}
108
109fn register_mdns(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
110    let service_name = mdns_service_name(discovery.scope.as_ref());
111    match iroh_mdns_address_lookup::MdnsAddressLookup::builder()
112        .service_name(service_name)
113        .build(endpoint.id())
114        .map_err(|error| SyncwebError::operation("failed to build mDNS address lookup", error))
115        .and_then(|mdns| {
116            endpoint
117                .address_lookup()
118                .map_err(|error| SyncwebError::operation("no address lookup service available", error))
119                .map(|lookup| lookup.add(mdns))
120        }) {
121        Ok(()) => tracing::debug!("mDNS address lookup registered"),
122        Err(error) => tracing::warn!(
123            %error,
124            "mDNS address lookup registration failed — local peer discovery unavailable"
125        ),
126    }
127}
128
129fn register_beacon(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
130    let Ok(beacon) = BeaconAddressLookup::new(
131        endpoint.id(),
132        discovery.scope,
133        discovery.beacon_base_port,
134        discovery.beacon_interval,
135        discovery.interface.as_deref(),
136    ) else {
137        return;
138    };
139    match endpoint.address_lookup() {
140        Ok(lookup) => {
141            lookup.add(beacon);
142            tracing::debug!("beacon address lookup registered");
143        }
144        Err(error) => tracing::warn!(
145            %error,
146            "beacon address lookup registration failed — no address lookup service available"
147        ),
148    }
149}
150
151pub struct IrohNode {
152    endpoint: iroh::Endpoint,
153    router: Arc<Router>,
154    blobs: iroh_blobs::BlobsProtocol,
155    docs: Docs,
156    gossip: Arc<Gossip>,
157    blob_store: BlobStore,
158    docs_engine: DocsEngine,
159    gossip_service: GossipService,
160    topic_tracker: TopicTracker,
161}
162
163impl IrohNode {
164    /// Creates a node and starts accepting the blobs, docs, and gossip protocols.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the directory structure cannot be created, if binding the endpoint fails, or if starting the background services fails.
169    pub async fn new(
170        identity: IdentityManager,
171        data_dir: PathBuf,
172        relay_mode: RelayMode,
173        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
174        discovery: DiscoveryConfig,
175    ) -> Result<Self> {
176        Self::new_with_address_lookup(
177            identity,
178            data_dir,
179            relay_mode,
180            MemoryLookup::new(),
181            discovery,
182            member_keys,
183        )
184        .await
185    }
186
187    /// # Errors
188    ///
189    /// Returns an error if the directory structure cannot be created, if binding the endpoint fails, or if starting the background services fails.
190    pub async fn new_with_address_lookup(
191        identity: IdentityManager,
192        data_dir: PathBuf,
193        relay_mode: RelayMode,
194        address_lookup: MemoryLookup,
195        discovery: DiscoveryConfig,
196        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
197    ) -> Result<Self> {
198        tokio::fs::create_dir_all(&data_dir)
199            .await
200            .map_err(|error| SyncwebError::operation("failed to create node data directory", error))?;
201        let docs_dir = data_dir.join("docs");
202        tokio::fs::create_dir_all(&docs_dir)
203            .await
204            .map_err(|error| SyncwebError::operation("failed to create docs directory", error))?;
205
206        let builder = match relay_mode {
207            RelayMode::Default => iroh::Endpoint::builder(iroh::endpoint::presets::N0),
208            RelayMode::Custom { map, insecure } => {
209                let mut b = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
210                    .relay_mode(iroh::endpoint::RelayMode::Custom(map));
211                if insecure {
212                    b = b.ca_tls_config(iroh::tls::CaTlsConfig::insecure_skip_verify());
213                }
214                b
215            }
216            RelayMode::None => {
217                iroh::Endpoint::builder(iroh::endpoint::presets::N0).relay_mode(iroh::endpoint::RelayMode::Disabled)
218            }
219        };
220
221        let hook = MembershipHook {
222            member_keys: member_keys.clone(),
223        };
224        let endpoint = builder
225            .address_lookup(address_lookup.clone())
226            .secret_key(identity.secret_key().clone())
227            .hooks(hook)
228            .bind()
229            .await
230            .map_err(|error| SyncwebError::operation("failed to bind Iroh endpoint", error))?;
231
232        // Register mDNS and UDP beacon address lookups for local peer discovery.
233        // Both are scoped by `discovery.scope` when the daemon belongs to a
234        // network; failures are logged and leave the other discovery mechanism
235        // (or remote relay/DHT discovery) in charge.
236        register_discovery(&endpoint, &discovery);
237
238        let fs_blob_store = iroh_blobs::store::fs::FsStore::load(data_dir.join("blobs"))
239            .await
240            .map_err(|error| SyncwebError::operation("failed to open blob store", error))?;
241        let blobs = BlobsProtocol::new(fs_blob_store.as_ref(), None);
242
243        let gossip = Arc::new(Gossip::builder().spawn(endpoint.clone()));
244
245        let docs = Docs::persistent(docs_dir)
246            .spawn(endpoint.clone(), blobs.store().clone(), gossip.as_ref().clone())
247            .await
248            .map_err(|error| SyncwebError::operation("failed to open docs store", error))?;
249
250        let router = Router::builder(endpoint.clone())
251            .accept(iroh_blobs::protocol::ALPN, blobs.clone())
252            .accept(iroh_docs::ALPN, docs.clone())
253            .accept(iroh_gossip::ALPN, gossip.clone())
254            .spawn();
255
256        let blob_store = BlobStore::new_with_address_lookup(&blobs, address_lookup);
257        let docs_engine = DocsEngine::new(&docs);
258        let gossip_service = GossipService::new(&gossip);
259        let topic_tracker = TopicTracker::new(&gossip, &endpoint);
260
261        Ok(Self {
262            endpoint,
263            router: Arc::new(router),
264            blobs,
265            docs,
266            gossip,
267            blob_store,
268            docs_engine,
269            gossip_service,
270            topic_tracker,
271        })
272    }
273
274    #[must_use]
275    pub const fn endpoint(&self) -> &iroh::Endpoint {
276        &self.endpoint
277    }
278
279    #[must_use]
280    pub const fn blobs(&self) -> &BlobsProtocol {
281        &self.blobs
282    }
283
284    #[must_use]
285    pub const fn docs(&self) -> &Docs {
286        &self.docs
287    }
288
289    #[must_use]
290    pub fn gossip(&self) -> &Gossip {
291        &self.gossip
292    }
293
294    #[must_use]
295    pub const fn blob_store(&self) -> &BlobStore {
296        &self.blob_store
297    }
298
299    #[must_use]
300    pub const fn docs_engine(&self) -> &DocsEngine {
301        &self.docs_engine
302    }
303
304    #[must_use]
305    pub const fn gossip_service(&self) -> &GossipService {
306        &self.gossip_service
307    }
308
309    #[must_use]
310    pub const fn topic_tracker(&self) -> &TopicTracker {
311        &self.topic_tracker
312    }
313
314    #[must_use]
315    pub fn is_running(&self) -> bool {
316        !self.router.is_shutdown()
317    }
318
319    /// # Errors
320    ///
321    /// Returns an error if the router fails to shutdown properly.
322    pub async fn stop(&self) -> Result<()> {
323        self.router
324            .shutdown()
325            .await
326            .map_err(|error| SyncwebError::operation("failed to stop node router", error))?;
327        Ok(())
328    }
329
330    /// # Errors
331    ///
332    /// Returns an error if stopping the node fails.
333    pub async fn shutdown(self) -> Result<()> {
334        self.stop().await
335    }
336}
337
338impl Drop for IrohNode {
339    fn drop(&mut self) {
340        if !self.router.is_shutdown() {
341            tracing::warn!("IrohNode dropped without calling stop() — router shutdown may be incomplete");
342        }
343    }
344}