Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/tuic-client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ pub struct Relay {

#[educe(Default = None)]
pub proxy: Option<ProxyConfig>,

/// Automatically reconnect to the relay after the connection drops.
#[educe(Default = true)]
pub reconnect: bool,

/// Delay before the first reconnect attempt; doubled after each failure.
#[educe(Default(expression = Duration::from_millis(500)))]
#[serde(with = "humantime_serde")]
pub reconnect_initial_backoff: Duration,

/// Upper bound on the reconnect backoff delay.
#[educe(Default(expression = Duration::from_secs(30)))]
#[serde(with = "humantime_serde")]
pub reconnect_max_backoff: Duration,
}

#[derive(Debug, Deserialize, serde::Serialize, Educe, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -557,8 +571,38 @@ mod tests {
assert_eq!(config.relay.gc_interval, Duration::from_secs(3));
assert_eq!(config.relay.gc_lifetime, Duration::from_secs(15));
assert!(!config.relay.skip_cert_verify);
// Reconnect defaults: enabled, 500ms initial backoff capped at 30s.
assert!(config.relay.reconnect);
assert_eq!(config.relay.reconnect_initial_backoff, Duration::from_millis(500));
assert_eq!(config.relay.reconnect_max_backoff, Duration::from_secs(30));
assert_eq!(config.local.max_packet_size, 1500);
}

#[test]
fn test_reconnect_can_be_disabled_and_tuned() {
// A config omitting reconnect keeps the defaults (backward compatible).
let default_cfg = r#"{ "relay": { "server": "example.com:8443", "uuid": "00000000-0000-0000-0000-000000000000", "password": "pw" } }"#;
let config = test_parse_config(default_cfg, ".json5").unwrap();
assert!(config.relay.reconnect);

// Explicit values are honoured, including disabling reconnect and
// humantime-formatted backoff durations.
let tuned = r#"{
"relay": {
"server": "example.com:8443",
"uuid": "00000000-0000-0000-0000-000000000000",
"password": "pw",
"reconnect": false,
"reconnect_initial_backoff": "2s",
"reconnect_max_backoff": "1m"
}
}"#;
let config = test_parse_config(tuned, ".json5").unwrap();
assert!(!config.relay.reconnect);
assert_eq!(config.relay.reconnect_initial_backoff, Duration::from_secs(2));
assert_eq!(config.relay.reconnect_max_backoff, Duration::from_secs(60));
}

#[test]
fn test_tcp_udp_forward() {
let json5_config = include_str!("../tests/config/tcp_udp_forward.json5");
Expand Down
9 changes: 8 additions & 1 deletion crates/tuic-client/src/wind_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{net::SocketAddr, sync::Arc};

use once_cell::sync::OnceCell;
use wind_core::{AbstractOutbound, AppContext, tcp::AbstractTcpStream, types::TargetAddr, udp::UdpStream};
use wind_tuic::quinn::outbound::{TuicOutbound, TuicOutboundOpts};
use wind_tuic::quinn::outbound::{ReconnectConfig, TuicOutbound, TuicOutboundOpts};

use crate::config::Relay;

Expand Down Expand Up @@ -62,6 +62,12 @@ impl TuicOutboundAdapter {
}
};

let reconnect = ReconnectConfig {
enabled: relay.reconnect,
initial_backoff: relay.reconnect_initial_backoff,
max_backoff: relay.reconnect_max_backoff,
};

