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())?
.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())?,
);
+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
/// 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<Arc<ClientConfig>
.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))
}
+29 -6
View File
@@ -26,6 +26,8 @@ pub const TCP_TLS_VERSION: u16 = 1;
pub struct TcpTlsConfig {
/// Requested port to bind.
pub port: Option<u16>,
/// Custom SNI domain to disguise TLS connections.
pub sni: Option<String>,
}
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<String>) -> 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<TcpTlsTransport>) -> 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")),
}
}
+15 -3
View File
@@ -250,6 +250,7 @@ pub struct TcpTlsTransport {
identity: DeviceIdentity,
local_id: EndpointId,
listen_port: u16,
sni: String,
peer_addresses: std::sync::RwLock<HashMap<EndpointId, Vec<SocketAddr>>>,
server_config: Arc<rustls::ServerConfig>,
listener: Option<Arc<TcpListener>>,
@@ -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<u16>,
requested_sni: Option<String>,
) -> 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 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();
+5 -4
View File
@@ -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);
+2 -2
View File
@@ -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"),
);
@@ -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();
+29 -8
View File
@@ -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 {
+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
/// 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.