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