//! The `tcp-tls` IP plugin implementation. use std::collections::HashMap; use std::sync::{Arc, RwLock}; use bytes::Bytes; use iroh::EndpointId; use serde::{Deserialize, Serialize}; use x25519_dalek::{PublicKey, StaticSecret}; use tsunagi::dataplane::tunnel::{GenericTunnelPlugin, TunnelCodec}; use tsunagi::dataplane::{PluginError, ProtocolOption}; use tsunagi::identity::NetworkId; use crate::crypto::PeerCipher; use crate::transport::TcpTlsTransport; /// The wire protocol identifier for `tcp-tls`. pub const TCP_TLS_PROTOCOL: &str = "tcp-tls"; /// Wire version for `tcp-tls`. pub const TCP_TLS_VERSION: u16 = 1; /// Configuration for the `tcp-tls` protocol plugin. #[derive(Debug, Clone, Default)] pub struct TcpTlsConfig { /// Requested port to bind. pub port: Option, /// Custom SNI domain to disguise TLS connections. pub sni: Option, } impl TcpTlsConfig { /// Creates a configuration with default settings. pub fn new() -> Self { Self::default() } /// Sets the TCP listen port. pub fn with_port(mut self, port: u16) -> Self { self.port = Some(port); self } /// Sets the SNI domain used to disguise TLS connections. pub fn with_sni(mut self, sni: impl Into) -> Self { self.sni = Some(sni.into()); self } } /// Announcement payload exchanged between peers for `tcp-tls`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TcpTlsAnnouncement { /// X25519 public key for E2EE noise encryption. pub noise_public: [u8; 32], /// TCP port the peer is listening on. pub port: u16, /// Candidate IP addresses where this peer can be reached on `port`. #[serde(default)] pub addrs: Vec, } /// Discovers local network interface IPs to announce to peers. pub fn local_ip_candidates() -> Vec { let mut candidates = Vec::new(); for iface in netdev::get_interfaces() { if !is_candidate_interface(&iface) { continue; } for ip in &iface.ipv4 { let addr = ip.addr(); if is_candidate_ipv4(addr) { let priority = ip_priority(&iface, std::net::IpAddr::V4(addr)); candidates.push((priority, std::net::IpAddr::V4(addr))); } } for ip in &iface.ipv6 { let addr = ip.addr(); if is_candidate_ip(std::net::IpAddr::V6(addr)) { let priority = ip_priority(&iface, std::net::IpAddr::V6(addr)); candidates.push((priority, std::net::IpAddr::V6(addr))); } } } // Sort descending by priority so best interface IP is candidate #0 candidates.sort_by_key(|b| std::cmp::Reverse(b.0)); let mut seen = std::collections::HashSet::new(); let mut ips = Vec::new(); for (_, ip) in candidates { if seen.insert(ip) { ips.push(ip); } } ips } pub(crate) fn is_candidate_interface(iface: &netdev::Interface) -> bool { if !iface.is_up() || iface.is_loopback() { return false; } let name = iface.name.to_lowercase(); let friendly = iface .friendly_name .as_deref() .map(str::to_lowercase) .unwrap_or_default(); let desc = iface .description .as_deref() .map(str::to_lowercase) .unwrap_or_default(); // Skip Tsunagi / Wintun overlay interfaces if name.starts_with("tsun") || name.contains("tsunagi") || name.contains("wintun") || friendly.starts_with("tsun") || friendly.contains("tsunagi") || friendly.contains("wintun") || desc.contains("wintun") || desc.contains("tsunagi") { return false; } // Skip container, VM, and virtual bridge interfaces if name.starts_with("docker") || name.starts_with("br-") || name.starts_with("veth") || name.starts_with("virbr") || name.starts_with("cni") || name.starts_with("flannel") || name.starts_with("cali") || name.starts_with("kube") || name.starts_with("dummy") || name.starts_with("lxc") || name.starts_with("podman") || friendly.contains("vethernet") || friendly.contains("vboxnet") || friendly.contains("vmnet") || desc.contains("virtualbox") || desc.contains("hyper-v") || desc.contains("vmware") { return false; } true } pub(crate) fn is_candidate_ipv4(addr: std::net::Ipv4Addr) -> bool { if addr.is_loopback() || addr.is_unspecified() || addr.is_link_local() || addr.is_broadcast() { return false; } if addr.is_documentation() { return false; } true } pub(crate) fn is_candidate_ip(addr: std::net::IpAddr) -> bool { match addr { std::net::IpAddr::V4(v4) => is_candidate_ipv4(v4), std::net::IpAddr::V6(v6) => { if v6.is_loopback() || v6.is_unspecified() { return false; } let segments = v6.segments(); // Filter link-local fe80::/10 if (segments[0] & 0xffc0) == 0xfe80 { return false; } true } } } pub(crate) fn ip_priority(iface: &netdev::Interface, ip: std::net::IpAddr) -> u32 { let has_gateway = iface.gateway.is_some(); let base = if has_gateway { 100 } else { 0 }; match ip { std::net::IpAddr::V4(v4) => { let octets = v4.octets(); // Tailscale: 100.64.0.0/10 let is_tailscale = octets[0] == 100 && (octets[1] & 0xc0) == 64; let is_private = v4.is_private(); if !is_private && !is_tailscale { base + 50 } else if has_gateway && is_private { base + 40 } else if is_tailscale { base + 30 } else { base + 10 } } std::net::IpAddr::V6(v6) => { let segments = v6.segments(); let is_global = (segments[0] & 0xe000) == 0x2000; if is_global { base + 45 } else { base + 20 } } } } /// The codec implementing cryptographic transformation and announcements for `tcp-tls`. pub struct TcpTlsCodec { local_id: EndpointId, static_secret: StaticSecret, public_key: PublicKey, transport: Arc, ciphers: RwLock>>, } impl std::fmt::Debug for TcpTlsCodec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TcpTlsCodec") .field("local_id", &self.local_id.fmt_short().to_string()) .field("port", &self.transport.bound_port()) .finish() } } impl TcpTlsCodec { /// Supported options for `tcp-tls`. pub const OPTIONS: &'static [ProtocolOption] = &[ ProtocolOption { key: "port", value: "PORT", help: "TCP port to listen on for TLS 1.3 connections", default: Some("443"), }, ProtocolOption { key: "sni", value: "DOMAIN", help: "Server Name Indication (SNI) domain to disguise TLS connections", default: Some(crate::cert::DEFAULT_SNI), }, ]; /// Creates a new `TcpTlsCodec`. pub fn new(local_id: EndpointId, transport: Arc) -> Self { let mut secret_bytes = [0u8; 32]; rand::fill(&mut secret_bytes); let static_secret = StaticSecret::from(secret_bytes); let public_key = PublicKey::from(&static_secret); Self { local_id, static_secret, public_key, transport, ciphers: RwLock::new(HashMap::new()), } } /// Configures settings from parsed `-o` options. pub fn configure( mut config: TcpTlsConfig, options: &[(String, String)], ) -> Result { for (key, value) in options { match key.as_str() { "port" => { let port: u16 = value .parse() .map_err(|_| format!("invalid port `{value}`"))?; config.port = Some(port); } "sni" => { let trimmed = value.trim(); if trimmed.is_empty() { return Err("sni domain cannot be empty".into()); } config.sni = Some(trimmed.to_string()); } other => return Err(format!("unknown option `{other}` for tcp-tls")), } } Ok(config) } } impl TunnelCodec for TcpTlsCodec { fn protocol_id(&self) -> &str { TCP_TLS_PROTOCOL } fn protocol_version(&self) -> u16 { TCP_TLS_VERSION } fn options(&self) -> &'static [ProtocolOption] { Self::OPTIONS } fn local_capability_data(&self, _network: NetworkId) -> Result, PluginError> { let announcement = TcpTlsAnnouncement { noise_public: *self.public_key.as_bytes(), port: self.transport.bound_port(), addrs: local_ip_candidates(), }; postcard::to_allocvec(&announcement) .map_err(|err| PluginError::Other(format!("encode announcement failed: {err}"))) } fn on_peer_capability_data( &self, network: NetworkId, peer: EndpointId, data: &[u8], ) -> Result<(), PluginError> { let announcement: TcpTlsAnnouncement = postcard::from_bytes(data) .map_err(|err| PluginError::Rejected(format!("invalid announcement: {err}")))?; for ip in &announcement.addrs { if is_candidate_ip(*ip) { self.transport .set_peer_addr(peer, std::net::SocketAddr::new(*ip, announcement.port)); } } let peer_noise_pubkey = PublicKey::from(announcement.noise_public); let cipher = Arc::new(PeerCipher::new( self.local_id, peer, &self.static_secret, &peer_noise_pubkey, )); let mut guard = match self.ciphers.write() { Ok(g) => g, Err(p) => p.into_inner(), }; guard.insert((network, peer), cipher); Ok(()) } fn on_peer_down(&self, network: NetworkId, peer: EndpointId) { let mut guard = match self.ciphers.write() { Ok(g) => g, Err(p) => p.into_inner(), }; guard.remove(&(network, peer)); } fn encrypt( &self, network: NetworkId, peer: EndpointId, packet: &[u8], ) -> Result { let cipher = { let guard = match self.ciphers.read() { Ok(g) => g, Err(p) => p.into_inner(), }; guard.get(&(network, peer)).cloned() }; let Some(cipher) = cipher else { return Err(PluginError::Unavailable("no cipher for peer".into())); }; cipher.encrypt(packet) } fn decrypt( &self, network: NetworkId, peer: EndpointId, payload: &[u8], ) -> Result { let cipher = { let guard = match self.ciphers.read() { Ok(g) => g, Err(p) => p.into_inner(), }; guard.get(&(network, peer)).cloned() }; let Some(cipher) = cipher else { return Err(PluginError::Unavailable("no cipher for peer".into())); }; cipher.decrypt(payload) } } /// The complete `tcp-tls` plugin. pub type TcpTlsPlugin = GenericTunnelPlugin; #[cfg(test)] mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; #[test] fn test_is_candidate_interface_filters_overlay_and_bridges() { let mut iface = netdev::Interface::dummy(); iface.flags = 1; // UP // Standard Ethernet - should pass iface.name = "eth0".to_string(); assert!(is_candidate_interface(&iface)); // Windows Ethernet with GUID name - should pass iface.name = "{B6149D2C-1234-5678-9ABC-DEF012345678}".to_string(); iface.friendly_name = Some("Ethernet".to_string()); iface.description = Some("Intel(R) Ethernet Connection".to_string()); assert!(is_candidate_interface(&iface)); // Linux tsun0 overlay - must be filtered iface.name = "tsun0".to_string(); iface.friendly_name = None; iface.description = None; assert!(!is_candidate_interface(&iface)); // Windows Wintun overlay (GUID name with tsun0 friendly name) - must be filtered! iface.name = "{72D85D76-F1A5-42AC-BC49-2169527CDDDF}".to_string(); iface.friendly_name = Some("tsun0".to_string()); iface.description = Some("Wintun Userspace Tunnel".to_string()); assert!(!is_candidate_interface(&iface)); // Windows Wintun overlay with description containing Wintun - must be filtered! iface.friendly_name = Some("Local Area Connection 2".to_string()); iface.description = Some("Wintun Userspace Tunnel".to_string()); assert!(!is_candidate_interface(&iface)); // Docker bridge - must be filtered iface.name = "docker0".to_string(); iface.friendly_name = None; iface.description = None; assert!(!is_candidate_interface(&iface)); // Docker network bridge br-* - must be filtered iface.name = "br-4f6d3a9b1c2e".to_string(); assert!(!is_candidate_interface(&iface)); // Virtual ethernet veth* - must be filtered iface.name = "veth12345".to_string(); assert!(!is_candidate_interface(&iface)); // Libvirt NAT bridge virbr* - must be filtered iface.name = "virbr0".to_string(); assert!(!is_candidate_interface(&iface)); // CNI bridge - must be filtered iface.name = "cni0".to_string(); assert!(!is_candidate_interface(&iface)); // Hyper-V / WSL vEthernet switch on Windows - must be filtered iface.name = "{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}".to_string(); iface.friendly_name = Some("vEthernet (WSL)".to_string()); iface.description = Some("Hyper-V Virtual Ethernet Adapter".to_string()); assert!(!is_candidate_interface(&iface)); // Down interface - must be filtered let mut down_iface = netdev::Interface::dummy(); down_iface.flags = 0; // Not UP down_iface.name = "eth0".to_string(); assert!(!is_candidate_interface(&down_iface)); } #[test] fn test_is_candidate_ip() { // Valid LAN IP assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new( 192, 168, 1, 117 )))); // Valid Tailscale IP assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new( 100, 77, 155, 120 )))); // Valid Public IP assert!(is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new( 1, 1, 1, 1 )))); // Valid IPv6 GUA assert!(is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new( 0x2a01, 0x4b00, 0xb8e3, 0x4e00, 0, 0, 0, 1 )))); // Loopback - must be rejected assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new( 127, 0, 0, 1 )))); assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::LOCALHOST))); // Unspecified - must be rejected assert!(!is_candidate_ip(std::net::IpAddr::V4( Ipv4Addr::UNSPECIFIED ))); assert!(!is_candidate_ip(std::net::IpAddr::V6( Ipv6Addr::UNSPECIFIED ))); // Link-local - must be rejected assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::new( 169, 254, 1, 1 )))); assert!(!is_candidate_ip(std::net::IpAddr::V6(Ipv6Addr::new( 0xfe80, 0, 0, 0, 0, 0x1ff, 0xfe00, 1 )))); // Broadcast - must be rejected assert!(!is_candidate_ip(std::net::IpAddr::V4(Ipv4Addr::BROADCAST))); } #[test] fn test_ip_priority_ordering() { let mut iface_lan = netdev::Interface::dummy(); iface_lan.flags = 1; iface_lan.gateway = Some(netdev::net::device::NetworkDevice::new()); let mut iface_vpn = netdev::Interface::dummy(); iface_vpn.flags = 1; iface_vpn.name = "tailscale0".to_string(); let lan_ip = std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 117)); let tailscale_ip = std::net::IpAddr::V4(Ipv4Addr::new(100, 77, 155, 120)); let p_lan = ip_priority(&iface_lan, lan_ip); let p_ts = ip_priority(&iface_vpn, tailscale_ip); // LAN IP with gateway should score higher than VPN IP without gateway assert!(p_lan > p_ts); } }