Fix Windows address allocation check and add custom SNI for tcp-tls

When running on Windows with Wintun, assigning an overlay IPv4 address
via netsh triggers Duplicate Address Detection (DAD). During this tentative
window, binding a UDP socket to the address returns WSAEADDRNOTAVAIL
(os error 10049), causing the agent to prematurely log a false-positive
PluginError ('allocated ... but the address is not on any interface').

Furthermore, Russian TSPU / DPI filters block unrecognized SNIs and
non-standard ALPNs on TCP port 443 with TCP RST injection (os error 10054).
The default 'tsunagi.local' SNI and 'tsunagi-tls/1' ALPN were trivially
fingerprinted.

Key improvements:
- In crates/tsunagi/src/overlay/tun.rs, enhance address_is_local on Windows
  to inspect active OS interfaces via netdev::get_interfaces() if UDP
  bind fails.
- In crates/tsunagi/src/agent/network.rs, introduce an asynchronous retry
  loop in publish_allocations on Windows (up to 1.5s in 150ms intervals)
  to gracefully allow DAD and IP helper updates to complete before
  reporting missing addresses.
- In crates/tsunagi-tcp-tls, add support for custom SNI domain via
  protocol options (-o sni=<domain>), defaulting to 'cloudflare.com'
  instead of 'tsunagi.local'.
- Switch TLS ALPN in tsunagi-tcp-tls from 'tsunagi-tls/1' to 'http/1.1'
  to camouflage handshake traffic as standard HTTPS web traffic.
- Update tests to verify custom and default SNI behavior.

Validation on Linux:
- cargo fmt --all -- --check passed.
- cargo clippy --locked --workspace --all-targets -- -D warnings passed.
- cargo test --locked --workspace --all-targets passed (348 tests passed).
- cargo build --release passed.
This commit is contained in:
Aleksandr Bogomiakov committed 2026-09-30 20:38:16 +01:00
1 parent a4e341cd66
commit e24548b3b0
9 files changed
+119 -32

No files matched your search

