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::blob_store::BlobStore;
15use super::discovery::TopicTracker;
16use super::docs_engine::DocsEngine;
17use super::identity::IdentityManager;
18use super::membership_hook::MembershipHook;
19
20#[non_exhaustive]
21#[derive(Clone, Debug)]
22pub enum RelayMode {
23    Default,
24    Custom { map: iroh::RelayMap, insecure: bool },
25    None,
26}
27
28/// Runtime configuration for the local discovery mechanisms attached to a node.
29///
30/// `scope` is a 16-byte token derived from the daemon's network name. When
31/// present it scopes both mDNS and the UDP beacon so that unrelated networks
32/// do not discover each other; `None` keeps the default iroh mDNS service name
33/// and a scope-less beacon (used by the network-agnostic default daemon).
34#[derive(Clone, Debug)]
35#[non_exhaustive]
36pub struct DiscoveryConfig {
37    /// Whether the mDNS address lookup is registered.
38    pub mdns: bool,
39    /// Whether the UDP beacon address lookup is registered.
40    pub beacon: bool,
41    /// Base UDP port the beacon spreads scopes over.
42    pub beacon_base_port: u16,
43    /// How often the beacon re-broadcasts its endpoint data.
44    pub beacon_interval: Duration,
45    /// Restrict the beacon to a single network interface by name, if any.
46    pub interface: Option<String>,
47    /// 16-byte discovery scope derived from the network name, if any.
48    pub scope: Option<[u8; 16]>,
49}
50
51impl Default for DiscoveryConfig {
52    fn default() -> Self {
53        Self {
54            mdns: true,
55            beacon: true,
56            beacon_base_port: DEFAULT_BEACON_PORT,
57            beacon_interval: Duration::from_secs(1),
58            interface: None,
59            scope: None,
60        }
61    }
62}
63
64impl DiscoveryConfig {
65    /// A discovery configuration with every mechanism disabled.
66    #[must_use]
67    pub const fn disabled() -> Self {
68        Self {
69            mdns: false,
70            beacon: false,
71            beacon_base_port: DEFAULT_BEACON_PORT,
72            beacon_interval: Duration::from_secs(1),
73            interface: None,
74            scope: None,
75        }
76    }
77}
78
79/// mDNS service name for a scope, or the default iroh name when unscoped.
80fn mdns_service_name(scope: Option<&[u8; 16]>) -> String {
81    scope.map_or_else(
82        || "irohv1".to_owned(),
83        |bytes| format!("syncweb-{}", hex::encode(&bytes[..8])),
84    )
85}
86
87/// Derive a discovery scope (first 16 bytes of the network id) from a network
88/// name. `None` for networks without a name, keeping the default daemon
89/// compatible with plain iroh peers on the LAN.
90#[must_use]
91pub fn discovery_scope(network: Option<&str>) -> Option<[u8; 16]> {
92    let name = network?;
93    let id = crate::net::NetworkId::from_name(name);
94    let bytes = id.as_bytes();
95    let mut scope = [0_u8; 16];
96    scope.copy_from_slice(&bytes[..16]);
97    Some(scope)
98}
99
100/// Registers the scoped mDNS and UDP beacon address lookups on an endpoint.
101fn register_discovery(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
102    if discovery.mdns {
103        register_mdns(endpoint, discovery);
104    }
105    if discovery.beacon {
106        register_beacon(endpoint, discovery);
107    }
108}
109
110fn register_mdns(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
111    let service_name = mdns_service_name(discovery.scope.as_ref());
112    match iroh_mdns_address_lookup::MdnsAddressLookup::builder()
113        .service_name(service_name)
114        .build(endpoint.id())
115        .map_err(|error| SyncwebError::operation("failed to build mDNS address lookup", error))
116        .and_then(|mdns| {
117            endpoint
118                .address_lookup()
119                .map_err(|error| SyncwebError::operation("no address lookup service available", error))
120                .map(|lookup| lookup.add(mdns))
121        }) {
122        Ok(()) => tracing::debug!("mDNS address lookup registered"),
123        Err(error) => tracing::warn!(
124            %error,
125            "mDNS address lookup registration failed — local peer discovery unavailable"
126        ),
127    }
128}
129
130fn register_beacon(endpoint: &iroh::Endpoint, discovery: &DiscoveryConfig) {
131    let Ok(beacon) = BeaconAddressLookup::new(
132        endpoint.id(),
133        discovery.scope,
134        discovery.beacon_base_port,
135        discovery.beacon_interval,
136        discovery.interface.as_deref(),
137    ) else {
138        return;
139    };
140    match endpoint.address_lookup() {
141        Ok(lookup) => {
142            lookup.add(beacon);
143            tracing::debug!("beacon address lookup registered");
144        }
145        Err(error) => tracing::warn!(
146            %error,
147            "beacon address lookup registration failed — no address lookup service available"
148        ),
149    }
150}
151
152pub struct IrohNode {
153    endpoint: iroh::Endpoint,
154    router: Arc<Router>,
155    blobs: iroh_blobs::BlobsProtocol,
156    docs: Docs,
157    gossip: Arc<Gossip>,
158    blob_store: BlobStore,
159    docs_engine: DocsEngine,
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 topic_tracker = TopicTracker::new(&gossip, &endpoint);
259
260        Ok(Self {
261            endpoint,
262            router: Arc::new(router),
263            blobs,
264            docs,
265            gossip,
266            blob_store,
267            docs_engine,
268            topic_tracker,
269        })
270    }
271
272    #[must_use]
273    pub const fn endpoint(&self) -> &iroh::Endpoint {
274        &self.endpoint
275    }
276
277    #[must_use]
278    pub const fn blobs(&self) -> &BlobsProtocol {
279        &self.blobs
280    }
281
282    #[must_use]
283    pub const fn docs(&self) -> &Docs {
284        &self.docs
285    }
286
287    #[must_use]
288    pub fn gossip(&self) -> &Gossip {
289        &self.gossip
290    }
291
292    #[must_use]
293    pub const fn blob_store(&self) -> &BlobStore {
294        &self.blob_store
295    }
296
297    #[must_use]
298    pub const fn docs_engine(&self) -> &DocsEngine {
299        &self.docs_engine
300    }
301
302    #[must_use]
303    pub const fn topic_tracker(&self) -> &TopicTracker {
304        &self.topic_tracker
305    }
306
307    #[must_use]
308    pub fn is_running(&self) -> bool {
309        !self.router.is_shutdown()
310    }
311
312    /// # Errors
313    ///
314    /// Returns an error if the router fails to shutdown properly.
315    pub async fn stop(&self) -> Result<()> {
316        self.router
317            .shutdown()
318            .await
319            .map_err(|error| SyncwebError::operation("failed to stop node router", error))?;
320        Ok(())
321    }
322
323    /// # Errors
324    ///
325    /// Returns an error if stopping the node fails.
326    pub async fn shutdown(self) -> Result<()> {
327        self.stop().await
328    }
329}
330
331impl Drop for IrohNode {
332    fn drop(&mut self) {
333        if !self.router.is_shutdown() {
334            tracing::warn!("IrohNode dropped without calling stop() — router shutdown may be incomplete");
335        }
336    }
337}