diff --git a/src/config.rs b/src/config.rs index 12161cb5..f8dfa0f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -249,6 +249,136 @@ impl TransportsConfig { } } +// ============================================================================ +// Peer Configuration +// ============================================================================ + +/// Connection policy for a peer. +/// +/// Determines when and how to establish a connection to a peer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectPolicy { + /// Connect to this peer automatically on node startup. + /// This is the only policy supported in the initial implementation. + #[default] + AutoConnect, + + /// Connect only when traffic needs to be routed through this peer (future). + OnDemand, + + /// Wait for explicit API call to connect (future). + Manual, +} + +/// A transport-specific address for reaching a peer. +/// +/// Each peer can have multiple addresses across different transports, +/// allowing fallback if one transport is unavailable. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerAddress { + /// Transport type (e.g., "udp", "tor", "ethernet"). + pub transport: String, + + /// Transport-specific address string. + /// + /// Format depends on transport type: + /// - UDP: "host:port" (e.g., "192.168.1.1:4000") + /// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:4000") + /// - Ethernet: "interface/mac" (future) + pub addr: String, + + /// Priority for address selection (lower = preferred). + /// When multiple addresses are available, lower priority addresses + /// are tried first. + #[serde(default = "default_priority")] + pub priority: u8, +} + +fn default_priority() -> u8 { + 100 +} + +impl PeerAddress { + /// Create a new peer address. + pub fn new(transport: impl Into, addr: impl Into) -> Self { + Self { + transport: transport.into(), + addr: addr.into(), + priority: default_priority(), + } + } + + /// Create a new peer address with priority. + pub fn with_priority(transport: impl Into, addr: impl Into, priority: u8) -> Self { + Self { + transport: transport.into(), + addr: addr.into(), + priority, + } + } +} + +/// Configuration for a known peer. +/// +/// Peers are identified by their Nostr public key (npub) and can have +/// multiple transport addresses for reaching them. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerConfig { + /// The peer's Nostr public key in npub (bech32) or hex format. + pub npub: String, + + /// Human-readable alias for the peer (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// Transport addresses for reaching this peer. + /// At least one address is required. + pub addresses: Vec, + + /// Connection policy for this peer. + #[serde(default)] + pub connect_policy: ConnectPolicy, +} + +impl PeerConfig { + /// Create a new peer config with a single address. + pub fn new(npub: impl Into, transport: impl Into, addr: impl Into) -> Self { + Self { + npub: npub.into(), + alias: None, + addresses: vec![PeerAddress::new(transport, addr)], + connect_policy: ConnectPolicy::default(), + } + } + + /// Set an alias for the peer. + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.alias = Some(alias.into()); + self + } + + /// Add an additional address for the peer. + pub fn with_address(mut self, addr: PeerAddress) -> Self { + self.addresses.push(addr); + self + } + + /// Get addresses sorted by priority (lowest first). + pub fn addresses_by_priority(&self) -> Vec<&PeerAddress> { + let mut addrs: Vec<_> = self.addresses.iter().collect(); + addrs.sort_by_key(|a| a.priority); + addrs + } + + /// Check if this peer should auto-connect on startup. + pub fn is_auto_connect(&self) -> bool { + matches!(self.connect_policy, ConnectPolicy::AutoConnect) + } +} + /// Root configuration structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { @@ -263,6 +393,10 @@ pub struct Config { /// Transport instances (`transports.*`). #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")] pub transports: TransportsConfig, + + /// Static peers to connect to (`peers`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub peers: Vec, } impl Config { @@ -363,6 +497,10 @@ impl Config { } // Merge transports section self.transports.merge(other.transports); + // Merge peers (replace if non-empty) + if !other.peers.is_empty() { + self.peers = other.peers; + } } /// Create an Identity from this configuration. @@ -386,6 +524,16 @@ impl Config { self.node.leaf_only } + /// Get the configured peers. + pub fn peers(&self) -> &[PeerConfig] { + &self.peers + } + + /// Get peers that should auto-connect on startup. + pub fn auto_connect_peers(&self) -> impl Iterator { + self.peers.iter().filter(|p| p.is_auto_connect()) + } + /// Serialize this configuration to YAML. pub fn to_yaml(&self) -> Result { serde_yaml::to_string(self) @@ -679,4 +827,89 @@ transports: {} // All named instances should have Some(name) assert!(items.iter().all(|(name, _)| name.is_some())); } + + #[test] + fn test_parse_peer_config() { + let yaml = r#" +peers: + - npub: "npub1abc123" + alias: "gateway" + addresses: + - transport: udp + addr: "192.168.1.1:4000" + priority: 1 + - transport: tor + addr: "xyz.onion:4000" + priority: 2 + connect_policy: auto_connect +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(config.peers.len(), 1); + let peer = &config.peers[0]; + assert_eq!(peer.npub, "npub1abc123"); + assert_eq!(peer.alias, Some("gateway".to_string())); + assert_eq!(peer.addresses.len(), 2); + assert!(peer.is_auto_connect()); + + // Check addresses are sorted by priority + let sorted = peer.addresses_by_priority(); + assert_eq!(sorted[0].transport, "udp"); + assert_eq!(sorted[0].priority, 1); + assert_eq!(sorted[1].transport, "tor"); + assert_eq!(sorted[1].priority, 2); + } + + #[test] + fn test_parse_peer_minimal() { + let yaml = r#" +peers: + - npub: "npub1xyz" + addresses: + - transport: udp + addr: "10.0.0.1:4000" +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(config.peers.len(), 1); + let peer = &config.peers[0]; + assert_eq!(peer.npub, "npub1xyz"); + assert!(peer.alias.is_none()); + // Default connect_policy is auto_connect + assert!(peer.is_auto_connect()); + // Default priority is 100 + assert_eq!(peer.addresses[0].priority, 100); + } + + #[test] + fn test_parse_multiple_peers() { + let yaml = r#" +peers: + - npub: "npub1peer1" + addresses: + - transport: udp + addr: "10.0.0.1:4000" + - npub: "npub1peer2" + addresses: + - transport: udp + addr: "10.0.0.2:4000" + connect_policy: on_demand +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + + assert_eq!(config.peers.len(), 2); + assert_eq!(config.auto_connect_peers().count(), 1); + } + + #[test] + fn test_peer_config_builder() { + let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:4000") + .with_alias("test-peer") + .with_address(PeerAddress::with_priority("tor", "xyz.onion:4000", 50)); + + assert_eq!(peer.npub, "npub1test"); + assert_eq!(peer.alias, Some("test-peer".to_string())); + assert_eq!(peer.addresses.len(), 2); + assert!(peer.is_auto_connect()); + } } diff --git a/src/node.rs b/src/node.rs index 693027f9..f721c44f 100644 --- a/src/node.rs +++ b/src/node.rs @@ -6,19 +6,22 @@ use crate::bloom::BloomState; use crate::cache::CoordCache; +use crate::config::PeerConfig; use crate::peer::Peer; use crate::transport::{ - packet_channel, Link, LinkId, PacketRx, PacketTx, TransportHandle, TransportId, + packet_channel, Link, LinkDirection, LinkId, PacketRx, PacketTx, TransportAddr, + TransportHandle, TransportId, }; use crate::transport::udp::UdpTransport; use crate::tree::TreeState; use crate::tun::{run_tun_reader, shutdown_tun_interface, TunDevice, TunError, TunState, TunTx}; -use crate::{Config, ConfigError, Identity, IdentityError, NodeId}; +use crate::{Config, ConfigError, Identity, IdentityError, NodeId, PeerIdentity}; use std::collections::HashMap; use std::fmt; use std::thread::{self, JoinHandle}; +use std::time::Duration; use thiserror::Error; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; /// Errors related to node operations. #[derive(Debug, Error)] @@ -35,6 +38,9 @@ pub enum NodeError { #[error("transport not found: {0}")] TransportNotFound(TransportId), + #[error("no transport available for type: {0}")] + NoTransportForType(String), + #[error("link not found: {0}")] LinkNotFound(LinkId), @@ -44,6 +50,9 @@ pub enum NodeError { #[error("peer already exists: {0:?}")] PeerAlreadyExists(NodeId), + #[error("invalid peer npub '{npub}': {reason}")] + InvalidPeerNpub { npub: String, reason: String }, + #[error("max peers exceeded: {max}")] MaxPeersExceeded { max: usize }, @@ -315,6 +324,129 @@ impl Node { transports } + /// Find an operational transport that matches the given transport type name. + fn find_transport_for_type(&self, transport_type: &str) -> Option { + self.transports + .iter() + .find(|(_, handle)| { + handle.transport_type().name == transport_type && handle.is_operational() + }) + .map(|(id, _)| *id) + } + + /// Initiate connections to configured static peers. + /// + /// For each peer configured with AutoConnect policy, creates a link and + /// peer entry. The peer starts in Connecting state; authentication + /// handshake will be handled by the event loop. + fn initiate_peer_connections(&mut self) { + // Collect peer configs to avoid borrow conflicts + let peer_configs: Vec<_> = self.config.auto_connect_peers().cloned().collect(); + + if peer_configs.is_empty() { + debug!("No static peers configured"); + return; + } + + info!(count = peer_configs.len(), "Initiating static peer connections"); + + for peer_config in peer_configs { + if let Err(e) = self.initiate_peer_connection(&peer_config) { + warn!( + npub = %peer_config.npub, + alias = ?peer_config.alias, + error = %e, + "Failed to initiate peer connection" + ); + } + } + } + + /// Initiate a connection to a single peer. + fn initiate_peer_connection(&mut self, peer_config: &PeerConfig) -> Result<(), NodeError> { + // Parse the peer's npub to get their identity + let peer_identity = PeerIdentity::from_npub(&peer_config.npub).map_err(|e| { + NodeError::InvalidPeerNpub { + npub: peer_config.npub.clone(), + reason: e.to_string(), + } + })?; + + let peer_node_id = *peer_identity.node_id(); + + // Check if peer already exists + if self.peers.contains_key(&peer_node_id) { + debug!( + npub = %peer_config.npub, + "Peer already exists, skipping" + ); + return Ok(()); + } + + // Try addresses in priority order until one works + for addr in peer_config.addresses_by_priority() { + // Find a transport matching this address type + let transport_id = match self.find_transport_for_type(&addr.transport) { + Some(id) => id, + None => { + debug!( + transport = %addr.transport, + addr = %addr.addr, + "No operational transport for address type" + ); + continue; + } + }; + + // Allocate link ID and create link + let link_id = self.allocate_link_id(); + let remote_addr = TransportAddr::from_string(&addr.addr); + + // For UDP, links are immediately "connected" (connectionless) + // TODO: For connection-oriented transports, state would be Connecting + let link = Link::connectionless( + link_id, + transport_id, + remote_addr, + LinkDirection::Outbound, + Duration::from_millis(100), // Base RTT estimate for UDP + ); + + self.links.insert(link_id, link); + + // Create peer in Connecting state + let mut peer = Peer::discovered(peer_identity.clone(), link_id); + peer.set_connecting(); + + let alias_display = peer_config + .alias + .as_deref() + .map(|a| format!(" ({})", a)) + .unwrap_or_default(); + + info!( + npub = %peer_config.npub, + node_id = %peer_node_id, + transport = %addr.transport, + addr = %addr.addr, + link_id = %link_id, + "Peer connection initiated{}", + alias_display + ); + + self.peers.insert(peer_node_id, peer); + + // Successfully initiated connection via this address + return Ok(()); + } + + // No address worked + Err(NodeError::NoTransportForType(format!( + "no operational transport for any of {}'s addresses", + peer_config.npub + ))) + } + // === Identity Accessors === /// Get this node's identity. @@ -655,8 +787,16 @@ impl Node { } } + // Connect to static peers (step 5 per architecture doc) + self.initiate_peer_connections(); + self.state = NodeState::Running; - info!(state = %self.state, "Node started"); + info!( + state = %self.state, + transports = self.transports.len(), + peers = self.peers.len(), + "Node started" + ); Ok(()) }