+1 -1
View File
@@ -3520,7 +3520,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let identity = tsunagi::storage::StateStore::open(paths.state_db())? let identity = tsunagi::storage::StateStore::open(paths.state_db())?
.load_or_create_device_identity()?; .load_or_create_device_identity()?;
let transport = Arc::new( let transport = Arc::new(
TcpTlsTransport::bind(&identity, tls_cfg.port) TcpTlsTransport::bind(&identity, tls_cfg.port, tls_cfg.sni)
.await .await
.map_err(|err| err.to_string())?, .map_err(|err| err.to_string())?,
); );
+9 -5
View File
@@ -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 /// 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( pub fn generate_self_signed_cert(
secret_key_bytes: &[u8; 32], secret_key_bytes: &[u8; 32],
sni: &str,
) -> Result< ) -> Result<
( (
CertificateDer<'static>, CertificateDer<'static>,
@@ -103,10 +107,10 @@ pub fn generate_self_signed_cert(
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(pkcs8); 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 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 params
.distinguished_name .distinguished_name
.push(rcgen::DnType::CommonName, "tsunagi-node"); .push(rcgen::DnType::CommonName, sni);
let cert = params.self_signed(&key_pair)?; let cert = params.self_signed(&key_pair)?;
let cert_der = CertificateDer::from(cert.der().to_vec()); let cert_der = CertificateDer::from(cert.der().to_vec());
@@ -125,7 +129,7 @@ pub fn make_server_config(
.with_safe_default_protocol_versions()? .with_safe_default_protocol_versions()?
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(vec![cert], key)?; .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)) Ok(Arc::new(config))
} }
@@ -138,6 +142,6 @@ pub fn make_client_config(expected_peer: EndpointId) -> Result<Arc<ClientConfig>
.dangerous() .dangerous()
.with_custom_certificate_verifier(verifier) .with_custom_certificate_verifier(verifier)
.with_no_client_auth(); .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)) Ok(Arc::new(config))
} }
+29 -6
View File
@@ -26,6 +26,8 @@ pub const TCP_TLS_VERSION: u16 = 1;
pub struct TcpTlsConfig { pub struct TcpTlsConfig {
/// Requested port to bind. /// Requested port to bind.
pub port: Option<u16>, pub port: Option<u16>,
/// Custom SNI domain to disguise TLS connections.
pub sni: Option<String>,
} }
impl TcpTlsConfig { impl TcpTlsConfig {
@@ -39,6 +41,12 @@ impl TcpTlsConfig {
self.port = Some(port); self.port = Some(port);
self self
} }
/// Sets the SNI domain used to disguise TLS connections.
pub fn with_sni(mut self, sni: impl Into<String>) -> Self {
self.sni = Some(sni.into());
self
}
} }
/// Announcement payload exchanged between peers for `tcp-tls`. /// Announcement payload exchanged between peers for `tcp-tls`.
@@ -96,12 +104,20 @@ impl std::fmt::Debug for TcpTlsCodec {
impl TcpTlsCodec { impl TcpTlsCodec {
/// Supported options for `tcp-tls`. /// Supported options for `tcp-tls`.
pub const OPTIONS: &'static [ProtocolOption] = &[ProtocolOption { pub const OPTIONS: &'static [ProtocolOption] = &[
key: "port", ProtocolOption {
value: "PORT", key: "port",
help: "TCP port to listen on for TLS 1.3 connections", value: "PORT",
default: Some("443"), 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`. /// Creates a new `TcpTlsCodec`.
pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self { pub fn new(local_id: EndpointId, transport: Arc<TcpTlsTransport>) -> Self {
@@ -132,6 +148,13 @@ impl TcpTlsCodec {
.map_err(|_| format!("invalid port `{value}`"))?; .map_err(|_| format!("invalid port `{value}`"))?;
config.port = Some(port); 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")), other => return Err(format!("unknown option `{other}` for tcp-tls")),
} }
} }
+15 -3
View File
@@ -250,6 +250,7 @@ pub struct TcpTlsTransport {
identity: DeviceIdentity, identity: DeviceIdentity,
local_id: EndpointId, local_id: EndpointId,
listen_port: u16, listen_port: u16,
sni: String,
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>, peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
server_config: Arc<rustls::ServerConfig>, server_config: Arc<rustls::ServerConfig>,
listener: Option<Arc<TcpListener>>, listener: Option<Arc<TcpListener>>,
@@ -260,6 +261,7 @@ impl std::fmt::Debug for TcpTlsTransport {
f.debug_struct("TcpTlsTransport") f.debug_struct("TcpTlsTransport")
.field("local_id", &self.local_id.fmt_short().to_string()) .field("local_id", &self.local_id.fmt_short().to_string())
.field("listen_port", &self.listen_port) .field("listen_port", &self.listen_port)
.field("sni", &self.sni)
.finish() .finish()
} }
} }
@@ -319,12 +321,16 @@ impl Drop for NetBindServiceGuard {
} }
impl TcpTlsTransport { 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( pub async fn bind(
identity: &DeviceIdentity, identity: &DeviceIdentity,
requested_port: Option<u16>, requested_port: Option<u16>,
requested_sni: Option<String>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
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 server_config = make_server_config(cert, key)?;
let target_port = requested_port.unwrap_or(DEFAULT_PORT); let target_port = requested_port.unwrap_or(DEFAULT_PORT);
@@ -391,6 +397,7 @@ impl TcpTlsTransport {
identity: identity.clone(), identity: identity.clone(),
local_id: identity.endpoint_id(), local_id: identity.endpoint_id(),
listen_port: bound_port, listen_port: bound_port,
sni,
peer_addresses: std::sync::RwLock::new(HashMap::new()), peer_addresses: std::sync::RwLock::new(HashMap::new()),
server_config, server_config,
listener, listener,
@@ -402,6 +409,11 @@ impl TcpTlsTransport {
self.listen_port 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. /// Records an observed TCP target address for a peer.
pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) { pub fn set_peer_addr(&self, peer: EndpointId, addr: SocketAddr) {
if let Ok(mut guard) = self.peer_addresses.write() { 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()))?; make_client_config(peer).map_err(|err| TransportError::Other(err.to_string()))?;
let connector = TlsConnector::from(client_config); 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()))? .map_err(|err| TransportError::Other(err.to_string()))?
.to_owned(); .to_owned();
+5 -4
View File
@@ -11,11 +11,12 @@ fn test_generate_and_pin_certificate() {
let identity = DeviceIdentity::generate(); let identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id(); let peer_id = identity.endpoint_id();
let (cert, _key) = generate_self_signed_cert(&identity.secret_bytes()) let (cert, _key) =
.expect("cert generation should succeed"); 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 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()); 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 identity = DeviceIdentity::generate();
let peer_id = identity.endpoint_id(); 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"); .expect("cert generation should succeed");
let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key); let server_config = tsunagi_tcp_tls::cert::make_server_config(cert, key);
+2 -2
View File
@@ -41,14 +41,14 @@ async fn test_tcp_tls_plugin_end_to_end() {
// Transports // Transports
let transport_b = Arc::new( let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0)) TcpTlsTransport::bind(&identity_b, Some(0), None)
.await .await
.expect("bind B"), .expect("bind B"),
); );
let port_b = transport_b.bound_port(); let port_b = transport_b.bound_port();
let transport_a = Arc::new( let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0)) TcpTlsTransport::bind(&identity_a, Some(0), None)
.await .await
.expect("bind A"), .expect("bind A"),
); );
@@ -23,19 +23,21 @@ async fn test_tcp_tls_transport_loopback() {
// Bind transport B on port 0 (ephemeral) // Bind transport B on port 0 (ephemeral)
let transport_b = Arc::new( let transport_b = Arc::new(
TcpTlsTransport::bind(&identity_b, Some(0)) TcpTlsTransport::bind(&identity_b, Some(0), Some("custom.example.com".to_string()))
.await .await
.expect("bind server should succeed"), .expect("bind server should succeed"),
); );
let port_b = transport_b.bound_port(); let port_b = transport_b.bound_port();
assert!(port_b > 0); assert!(port_b > 0);
assert_eq!(transport_b.sni(), "custom.example.com");
// Bind transport A on port 0 (ephemeral) // Bind transport A on port 0 (ephemeral)
let transport_a = Arc::new( let transport_a = Arc::new(
TcpTlsTransport::bind(&identity_a, Some(0)) TcpTlsTransport::bind(&identity_a, Some(0), Some("custom.example.com".to_string()))
.await .await
.expect("bind client should succeed"), .expect("bind client should succeed"),
); );
assert_eq!(transport_a.sni(), "custom.example.com");
// Register B's address on transport A // Register B's address on transport A
let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap(); let addr_b: SocketAddr = format!("127.0.0.1:{port_b}").parse().unwrap();
+29 -8
View File
@@ -1110,19 +1110,40 @@ impl Runtime {
// wrong here before. Said once per address, not every round. // wrong here before. Said once per address, not every round.
// An in-memory interface has no addresses to be missing from: with // An in-memory interface has no addresses to be missing from: with
// `--no-tun` this is the arrangement asked for, not a fault. // `--no-tun` this is the arrangement asked for, not a fault.
let missing = match local { let is_missing = if let Some(address) = local {
Some(address) if interface.on_host() {
if interface.on_host() let mut local_found =
&& !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) => 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); let already = self.reported_missing == Some(address);
self.reported_missing = Some(address); self.reported_missing = Some(address);
(!already).then_some(address) (!already).then_some(address)
} } else {
_ => {
self.reported_missing = None;
None None
} }
} else {
self.reported_missing = None;
None
}; };
if let Some(address) = missing { if let Some(address) = missing {
self.emit(Event::PluginError { self.emit(Event::PluginError {
+25 -1
View File
@@ -221,7 +221,31 @@ impl TunDevice for MemoryTun {
/// enough here: the agent chose the address, so anything else holding it is a /// enough here: the agent chose the address, so anything else holding it is a
/// problem in its own right. /// problem in its own right.
pub fn address_is_local(address: std::net::IpAddr) -> bool { 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. /// Creates [`MemoryTun`] devices.