diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 6bcd9a3..a6b21a1 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -3520,7 +3520,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { let identity = tsunagi::storage::StateStore::open(paths.state_db())? .load_or_create_device_identity()?; let transport = Arc::new( - TcpTlsTransport::bind(&identity, tls_cfg.port) + TcpTlsTransport::bind(&identity, tls_cfg.port, tls_cfg.sni) .await .map_err(|err| err.to_string())?, ); diff --git a/crates/tsunagi-tcp-tls/src/cert.rs b/crates/tsunagi-tcp-tls/src/cert.rs index f19f664..5d184ee 100644 --- a/crates/tsunagi-tcp-tls/src/cert.rs +++ b/crates/tsunagi-tcp-tls/src/cert.rs @@ -81,10 +81,14 @@ impl ServerCertVerifier for PinnedEndpointVerifier { } } +/// Default SNI domain used to disguise TLS connections. +pub const DEFAULT_SNI: &str = "cloudflare.com"; + /// Generates a self-signed certificate and private key for TLS 1.3 using -/// a given 32-byte Ed25519 secret key. +/// a given 32-byte Ed25519 secret key and specified SNI domain. pub fn generate_self_signed_cert( secret_key_bytes: &[u8; 32], + sni: &str, ) -> Result< ( CertificateDer<'static>, @@ -103,10 +107,10 @@ pub fn generate_self_signed_cert( let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8); let key_pair = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&key_der, &rcgen::PKCS_ED25519)?; - let mut params = rcgen::CertificateParams::new(vec!["tsunagi.local".to_string()])?; + let mut params = rcgen::CertificateParams::new(vec![sni.to_string()])?; params .distinguished_name - .push(rcgen::DnType::CommonName, "tsunagi-node"); + .push(rcgen::DnType::CommonName, sni); let cert = params.self_signed(&key_pair)?; let cert_der = CertificateDer::from(cert.der().to_vec()); @@ -125,7 +129,7 @@ pub fn make_server_config( .with_safe_default_protocol_versions()? .with_no_client_auth() .with_single_cert(vec![cert], key)?; - config.alpn_protocols = vec![b"tsunagi-tls/1".to_vec()]; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; Ok(Arc::new(config)) } @@ -138,6 +142,6 @@ pub fn make_client_config(expected_peer: EndpointId) -> Result .dangerous() .with_custom_certificate_verifier(verifier) .with_no_client_auth(); - config.alpn_protocols = vec![b"tsunagi-tls/1".to_vec()]; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; Ok(Arc::new(config)) } diff --git a/crates/tsunagi-tcp-tls/src/plugin.rs b/crates/tsunagi-tcp-tls/src/plugin.rs index fe2d052..80deecd 100644 --- a/crates/tsunagi-tcp-tls/src/plugin.rs +++ b/crates/tsunagi-tcp-tls/src/plugin.rs @@ -26,6 +26,8 @@ pub const TCP_TLS_VERSION: u16 = 1; pub struct TcpTlsConfig { /// Requested port to bind. pub port: Option, + /// Custom SNI domain to disguise TLS connections. + pub sni: Option, } impl TcpTlsConfig { @@ -39,6 +41,12 @@ impl TcpTlsConfig { 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`. @@ -96,12 +104,20 @@ impl std::fmt::Debug for TcpTlsCodec { 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"), - }]; + 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 { @@ -132,6 +148,13 @@ impl TcpTlsCodec { .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")), } } diff --git a/crates/tsunagi-tcp-tls/src/transport.rs b/crates/tsunagi-tcp-tls/src/transport.rs index e8f7b0c..5fa86df 100644 --- a/crates/tsunagi-tcp-tls/src/transport.rs +++ b/crates/tsunagi-tcp-tls/src/transport.rs @@ -250,6 +250,7 @@ pub struct TcpTlsTransport { identity: DeviceIdentity, local_id: EndpointId, listen_port: u16, + sni: String, peer_addresses: std::sync::RwLock>>, server_config: Arc, listener: Option>, @@ -260,6 +261,7 @@ impl std::fmt::Debug for TcpTlsTransport { f.debug_struct("TcpTlsTransport") .field("local_id", &self.local_id.fmt_short().to_string()) .field("listen_port", &self.listen_port) + .field("sni", &self.sni) .finish() } } @@ -319,12 +321,16 @@ impl Drop for NetBindServiceGuard { } impl TcpTlsTransport { - /// Binds a new TCP TLS transport using the device identity and configured port. + /// Binds a new TCP TLS transport using the device identity, configured port, and SNI domain. pub async fn bind( identity: &DeviceIdentity, requested_port: Option, + requested_sni: Option, ) -> Result> { - let (cert, key) = generate_self_signed_cert(&identity.secret_bytes())?; + let sni = requested_sni + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::cert::DEFAULT_SNI.to_string()); + let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), &sni)?; let server_config = make_server_config(cert, key)?; let target_port = requested_port.unwrap_or(DEFAULT_PORT); @@ -391,6 +397,7 @@ impl TcpTlsTransport { identity: identity.clone(), local_id: identity.endpoint_id(), listen_port: bound_port, + sni, peer_addresses: std::sync::RwLock::new(HashMap::new()), server_config, listener, @@ -402,6 +409,11 @@ impl TcpTlsTransport { self.listen_port } + /// The SNI domain this transport advertises during TLS handshake. + pub fn sni(&self) -> &str { + &self.sni + } + /// Records an observed TCP target address for a peer. pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) { if let Ok(mut guard) = self.peer_addresses.write() { @@ -539,7 +551,7 @@ impl PacketTransport for TcpTlsTransport { make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?; let connector = TlsConnector::from(client_config); - let server_name = rustls::pki_types::ServerName::try_from("tsunagi.local") + let server_name = rustls::pki_types::ServerName::try_from(self.sni.as_str()) .map_err(|err| TransportError::Other(err.to_string()))? .to_owned(); diff --git a/crates/tsunagi-tcp-tls/tests/cert_tests.rs b/crates/tsunagi-tcp-tls/tests/cert_tests.rs index c3471bf..18a5802 100644 --- a/crates/tsunagi-tcp-tls/tests/cert_tests.rs +++ b/crates/tsunagi-tcp-tls/tests/cert_tests.rs @@ -11,11 +11,12 @@ fn test_generate_and_pin_certificate() { let identity = DeviceIdentity::generate(); let peer_id = identity.endpoint_id(); - let (cert, _key) = generate_self_signed_cert(&identity.secret_bytes()) - .expect("cert generation should succeed"); + let (cert, _key) = + generate_self_signed_cert(&identity.secret_bytes(), tsunagi_tcp_tls::cert::DEFAULT_SNI) + .expect("cert generation should succeed"); let verifier = PinnedEndpointVerifier::new(peer_id); - let server_name = ServerName::try_from("tsunagi.local").unwrap(); + let server_name = ServerName::try_from(tsunagi_tcp_tls::cert::DEFAULT_SNI).unwrap(); let verified = verifier.verify_server_cert(&cert, &[], &server_name, &[], UnixTime::now()); @@ -41,7 +42,7 @@ fn test_make_client_and_server_config() { let identity = DeviceIdentity::generate(); let peer_id = identity.endpoint_id(); - let (cert, key) = generate_self_signed_cert(&identity.secret_bytes()) + let (cert, key) = generate_self_signed_cert(&identity.secret_bytes(), "custom.example.com") .expect("cert generation should succeed"); let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key); diff --git a/crates/tsunagi-tcp-tls/tests/plugin_tests.rs b/crates/tsunagi-tcp-tls/tests/plugin_tests.rs index bbd44d2..1b98eb6 100644 --- a/crates/tsunagi-tcp-tls/tests/plugin_tests.rs +++ b/crates/tsunagi-tcp-tls/tests/plugin_tests.rs @@ -41,14 +41,14 @@ async fn test_tcp_tls_plugin_end_to_end() { // Transports let transport_b = Arc::new( - TcpTlsTransport::bind(&identity_b, Some(0)) + TcpTlsTransport::bind(&identity_b, Some(0), None) .await .expect("bind B"), ); let port_b = transport_b.bound_port(); let transport_a = Arc::new( - TcpTlsTransport::bind(&identity_a, Some(0)) + TcpTlsTransport::bind(&identity_a, Some(0), None) .await .expect("bind A"), ); diff --git a/crates/tsunagi-tcp-tls/tests/transport_tests.rs b/crates/tsunagi-tcp-tls/tests/transport_tests.rs index 77cdc16..ab1774c 100644 --- a/crates/tsunagi-tcp-tls/tests/transport_tests.rs +++ b/crates/tsunagi-tcp-tls/tests/transport_tests.rs @@ -23,19 +23,21 @@ async fn test_tcp_tls_transport_loopback() { // Bind transport B on port 0 (ephemeral) let transport_b = Arc::new( - TcpTlsTransport::bind(&identity_b, Some(0)) + TcpTlsTransport::bind(&identity_b, Some(0), Some("custom.example.com".to_string())) .await .expect("bind server should succeed"), ); let port_b = transport_b.bound_port(); assert!(port_b > 0); + assert_eq!(transport_b.sni(), "custom.example.com"); // Bind transport A on port 0 (ephemeral) let transport_a = Arc::new( - TcpTlsTransport::bind(&identity_a, Some(0)) + TcpTlsTransport::bind(&identity_a, Some(0), Some("custom.example.com".to_string())) .await .expect("bind client should succeed"), ); + assert_eq!(transport_a.sni(), "custom.example.com"); // Register B's address on transport A let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap(); diff --git a/crates/tsunagi/src/agent/network.rs b/crates/tsunagi/src/agent/network.rs index efdfb82..5430948 100644 --- a/crates/tsunagi/src/agent/network.rs +++ b/crates/tsunagi/src/agent/network.rs @@ -1110,19 +1110,40 @@ impl Runtime { // wrong here before. Said once per address, not every round. // An in-memory interface has no addresses to be missing from: with // `--no-tun` this is the arrangement asked for, not a fault. - let missing = match local { - Some(address) - if interface.on_host() - && !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) => - { + let is_missing = if let Some(address) = local { + if interface.on_host() { + let mut local_found = + crate::overlay::address_is_local(std::net::IpAddr::V4(address)); + // On Windows, duplicate address detection (DAD) and netsh/IP helper updates + // may take a short moment before a newly assigned address is ready for binding. + if !local_found && cfg!(target_os = "windows") { + for _ in 0..10 { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if crate::overlay::address_is_local(std::net::IpAddr::V4(address)) { + local_found = true; + break; + } + } + } + !local_found + } else { + false + } + } else { + false + }; + + let missing = if is_missing { + if let Some(address) = local { let already = self.reported_missing == Some(address); self.reported_missing = Some(address); (!already).then_some(address) - } - _ => { - self.reported_missing = None; + } else { None } + } else { + self.reported_missing = None; + None }; if let Some(address) = missing { self.emit(Event::PluginError { diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index 7f71526..f10e9a5 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -221,7 +221,31 @@ impl TunDevice for MemoryTun { /// enough here: the agent chose the address, so anything else holding it is a /// problem in its own right. pub fn address_is_local(address: std::net::IpAddr) -> bool { - std::net::UdpSocket::bind((address, 0)).is_ok() + if std::net::UdpSocket::bind((address, 0)).is_ok() { + return true; + } + #[cfg(all(feature = "tun-device", target_os = "windows"))] + { + if let Ok(interfaces) = std::panic::catch_unwind(netdev::get_interfaces) { + for iface in interfaces { + if iface.is_up() { + match address { + std::net::IpAddr::V4(v4) => { + if iface.ipv4.iter().any(|ip| ip.addr() == v4) { + return true; + } + } + std::net::IpAddr::V6(v6) => { + if iface.ipv6.iter().any(|ip| ip.addr() == v6) { + return true; + } + } + } + } + } + } + } + false } /// Creates [`MemoryTun`] devices.