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#[derive(Clone, Debug)]
35#[non_exhaustive]
36pub struct DiscoveryConfig {
37 pub mdns: bool,
39 pub beacon: bool,
41 pub beacon_base_port: u16,
43 pub beacon_interval: Duration,
45 pub interface: Option<String>,
47 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 #[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
79fn 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#[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
100fn 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 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 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_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 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 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}