let opts = TuicOutboundOpts {
peer_addr: server_addr,
sni,
Expand All @@ -76,6 +82,7 @@ impl TuicOutboundAdapter {
.into_iter()
.map(|v| String::from_utf8_lossy(&v).to_string())
.collect(),
reconnect,
};

let outbound: TuicOutbound = TuicOutbound::new(ctx, opts).await?;
Expand Down
59 changes: 59 additions & 0 deletions crates/tuic-client/tests/graceful_shutdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Graceful-shutdown test for the tuic-client TCP/UDP forwarders.
//!
//! `forward::start` spawns each forwarder into `ctx.tasks`, driven by a child
//! of `ctx.token`. Cancelling the token must break every accept/recv loop so
//! the tracker drains — this is the forwarder half of the client's
//! `run_with_cancel` shutdown path.

use std::{net::SocketAddr, sync::Arc, time::Duration};

use tuic_client::{
config::{TcpForward, UdpForward},
forward,
};
use wind_core::AppContext;

/// Reserve a free loopback TCP port (the listener is dropped immediately so the
/// forwarder can bind it).
fn free_tcp_addr() -> SocketAddr {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let a = l.local_addr().unwrap();
drop(l);
a
}

/// Reserve a free loopback UDP port.
fn free_udp_addr() -> SocketAddr {
let s = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let a = s.local_addr().unwrap();
drop(s);
a
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn forwarders_drain_on_cancel() {
let ctx = Arc::new(AppContext::default());

let tcp = vec![TcpForward {
listen: free_tcp_addr(),
// Discard port (9) — never actually dialed; the loops are idle.
remote: ("127.0.0.1".to_string(), 9),
}];
let udp = vec![UdpForward {
listen: free_udp_addr(),
remote: ("127.0.0.1".to_string(), 9),
timeout: Duration::from_secs(60),
}];

forward::start(tcp, udp, &ctx).await;

// Let both forwarder loops bind and reach their `select!`.
tokio::time::sleep(Duration::from_millis(200)).await;

// Graceful shutdown: cancel the context token, then drain the tracker.
ctx.token.cancel();
ctx.tasks.close();
tokio::time::timeout(Duration::from_secs(5), ctx.tasks.wait())
.await
.expect("forwarder tasks did not drain within 5s of cancellation");
}
3 changes: 3 additions & 0 deletions crates/tuic-tests/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,9 @@ async fn test_ipv6_server_client_integration() -> eyre::Result<()> {
gc_lifetime: Duration::from_secs(15),
skip_cert_verify: true,
proxy: None,
reconnect: true,
reconnect_initial_backoff: Duration::from_millis(500),
reconnect_max_backoff: Duration::from_secs(30),
},
local: tuic_client::config::Local {
server: "[::1]:1081".parse()?,
Expand Down
3 changes: 3 additions & 0 deletions crates/wind-base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ eyre = "0.6"
tracing = "0.1"
bytes = "1"
async-trait = "0.1"

[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["macros", "rt"] }
58 changes: 58 additions & 0 deletions crates/wind-base/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,61 @@ pub async fn resolve_target(target: &TargetAddr, resolver: &dyn Resolver) -> eyr
TargetAddr::Domain(domain, port) => Ok(SocketAddr::new(resolver.resolve(domain).await?, *port)),
}
}

#[cfg(test)]
mod tests {
use std::{future::Future, net::IpAddr, pin::Pin};

use super::*;

/// Resolver that returns a fixed IP for any host.
struct FixedResolver(IpAddr);

impl Resolver for FixedResolver {
fn resolve<'a>(&'a self, _host: &'a str) -> Pin<Box<dyn Future<Output = eyre::Result<IpAddr>> + Send + 'a>> {
let ip = self.0;
Box::pin(async move { Ok(ip) })
}

fn resolve_all<'a>(&'a self, _host: &'a str) -> Pin<Box<dyn Future<Output = eyre::Result<Vec<IpAddr>>> + Send + 'a>> {
let ip = self.0;
Box::pin(async move { Ok(vec![ip]) })
}
}

/// Resolver that panics if used — proves IP-literal targets never hit DNS.
struct PanicResolver;

impl Resolver for PanicResolver {
fn resolve<'a>(&'a self, _host: &'a str) -> Pin<Box<dyn Future<Output = eyre::Result<IpAddr>> + Send + 'a>> {
Box::pin(async { panic!("resolver must not be called for IP-literal targets") })
}

fn resolve_all<'a>(&'a self, _host: &'a str) -> Pin<Box<dyn Future<Output = eyre::Result<Vec<IpAddr>>> + Send + 'a>> {
Box::pin(async { panic!("resolver must not be called for IP-literal targets") })
}
}

#[tokio::test]
async fn ip_literal_targets_bypass_the_resolver() {
let v4 = resolve_target(&TargetAddr::IPv4("192.168.1.1".parse().unwrap(), 8080), &PanicResolver)
.await
.unwrap();
assert_eq!(v4.to_string(), "192.168.1.1:8080");

let v6 = resolve_target(&TargetAddr::IPv6("::1".parse().unwrap(), 443), &PanicResolver)
.await
.unwrap();
assert!(v6.ip().is_ipv6());
assert_eq!(v6.port(), 443);
}

#[tokio::test]
async fn domain_targets_use_the_resolver_and_keep_the_port() {
let resolver = FixedResolver("203.0.113.7".parse().unwrap());
let s = resolve_target(&TargetAddr::Domain("example.com".into(), 443), &resolver)
.await
.unwrap();
assert_eq!(s.to_string(), "203.0.113.7:443");
}
}
73 changes: 73 additions & 0 deletions crates/wind-core/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,76 @@ pub fn filter_addrs_by_preference(addrs: Vec<IpAddr>, prefer: StackPrefer) -> Ve
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn ips(list: &[&str]) -> Vec<IpAddr> {
list.iter().map(|s| s.parse().unwrap()).collect()
}

#[test]
fn pick_only_modes_require_matching_family() {
let mixed = ips(&["192.168.1.1", "2001:db8::1"]);
assert!(pick_addr_by_preference(mixed.clone(), StackPrefer::V4only).unwrap().is_ipv4());
assert!(pick_addr_by_preference(mixed, StackPrefer::V6only).unwrap().is_ipv6());

assert!(pick_addr_by_preference(ips(&["2001:db8::1"]), StackPrefer::V4only).is_none());
assert!(pick_addr_by_preference(ips(&["192.168.1.1"]), StackPrefer::V6only).is_none());
}

#[test]
fn pick_first_modes_fall_back_to_other_family() {
assert!(
pick_addr_by_preference(ips(&["2001:db8::1", "192.168.1.1"]), StackPrefer::V4first)
.unwrap()
.is_ipv4()
);
// V4first with no IPv4 falls back to IPv6.
assert!(
pick_addr_by_preference(ips(&["2001:db8::1"]), StackPrefer::V4first)
.unwrap()
.is_ipv6()
);

assert!(
pick_addr_by_preference(ips(&["192.168.1.1", "2001:db8::1"]), StackPrefer::V6first)
.unwrap()
.is_ipv6()
);
// V6first with no IPv6 falls back to IPv4.
assert!(
pick_addr_by_preference(ips(&["192.168.1.1"]), StackPrefer::V6first)
.unwrap()
.is_ipv4()
);
}

#[test]
fn pick_empty_list_is_none() {
assert!(pick_addr_by_preference(vec![], StackPrefer::V4first).is_none());
}

#[test]
fn filter_only_modes_keep_a_single_family() {
let addrs = ips(&["192.168.1.1", "2001:db8::1", "10.0.0.1"]);
let v4 = filter_addrs_by_preference(addrs.clone(), StackPrefer::V4only);
assert_eq!(v4, ips(&["192.168.1.1", "10.0.0.1"]));
let v6 = filter_addrs_by_preference(addrs, StackPrefer::V6only);
assert_eq!(v6, ips(&["2001:db8::1"]));
}

#[test]
fn filter_first_modes_group_preferred_family_first_preserving_order() {
let addrs = ips(&["2001:db8::1", "192.168.1.1", "::1", "10.0.0.1"]);
assert_eq!(
filter_addrs_by_preference(addrs.clone(), StackPrefer::V4first),
ips(&["192.168.1.1", "10.0.0.1", "2001:db8::1", "::1"]),
);
assert_eq!(
filter_addrs_by_preference(addrs, StackPrefer::V6first),
ips(&["2001:db8::1", "::1", "192.168.1.1", "10.0.0.1"]),
);
}
}
79 changes: 79 additions & 0 deletions crates/wind-core/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,82 @@ pub fn is_private_ip(ip: &IpAddr) -> bool {
}
}
}

