Skip to main content

peat_protocol/network/
peer_config.rs

1//! Static peer configuration for Automerge+Iroh mesh networking
2//!
3//! This module provides TOML-based static peer configuration for establishing
4//! a mesh network without requiring relay servers or mDNS discovery.
5//!
6//! # Phase 6.1 Implementation
7//!
8//! Simple static mesh configuration for testing and small deployments.
9//! Production deployments will add relay and mDNS in Phase 7.
10//!
11//! # Example Configuration
12//!
13//! ```toml
14//! [local]
15//! bind_address = "127.0.0.1:9000"
16//!
17//! # Formation key for shared secret authentication (similar to Ditto SharedKey)
18//! [formation]
19//! id = "alpha-company"
20//! shared_key = "base64-encoded-32-byte-key"
21//!
22//! [[peers]]
23//! name = "node-1"
24//! node_id = "6eb2a534751444f1353b29aa307c78c1f72acfbb06bb8696103dfeede1f4f854"
25//! addresses = ["127.0.0.1:9001"]
26//! ```
27
28#[cfg(feature = "automerge-backend")]
29use crate::security::FormationKey;
30#[cfg(feature = "automerge-backend")]
31use anyhow::{Context, Result};
32#[cfg(feature = "automerge-backend")]
33use iroh::EndpointId;
34#[cfg(feature = "automerge-backend")]
35use serde::{Deserialize, Serialize};
36#[cfg(feature = "automerge-backend")]
37use std::net::SocketAddr;
38#[cfg(feature = "automerge-backend")]
39use std::path::Path;
40
41/// Static peer mesh configuration
42#[cfg(feature = "automerge-backend")]
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PeerConfig {
45    /// Optional local node configuration
46    #[serde(default)]
47    pub local: LocalConfig,
48    /// Optional formation configuration for shared secret authentication
49    #[serde(default)]
50    pub formation: Option<FormationConfig>,
51    /// List of static peers to connect to
52    #[serde(default)]
53    pub peers: Vec<PeerInfo>,
54}
55
56/// Formation configuration for shared secret authentication
57///
58/// Similar to Ditto's SharedKey identity - all nodes in the formation
59/// must have the same formation ID and shared key to sync.
60#[cfg(feature = "automerge-backend")]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct FormationConfig {
63    /// Formation identifier (e.g., "alpha-company")
64    pub id: String,
65    /// Base64-encoded 32-byte shared secret
66    pub shared_key: String,
67}
68
69/// Local node configuration
70#[cfg(feature = "automerge-backend")]
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct LocalConfig {
73    /// Bind address for this node (e.g., "127.0.0.1:9000" or "0.0.0.0:0")
74    #[serde(default = "default_bind_address")]
75    pub bind_address: String,
76    /// Optional: Override node ID (hex-encoded PublicKey)
77    pub node_id: Option<String>,
78}
79
80/// Static peer information
81#[cfg(feature = "automerge-backend")]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PeerInfo {
84    /// Human-readable peer name
85    pub name: String,
86    /// Iroh PublicKey (EndpointId) in hex format
87    pub node_id: String,
88    /// Direct addresses to try connecting to
89    pub addresses: Vec<String>,
90    /// Optional relay URL (Phase 7)
91    pub relay_url: Option<String>,
92}
93
94#[cfg(feature = "automerge-backend")]
95fn default_bind_address() -> String {
96    "0.0.0.0:0".to_string()
97}
98
99#[cfg(feature = "automerge-backend")]
100impl Default for LocalConfig {
101    fn default() -> Self {
102        Self {
103            bind_address: default_bind_address(),
104            node_id: None,
105        }
106    }
107}
108
109#[cfg(feature = "automerge-backend")]
110impl PeerConfig {
111    /// Load peer configuration from a TOML file
112    ///
113    /// # Arguments
114    ///
115    /// * `path` - Path to TOML configuration file
116    ///
117    /// # Example
118    ///
119    /// ```ignore
120    /// let config = PeerConfig::from_file("peers.toml")?;
121    /// ```
122    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
123        let contents =
124            std::fs::read_to_string(path.as_ref()).context("Failed to read peer config file")?;
125        Self::from_toml(&contents)
126    }
127
128    /// Parse peer configuration from TOML string
129    pub fn from_toml(toml_str: &str) -> Result<Self> {
130        toml::from_str(toml_str).context("Failed to parse TOML peer config")
131    }
132
133    /// Create an empty configuration
134    pub fn empty() -> Self {
135        Self {
136            local: LocalConfig::default(),
137            formation: None,
138            peers: Vec::new(),
139        }
140    }
141
142    /// Get the formation key if configured
143    ///
144    /// Returns `None` if no formation is configured, or an error if the
145    /// shared key is invalid.
146    pub fn formation_key(&self) -> Result<Option<FormationKey>> {
147        match &self.formation {
148            Some(config) => {
149                let key = FormationKey::from_base64(&config.id, &config.shared_key)
150                    .map_err(|e| anyhow::anyhow!("Invalid formation key: {}", e))?;
151                Ok(Some(key))
152            }
153            None => Ok(None),
154        }
155    }
156
157    /// Check if formation authentication is required
158    pub fn requires_formation_auth(&self) -> bool {
159        self.formation.is_some()
160    }
161
162    /// Parse bind address as SocketAddr
163    pub fn bind_socket_addr(&self) -> Result<SocketAddr> {
164        self.local
165            .bind_address
166            .parse()
167            .context("Invalid bind address")
168    }
169
170    /// Get peer by name
171    pub fn get_peer(&self, name: &str) -> Option<&PeerInfo> {
172        self.peers.iter().find(|p| p.name == name)
173    }
174}
175
176#[cfg(feature = "automerge-backend")]
177impl PeerInfo {
178    /// Parse node_id as EndpointId
179    pub fn endpoint_id(&self) -> Result<EndpointId> {
180        // Decode hex string to bytes
181        let bytes = hex::decode(&self.node_id).context("Failed to decode node_id hex")?;
182
183        // EndpointId is a 32-byte PublicKey
184        if bytes.len() != 32 {
185            anyhow::bail!(
186                "Invalid node_id length: expected 32 bytes, got {}",
187                bytes.len()
188            );
189        }
190
191        // Convert Vec<u8> to [u8; 32]
192        let mut array = [0u8; 32];
193        array.copy_from_slice(&bytes);
194
195        // Convert bytes to EndpointId
196        EndpointId::from_bytes(&array).context("Failed to construct EndpointId from bytes")
197    }
198
199    /// Parse addresses as SocketAddr list
200    pub fn socket_addrs(&self) -> Result<Vec<SocketAddr>> {
201        self.addresses
202            .iter()
203            .map(|addr| {
204                addr.parse()
205                    .with_context(|| format!("Invalid address: {}", addr))
206            })
207            .collect()
208    }
209}
210
211#[cfg(all(test, feature = "automerge-backend"))]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_parse_empty_config() {
217        let config = PeerConfig::from_toml("").unwrap();
218        assert_eq!(config.peers.len(), 0);
219        assert_eq!(config.local.bind_address, "0.0.0.0:0");
220    }
221
222    #[test]
223    fn test_parse_local_config() {
224        let toml = r#"
225            [local]
226            bind_address = "127.0.0.1:9000"
227        "#;
228
229        let config = PeerConfig::from_toml(toml).unwrap();
230        assert_eq!(config.local.bind_address, "127.0.0.1:9000");
231        assert_eq!(config.bind_socket_addr().unwrap().port(), 9000);
232    }
233
234    #[test]
235    fn test_parse_peers() {
236        let toml = r#"
237            [[peers]]
238            name = "node-1"
239            node_id = "6eb2a534751444f1353b29aa307c78c1f72acfbb06bb8696103dfeede1f4f854"
240            addresses = ["127.0.0.1:9001"]
241
242            [[peers]]
243            name = "node-2"
244            node_id = "b654917328aea8ccfae00463d63642eb4904bd276fecb4caf94dd740a76b5567"
245            addresses = ["127.0.0.1:9002", "192.168.1.100:9002"]
246        "#;
247
248        let config = PeerConfig::from_toml(toml).unwrap();
249        assert_eq!(config.peers.len(), 2);
250
251        let peer1 = &config.peers[0];
252        assert_eq!(peer1.name, "node-1");
253        assert_eq!(peer1.addresses.len(), 1);
254
255        let peer2 = &config.peers[1];
256        assert_eq!(peer2.name, "node-2");
257        assert_eq!(peer2.addresses.len(), 2);
258
259        // Test SocketAddr parsing
260        let addrs = peer2.socket_addrs().unwrap();
261        assert_eq!(addrs.len(), 2);
262        assert_eq!(addrs[0].port(), 9002);
263    }
264
265    #[test]
266    fn test_endpoint_id_parsing() {
267        let peer = PeerInfo {
268            name: "test".to_string(),
269            node_id: "6eb2a534751444f1353b29aa307c78c1f72acfbb06bb8696103dfeede1f4f854".to_string(),
270            addresses: vec![],
271            relay_url: None,
272        };
273
274        let endpoint_id = peer.endpoint_id().unwrap();
275        // Verify it's 32 bytes
276        assert_eq!(endpoint_id.as_bytes().len(), 32);
277    }
278
279    #[test]
280    fn test_get_peer_by_name() {
281        let toml = r#"
282            [[peers]]
283            name = "alice"
284            node_id = "6eb2a534751444f1353b29aa307c78c1f72acfbb06bb8696103dfeede1f4f854"
285            addresses = ["127.0.0.1:9001"]
286
287            [[peers]]
288            name = "bob"
289            node_id = "b654917328aea8ccfae00463d63642eb4904bd276fecb4caf94dd740a76b5567"
290            addresses = ["127.0.0.1:9002"]
291        "#;
292
293        let config = PeerConfig::from_toml(toml).unwrap();
294
295        assert!(config.get_peer("alice").is_some());
296        assert!(config.get_peer("bob").is_some());
297        assert!(config.get_peer("charlie").is_none());
298    }
299
300    #[test]
301    fn test_parse_formation_config() {
302        // Generate a valid base64 secret for testing
303        let secret = FormationKey::generate_secret();
304
305        let toml = format!(
306            r#"
307            [formation]
308            id = "alpha-company"
309            shared_key = "{}"
310
311            [local]
312            bind_address = "127.0.0.1:9000"
313        "#,
314            secret
315        );
316
317        let config = PeerConfig::from_toml(&toml).unwrap();
318
319        assert!(config.formation.is_some());
320        let formation = config.formation.as_ref().unwrap();
321        assert_eq!(formation.id, "alpha-company");
322        assert!(config.requires_formation_auth());
323    }
324
325    #[test]
326    fn test_formation_key_creation() {
327        let secret = FormationKey::generate_secret();
328
329        let toml = format!(
330            r#"
331            [formation]
332            id = "bravo-team"
333            shared_key = "{}"
334        "#,
335            secret
336        );
337
338        let config = PeerConfig::from_toml(&toml).unwrap();
339        let key = config.formation_key().unwrap();
340
341        assert!(key.is_some());
342        assert_eq!(key.unwrap().formation_id(), "bravo-team");
343    }
344
345    #[test]
346    fn test_no_formation_config() {
347        let config = PeerConfig::empty();
348
349        assert!(config.formation.is_none());
350        assert!(!config.requires_formation_auth());
351        assert!(config.formation_key().unwrap().is_none());
352    }
353
354    #[test]
355    fn test_invalid_formation_key() {
356        let toml = r#"
357            [formation]
358            id = "test"
359            shared_key = "not-valid-base64!!!"
360        "#;
361
362        let config = PeerConfig::from_toml(toml).unwrap();
363        assert!(config.formation_key().is_err());
364    }
365}