Skip to main content

syncweb_core/net/
network_manager.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::{Path, PathBuf},
4};
5
6use iroh::PublicKey;
7use iroh_docs::NamespaceId;
8use iroh_gossip::{TopicId, api::GossipTopic};
9use serde::{Deserialize, Serialize};
10
11use crate::node::gossip_service::GossipService;
12use crate::{Result, SyncwebError};
13
14use super::network::{Network, NetworkId, NetworkOptions, NetworkTicket, network_topic, parse_public_key};
15
16/// Persistent manager for network membership and folder associations.
17#[derive(Clone, Debug)]
18pub struct NetworkManager {
19    path: PathBuf,
20    local_node: PublicKey,
21    networks: HashMap<NetworkId, Network>,
22}
23
24impl NetworkManager {
25    /// Open the network database at `path`.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if existing state cannot be read or parsed.
30    pub fn new(path_arg: impl Into<PathBuf>, local_node: PublicKey) -> Result<Self> {
31        let path = path_arg.into();
32        let networks = load_networks(&path)?
33            .into_iter()
34            .map(|network| (network.id, network))
35            .collect();
36        Ok(Self {
37            path,
38            local_node,
39            networks,
40        })
41    }
42
43    /// Create and persist a network owned by the local node.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error for an empty/duplicate name or failed persistence.
48    pub fn create(&mut self, name: &str, options: NetworkOptions) -> Result<NetworkId> {
49        let normalized = name.trim();
50        if normalized.is_empty() {
51            return Err(SyncwebError::InvalidConfig("network name cannot be empty".to_owned()));
52        }
53        let id = NetworkId::from_name(normalized);
54        if self.networks.contains_key(&id) {
55            return Err(SyncwebError::InvalidConfig(format!(
56                "network {normalized:?} already exists"
57            )));
58        }
59        self.networks
60            .insert(id, Network::new(normalized, self.local_node, options));
61        self.save()?;
62        Ok(id)
63    }
64
65    /// Join and persist the network represented by a ticket.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the ticket is for another device or persistence fails.
70    pub fn join(&mut self, ticket: NetworkTicket) -> Result<NetworkId> {
71        if ticket.name.trim().is_empty() || NetworkId::from_name(ticket.name.trim()) != ticket.network_id {
72            return Err(SyncwebError::InvalidTicket(
73                "network ticket ID does not match its network name".to_owned(),
74            ));
75        }
76        if ticket.invited_node.is_some_and(|invited| invited != self.local_node) {
77            return Err(SyncwebError::InvalidTicket(
78                "network ticket was issued for another device".to_owned(),
79            ));
80        }
81        let mut members = ticket.members;
82        members.insert(self.local_node);
83        let network = Network {
84            id: ticket.network_id,
85            name: ticket.name,
86            label: ticket.label,
87            topic: network_topic(ticket.network_id),
88            owner: ticket.owner,
89            members,
90            folders: ticket.folders,
91            shared_secret: ticket.shared_secret,
92        };
93        let id = network.id;
94        self.networks.insert(id, network);
95        self.save()?;
96        Ok(id)
97    }
98
99    /// Leave a network and remove its local state.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the network does not exist or persistence fails.
104    pub fn leave(&mut self, id: NetworkId) -> Result<()> {
105        self.networks
106            .remove(&id)
107            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
108        self.save()
109    }
110
111    /// Generate a device-bound invitation and add the device as a member.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the network does not exist, the local node is not
116    /// its owner, or persistence fails.
117    pub fn invite(&mut self, id: NetworkId, device: PublicKey) -> Result<NetworkTicket> {
118        let network = self.network_mut_as_owner(id)?;
119        network.members.insert(device);
120        let ticket = NetworkTicket {
121            network_id: network.id,
122            name: network.name.clone(),
123            label: network.label.clone(),
124            owner: network.owner,
125            invited_node: Some(device),
126            members: network.members.clone(),
127            folders: network.folders.clone(),
128            shared_secret: network.shared_secret,
129        };
130        self.save()?;
131        Ok(ticket)
132    }
133
134    /// Generate an invitation usable by any device.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the network does not exist or the local node is not its owner.
139    pub fn invite_any(&self, id: NetworkId) -> Result<NetworkTicket> {
140        let network = self.network_as_owner(id)?;
141        Ok(NetworkTicket {
142            network_id: network.id,
143            name: network.name.clone(),
144            label: network.label.clone(),
145            owner: network.owner,
146            invited_node: None,
147            members: network.members.clone(),
148            folders: network.folders.clone(),
149            shared_secret: network.shared_secret,
150        })
151    }
152
153    /// Remove a member from a locally owned network.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the network does not exist, authorization fails,
158    /// the owner is targeted, or persistence fails.
159    pub fn kick(&mut self, id: NetworkId, device: &PublicKey) -> Result<()> {
160        let network = self.network_mut_as_owner(id)?;
161        if network.owner == *device {
162            return Err(SyncwebError::InvalidConfig(
163                "the network owner cannot be kicked".to_owned(),
164            ));
165        }
166        if !network.members.remove(device) {
167            return Err(SyncwebError::InvalidConfig("device is not a network member".to_owned()));
168        }
169        self.save()
170    }
171
172    /// Associate a folder with a network.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the network does not exist or persistence fails.
177    pub fn add_folder(&mut self, id: NetworkId, folder: NamespaceId) -> Result<()> {
178        self.network_mut(id)?.folders.insert(folder);
179        self.save()
180    }
181
182    /// Remove a folder association without changing the folder itself.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the network does not exist or persistence fails.
187    pub fn remove_folder(&mut self, id: NetworkId, folder: NamespaceId) -> Result<()> {
188        self.network_mut(id)?.folders.remove(&folder);
189        self.save()
190    }
191
192    /// Subscribe to the network's deterministic gossip topic.
193    ///
194    /// The returned topic must be retained by the caller for membership to
195    /// remain active.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the network does not exist or gossip rejects the
200    /// subscription.
201    pub async fn subscribe(&self, id: NetworkId, gossip: &GossipService) -> Result<GossipTopic> {
202        let network = self
203            .networks
204            .get(&id)
205            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
206        if !network.is_member(&self.local_node) {
207            return Err(SyncwebError::InvalidConfig(
208                "local device is not a member of this network".to_owned(),
209            ));
210        }
211        let mut bootstrap = network
212            .members
213            .iter()
214            .copied()
215            .filter(|member| *member != self.local_node)
216            .collect::<Vec<_>>();
217        bootstrap.sort_unstable();
218        gossip.subscribe(network.topic, bootstrap).await
219    }
220
221    /// Return the gossip topic associated with a network.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the network does not exist.
226    pub fn topic(&self, id: NetworkId) -> Result<TopicId> {
227        self.networks
228            .get(&id)
229            .map(|network| network.topic)
230            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))
231    }
232
233    #[must_use]
234    pub fn list(&self) -> Vec<&Network> {
235        let mut networks = self.networks.values().collect::<Vec<_>>();
236        networks.sort_by(|left, right| left.name.cmp(&right.name));
237        networks
238    }
239
240    #[must_use]
241    pub fn get(&self, id: &NetworkId) -> Option<&Network> {
242        self.networks.get(id)
243    }
244
245    #[must_use]
246    pub fn get_by_name(&self, name: &str) -> Option<&Network> {
247        self.networks.values().find(|network| network.name == name)
248    }
249
250    fn network_mut(&mut self, id: NetworkId) -> Result<&mut Network> {
251        self.networks
252            .get_mut(&id)
253            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))
254    }
255
256    fn network_as_owner(&self, id: NetworkId) -> Result<&Network> {
257        let network = self
258            .networks
259            .get(&id)
260            .ok_or_else(|| SyncwebError::FolderNotFound(format!("network {id}")))?;
261        if network.owner != self.local_node {
262            return Err(SyncwebError::InvalidConfig(
263                "only the network owner can manage invitations".to_owned(),
264            ));
265        }
266        Ok(network)
267    }
268
269    fn network_mut_as_owner(&mut self, id: NetworkId) -> Result<&mut Network> {
270        if self.network_as_owner(id)?.owner != self.local_node {
271            return Err(SyncwebError::InvalidConfig(
272                "only the network owner can manage members".to_owned(),
273            ));
274        }
275        self.network_mut(id)
276    }
277
278    fn save(&self) -> Result<()> {
279        if let Some(parent) = self.path.parent() {
280            std::fs::create_dir_all(parent)?;
281        }
282        let records = self.networks.values().map(NetworkRecord::from).collect::<Vec<_>>();
283        let contents = serde_json::to_vec_pretty(&records)
284            .map_err(|error| SyncwebError::operation("failed to serialize networks", error))?;
285        let temporary = self.path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
286        std::fs::write(&temporary, contents)
287            .map_err(|error| SyncwebError::operation("failed to write network state", error))?;
288        std::fs::rename(&temporary, &self.path)
289            .map_err(|error| SyncwebError::operation("failed to persist network state", error))
290    }
291}
292
293#[derive(Deserialize, Serialize)]
294struct NetworkRecord {
295    id: String,
296    name: String,
297    label: String,
298    owner: String,
299    members: Vec<String>,
300    folders: Vec<String>,
301    shared_secret: Option<String>,
302}
303
304impl From<&Network> for NetworkRecord {
305    fn from(network: &Network) -> Self {
306        Self {
307            id: network.id.to_string(),
308            name: network.name.clone(),
309            label: network.label.clone(),
310            owner: network.owner.to_string(),
311            members: network.members.iter().map(ToString::to_string).collect(),
312            folders: network.folders.iter().map(ToString::to_string).collect(),
313            shared_secret: network.shared_secret.map(hex::encode),
314        }
315    }
316}
317
318impl TryFrom<NetworkRecord> for Network {
319    type Error = SyncwebError;
320
321    fn try_from(record: NetworkRecord) -> Result<Self> {
322        if record.name.trim().is_empty() {
323            return Err(SyncwebError::InvalidConfig("network name cannot be empty".to_owned()));
324        }
325        let id: NetworkId = record.id.parse()?;
326        if id != NetworkId::from_name(record.name.trim()) {
327            return Err(SyncwebError::InvalidConfig(
328                "network ID does not match its name".to_owned(),
329            ));
330        }
331        let members = record
332            .members
333            .into_iter()
334            .map(|member| parse_public_key(&member))
335            .collect::<Result<HashSet<_>>>()?;
336        let folders = record
337            .folders
338            .into_iter()
339            .map(|namespace| {
340                namespace
341                    .parse()
342                    .map_err(|error| SyncwebError::InvalidConfig(format!("invalid folder namespace: {error}")))
343            })
344            .collect::<Result<HashSet<_>>>()?;
345        let shared_secret = record
346            .shared_secret
347            .map(|secret| {
348                let bytes = hex::decode(secret)
349                    .map_err(|error| SyncwebError::InvalidConfig(format!("invalid network secret: {error}")))?;
350                bytes.try_into().map_err(|_length_error| {
351                    SyncwebError::InvalidConfig("network secret must contain 32 bytes".to_owned())
352                })
353            })
354            .transpose()?;
355        Ok(Self {
356            id,
357            name: record.name,
358            label: record.label,
359            topic: network_topic(id),
360            owner: parse_public_key(&record.owner)?,
361            members,
362            folders,
363            shared_secret,
364        })
365    }
366}
367
368fn load_networks(path: &Path) -> Result<Vec<Network>> {
369    if !path.exists() {
370        return Ok(Vec::new());
371    }
372    let bytes = std::fs::read(path).map_err(|error| SyncwebError::operation("failed to read network state", error))?;
373    let records: Vec<NetworkRecord> = serde_json::from_slice(&bytes)
374        .map_err(|error| SyncwebError::operation("failed to parse network state", error))?;
375    records.into_iter().map(Network::try_from).collect()
376}