#[cfg(test)]
mod tests {
use std::net::IpAddr;

use super::*;

fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}

#[test]
fn private_ipv4_ranges_are_private() {
for s in [
"10.0.0.1",
"10.255.255.255",
"172.16.0.0",
"172.31.255.255",
"192.168.0.1",
"192.168.255.255",
"169.254.0.1",
] {
assert!(is_private_ip(&ip(s)), "{s} should be private");
}
}

#[test]
fn public_ipv4_and_boundary_ranges_are_public() {
for s in [
"8.8.8.8",
"1.1.1.1",
"11.0.0.1",
"172.15.255.255", // just below the 172.16/12 block
"172.32.0.0", // just above it
"192.167.255.255",
"169.253.0.1",
] {
assert!(!is_private_ip(&ip(s)), "{s} should be public");
}
}

#[test]
fn private_ipv6_ranges_are_private() {
// fc00::/7 (fc.. and fd..) and fe80::/10 (fe80.. through febf..).
for s in ["fc00::1", "fd00::1", "fe80::1", "febf::1"] {
assert!(is_private_ip(&ip(s)), "{s} should be private");
}
}

#[test]
fn public_ipv6_ranges_are_public() {
// 2001:db8 doc range, loopback, and fec0 (outside fe80::/10).
for s in ["2001:db8::1", "::1", "fec0::1"] {
assert!(!is_private_ip(&ip(s)), "{s} should be public");
}
}

#[test]
fn stack_prefer_parses_all_aliases_case_insensitively() {
for s in ["v4", "v4only", "only_v4", "V4ONLY"] {
assert_eq!(s.parse::<StackPrefer>(), Ok(StackPrefer::V4only), "{s}");
}
for s in ["v6", "v6only", "only_v6"] {
assert_eq!(s.parse::<StackPrefer>(), Ok(StackPrefer::V6only), "{s}");
}
for s in ["v4v6", "v4first", "prefer_v4", "auto"] {
assert_eq!(s.parse::<StackPrefer>(), Ok(StackPrefer::V4first), "{s}");
}
for s in ["v6v4", "v6first", "prefer_v6"] {
assert_eq!(s.parse::<StackPrefer>(), Ok(StackPrefer::V6first), "{s}");
}
}

#[test]
fn stack_prefer_rejects_unknown() {
assert!("nonsense".parse::<StackPrefer>().is_err());
assert!("".parse::<StackPrefer>().is_err());
}
}
2 changes: 1 addition & 1 deletion crates/wind-socks/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ tracing = "0.1"
async-trait = "0.1"

[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "net", "time", "sync"] }
tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "net", "time", "sync", "io-util"] }
eyre = "0.6"
bytes = "1"
Loading
Loading