Skip to main content

syncweb_core/net/
network_manager.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::{Arc, RwLock};
3
4use iroh::PublicKey;
5use iroh_docs::NamespaceId;
6use iroh_gossip::{TopicId, api::GossipTopic};
7
8use crate::node::gossip_service::GossipService;
9use crate::storage::node_db::NodeDatabase;
10use crate::{Result, SyncwebError};
11
12use super::network::{Network, NetworkId, NetworkOptions, NetworkTicket, network_topic};
13use super::network_log::{NetworkEventType, NetworkLogger};
14
15/// Persistent manager for network membership and folder associations.
16#[derive(Clone, Debug)]
17pub struct NetworkManager {
18    db: NodeDatabase,
19    local_node: PublicKey,
20    networks: HashMap<NetworkId, Network>,
21    logger: Option<NetworkLogger>,
22    member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
23}
24
25impl NetworkManager {
26    /// Open the network database.
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if existing state cannot be read.
31    pub fn new(
32        db: NodeDatabase,
33        local_node: PublicKey,
34        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
35    ) -> Result<Self> {
36        let networks = db
37            .list_networks()?
38            .into_iter()
39            .map(|network| (network.id, network))
40            .collect();
41        let mgr = Self {
42            db,
43            local_node,
44            networks,
45            logger: None,
46            member_keys,
47        };
48        mgr.sync_member_keys();
49        Ok(mgr)
50    }
51
52    /// Open the network database with a network logger.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if existing state cannot be read.
57    pub fn with_logger(
58        db: NodeDatabase,
59        local_node: PublicKey,
60        logger: NetworkLogger,
61        member_keys: Arc<RwLock<HashSet<iroh::PublicKey>>>,
62    ) -> Result<Self> {
63        let mut mgr = Self::new(db, local_node, member_keys)?;
64        mgr.logger = Some(logger);
65        Ok(mgr)
66    }
67
68    /// Return a reference to the network logger, if set.
69    #[must_use]
70    pub const fn logger(&self) -> &Option<NetworkLogger> {
71        &self.logger
72    }
73
74    /// Set the network logger after construction.
75    pub fn set_logger(&mut self, logger: NetworkLogger) {
76        self.logger = Some(logger);
77    }
78
79    /// Return a reference to the underlying database.
80    #[must_use]
81    pub const fn database(&self) -> &NodeDatabase {
82        &self.db
83    }
84
85    /// Return the local node's public key.
86    #[must_use]
87    pub const fn local_node(&self) -> &iroh::PublicKey {
88        &self.local_node
89    }
90
91    /// Create and persist a network owned by the local node.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error for an empty/duplicate name or failed persistence.
96    pub fn create(&mut self, name: &str, options: NetworkOptions) -> Result<NetworkId> {
97        self.create_with_doc_ticket(name, options, None)
98    }
99
100    /// Create a network with an optional membership doc ticket.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error for an empty/duplicate name or failed persistence.
105    pub fn create_with_doc_ticket(
106        &mut self,
107        name: &str,
108        options: NetworkOptions,
109        doc_ticket: Option<String>,
110    ) -> Result<NetworkId> {
111        let normalized = name.trim();
112        if normalized.is_empty() {
113            return Err(SyncwebError::InvalidConfig("network name cannot be empty".to_owned()));
114        }
115        let id = NetworkId::from_name(normalized);
116        if self.networks.contains_key(&id) {
117            return Err(SyncwebError::InvalidConfig(format!(
118                "network {normalized:?} already exists"
119            )));
120        }
121        let mut network = Network::new(normalized, self.local_node, options);
122        network.doc_ticket = doc_ticket;
123        self.db.create_network(&network)?;
124        self.networks.insert(id, network.clone());
125        self.log_event(
126            &id,
127            NetworkEventType::MemberAdded,
128            Some(&self.local_node),
129            Some("created"),
130        );
131        if network.is_invite_only() {
132            self.log_event(
133                &id,
134                NetworkEventType::TicketCreated,
135                None,
136                Some("invite-only network created"),
137            );
138        }
139        Ok(id)
140    }
141
142    /// Join and persist the network represented by a ticket.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the ticket is for another device or persistence fails.
147    pub fn join(&mut self, ticket: NetworkTicket) -> Result<NetworkId> {
148        if ticket.name.trim().is_empty() || NetworkId::from_name(ticket.name.trim()) != ticket.network_id {
149            return Err(SyncwebError::InvalidTicket(
150                "network ticket ID does not match its network name".to_owned(),
151            ));
152        }
153        if ticket.invited_node.is_some_and(|invited| invited != self.local_node) {
154            return Err(SyncwebError::InvalidTicket(
155                "network ticket was issued for another device".to_owned(),
156            ));
157        }
158        if self.networks.contains_key(&ticket.network_id) {
159            return Err(SyncwebError::InvalidConfig(format!(
160                "already joined network {}",
161                ticket.network_id,
162            )));
163        }
164        let mut members = ticket.members;
165        members.insert(self.local_node);
166        let network = Network {
167            id: ticket.network_id,
168            name: ticket.name,
169            label: ticket.label,
170            topic: network_topic(ticket.network_id),
171            owner: ticket.owner,
172            members,
173            folders: ticket.folders,
174            shared_secret: ticket.shared_secret,
175            doc_ticket: ticket.doc_ticket,
176        };
177        let id = network.id;
178        self.db.create_network(&network)?;
179        self.networks.insert(id, network);
180        self.log_event(
181            &id,
182            NetworkEventType::MemberAdded,
183            Some(&self.local_node),
184            Some("joined via ticket"),
185        );
186        self.sync_member_keys();
187        self.log_event(&id, NetworkEventType::TicketAccepted, None, Some("network joined"));
188        Ok(id)
189    }
190
191    /// Leave a network and remove its local state.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the network does not exist or persistence fails.
196    pub fn leave(&mut self, id: NetworkId) -> Result<()> {
197        self.log_event(
198            &id,
199            NetworkEventType::MemberRemoved,
200            Some(&self.local_node),
201            Some("left network"),
202        );
203        self.networks
204            .remove(&id)
205            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
206        self.sync_member_keys();
207        self.db.delete_network(id)
208    }
209
210    /// Generate a device-bound invitation and add the device as a member.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the network does not exist, the local node is not
215    /// its owner, or persistence fails.
216    pub fn invite(&mut self, id: NetworkId, device: PublicKey) -> Result<NetworkTicket> {
217        let network = self.network_mut_as_owner(id)?;
218        network.members.insert(device);
219        let ticket = NetworkTicket {
220            network_id: network.id,
221            name: network.name.clone(),
222            label: network.label.clone(),
223            owner: network.owner,
224            invited_node: Some(device),
225            members: network.members.clone(),
226            folders: network.folders.clone(),
227            shared_secret: network.shared_secret,
228            doc_ticket: network.doc_ticket.clone(),
229        };
230        self.db.add_member(id, device)?;
231        self.sync_member_keys();
232        self.log_event(&id, NetworkEventType::MemberAdded, Some(&device), Some("invited"));
233        self.log_event(
234            &id,
235            NetworkEventType::TicketCreated,
236            None,
237            Some("device-bound ticket generated"),
238        );
239        Ok(ticket)
240    }
241
242    /// Generate an invitation usable by any device.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the network does not exist or the local node is not its owner.
247    pub fn invite_any(&self, id: NetworkId) -> Result<NetworkTicket> {
248        let network = self.network_as_owner(id)?;
249        self.log_event(
250            &id,
251            NetworkEventType::TicketCreated,
252            None,
253            Some("open ticket generated"),
254        );
255        Ok(NetworkTicket {
256            network_id: network.id,
257            name: network.name.clone(),
258            label: network.label.clone(),
259            owner: network.owner,
260            invited_node: None,
261            members: network.members.clone(),
262            folders: network.folders.clone(),
263            shared_secret: network.shared_secret,
264            doc_ticket: network.doc_ticket.clone(),
265        })
266    }
267
268    /// Remove a member from a locally owned network.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the network does not exist, authorization fails,
273    /// the owner is targeted, or persistence fails.
274    pub fn kick(&mut self, id: NetworkId, device: &PublicKey) -> Result<()> {
275        let network = self.network_mut_as_owner(id)?;
276        if network.owner == *device {
277            return Err(SyncwebError::InvalidConfig(
278                "the network owner cannot be kicked".to_owned(),
279            ));
280        }
281        if !network.members.remove(device) {
282            return Err(SyncwebError::InvalidConfig("device is not a network member".to_owned()));
283        }
284        self.db.remove_member(id, device)?;
285        self.sync_member_keys();
286        self.log_event(&id, NetworkEventType::MemberRemoved, Some(device), Some("kicked"));
287        Ok(())
288    }
289
290    /// Associate a folder with a network.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the network does not exist or persistence fails.
295    pub fn add_folder(&mut self, id: NetworkId, folder: NamespaceId) -> Result<()> {
296        self.network_mut(id)?.folders.insert(folder);
297        self.log_event(
298            &id,
299            NetworkEventType::FolderAdded,
300            None,
301            Some(&format!("folder {folder}")),
302        );
303        self.db.add_folder_to_network(id, folder)
304    }
305
306    /// Remove a folder association without changing the folder itself.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if the network does not exist or persistence fails.
311    pub fn remove_folder(&mut self, id: NetworkId, folder: NamespaceId) -> Result<()> {
312        self.network_mut(id)?.folders.remove(&folder);
313        self.log_event(
314            &id,
315            NetworkEventType::FolderRemoved,
316            None,
317            Some(&format!("folder {folder}")),
318        );
319        self.db.remove_folder_from_network(id, folder)
320    }
321
322    /// Subscribe to the network's deterministic gossip topic.
323    ///
324    /// The returned topic must be retained by the caller for membership to
325    /// remain active.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the network does not exist or gossip rejects the
330    /// subscription.
331    pub async fn subscribe(&self, id: NetworkId, gossip: &GossipService) -> Result<GossipTopic> {
332        let network = self
333            .networks
334            .get(&id)
335            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
336        if !network.is_member(&self.local_node) {
337            return Err(SyncwebError::InvalidConfig(
338                "local device is not a member of this network".to_owned(),
339            ));
340        }
341        let mut bootstrap = network
342            .members
343            .iter()
344            .copied()
345            .filter(|member| *member != self.local_node)
346            .collect::<Vec<_>>();
347        bootstrap.sort_unstable();
348        gossip.subscribe(network.topic, bootstrap).await
349    }
350
351    /// Return the gossip topic associated with a network.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the network does not exist.
356    pub fn topic(&self, id: NetworkId) -> Result<TopicId> {
357        self.networks
358            .get(&id)
359            .map(|network| network.topic)
360            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))
361    }
362
363    #[must_use]
364    pub fn list(&self) -> Vec<&Network> {
365        let mut networks = self.networks.values().collect::<Vec<_>>();
366        networks.sort_by(|left, right| left.name.cmp(&right.name));
367        networks
368    }
369
370    #[must_use]
371    pub fn get(&self, id: &NetworkId) -> Option<&Network> {
372        self.networks.get(id)
373    }
374
375    #[must_use]
376    pub fn get_by_name(&self, name: &str) -> Option<&Network> {
377        self.networks.values().find(|network| network.name == name)
378    }
379
380    /// Check if the local node can access a given folder namespace through any network.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error if the database query fails.
385    pub fn can_access_folder(&self, namespace_id: &NamespaceId) -> Result<bool> {
386        self.db
387            .can_access_folder(&namespace_id.to_string(), &self.local_node.to_string())
388    }
389
390    /// Return the network IDs (as strings) that contain a given folder.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if the database query fails.
395    pub fn networks_for_folder(&self, namespace_id: &NamespaceId) -> Result<Vec<String>> {
396        self.db.networks_for_folder(&namespace_id.to_string())
397    }
398
399    /// Return the first network ID that contains a given folder, if any.
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if the database query fails.
404    pub fn network_for_folder(&self, namespace_id: &NamespaceId) -> Result<Option<String>> {
405        Ok(self
406            .db
407            .networks_for_folder(&namespace_id.to_string())?
408            .into_iter()
409            .next())
410    }
411
412    /// List all folder namespace IDs associated with a network.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the database query fails.
417    pub fn folders_for_network(&self, network_id: &NetworkId) -> Result<Vec<NamespaceId>> {
418        let folders = self.db.folders_for_network(&network_id.to_string())?;
419        folders
420            .into_iter()
421            .map(|f| {
422                f.parse()
423                    .map_err(|error| SyncwebError::operation("invalid namespace in database", error))
424            })
425            .collect()
426    }
427
428    /// List all members of a network.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if the database query fails.
433    pub fn members_of_network(&self, network_id: &NetworkId) -> Result<Vec<iroh::PublicKey>> {
434        let members = self.db.members_of_network(&network_id.to_string())?;
435        members
436            .into_iter()
437            .map(|m| {
438                m.parse()
439                    .map_err(|error| SyncwebError::operation("invalid member key in database", error))
440            })
441            .collect()
442    }
443
444    /// Sync the `member_keys` set from all networks.
445    fn sync_member_keys(&self) {
446        let keys: HashSet<iroh::PublicKey> = self.networks.values().flat_map(|n| n.members.iter().copied()).collect();
447        if let Ok(mut guard) = self.member_keys.write() {
448            *guard = keys;
449        }
450    }
451
452    fn log_event(
453        &self,
454        network_id: &NetworkId,
455        event: NetworkEventType,
456        peer: Option<&iroh::PublicKey>,
457        details: Option<&str>,
458    ) {
459        if let Some(ref logger) = self.logger {
460            let peer_str = peer.map(std::string::ToString::to_string);
461            let _ = logger.record_event(&network_id.to_string(), event, peer_str.as_deref(), details);
462        }
463    }
464
465    fn network_mut(&mut self, id: NetworkId) -> Result<&mut Network> {
466        self.networks
467            .get_mut(&id)
468            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))
469    }
470
471    fn network_as_owner(&self, id: NetworkId) -> Result<&Network> {
472        let network = self
473            .networks
474            .get(&id)
475            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
476        if network.owner != self.local_node {
477            return Err(SyncwebError::InvalidConfig(
478                "only the network owner can manage invitations".to_owned(),
479            ));
480        }
481        Ok(network)
482    }
483
484    fn network_mut_as_owner(&mut self, id: NetworkId) -> Result<&mut Network> {
485        self.network_as_owner(id)?;
486        self.network_mut(id)
487    }
488}