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:
1 parent
a4e341cd66
commit
e24548b3b0
9 files changed
+119
-32
No files matched your search
@@ -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())?,
|
||||
);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user