fix(server): outbound connect no longer lets a dead IPv6 candidate eat the whole timeout

Matches a real report: traffic counters move (the OSTP tunnel handshakes
fine) but sites don't open, or take very long - on a freshly deployed
DigitalOcean droplet in Amsterdam.

connect_target's fallback path handed the raw "host:port" string straight
to TcpStream::connect, which resolves and tries addresses internally but
shares ONE 10s timeout across the WHOLE attempt (all resolved addresses,
not per-address). Some VPS hosts assign the machine an IPv6 address that
the OS prefers by RFC 6724 ordering but that has no actually-working
outbound route - the connect doesn't get refused, it just hangs. With a
single shared budget, that one dead IPv6 candidate eats the entire 10s and
the working IPv4 candidate is never even attempted: every dual-stack
destination (i.e. most popular sites) times out, while IPv4-only
destinations work fine.

New connect_direct() resolves target itself via lookup_host, sorts IPv4
candidates first, and tries each with its own 3s budget (still bounded
overall by the original 10s outer timeout as a backstop) so a hung IPv6
attempt can't starve the IPv4 fallback of a chance.

Added tests: IPv4-first sort ordering (and stability within a family), a
successful connect against a live local listener, and a refused-port
connect failing well under the timeout (proving failures aren't
needlessly slow). Scoped to connect_target's direct-connect paths; the
SOCKS5/HTTP outbound-proxy paths and the fallback/camouflage TCP proxy
(which targets a fixed admin-configured local address, not arbitrary
dual-stack hostnames) are unaffected.
This commit is contained in:
ospab 2026-07-18 16:40:57 +03:00
parent c6a130673d
commit c523b083cb
1 changed files with 97 additions and 8 deletions

View File

@ -51,19 +51,63 @@ pub async fn connect_target(
return match outbound.protocol.as_str() { return match outbound.protocol.as_str() {
"socks5" => connect_via_socks5(&proxy_addr, target).await, "socks5" => connect_via_socks5(&proxy_addr, target).await,
"http" => connect_via_http(&proxy_addr, target).await, "http" => connect_via_http(&proxy_addr, target).await,
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target)) _ => connect_direct(target, connect_timeout).await,
.await
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
.map_err(Into::into),
}; };
} }
} }
} }
tokio::time::timeout(connect_timeout, TcpStream::connect(target)) connect_direct(target, connect_timeout).await
.await }
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
.map_err(Into::into) /// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
/// below). Short enough that a single dead-end address can't eat the whole
/// outer `connect_timeout` budget.
const PER_ADDR_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// Resolve `target` ("host:port") and connect to it, trying candidate
/// addresses in turn rather than handing the raw string straight to
/// `TcpStream::connect` (which resolves and tries addresses internally but
/// shares ONE timeout across the whole attempt).
///
/// IPv4 candidates are tried first. Some VPS hosts (observed on a
/// DigitalOcean droplet) assign the machine an IPv6 address that the OS
/// prefers by RFC 6724 ordering but that has no actually-working outbound
/// route - the connect attempt doesn't get refused, it just hangs. With a
/// single shared timeout across all candidates, that one dead IPv6 address
/// eats the entire budget and the working IPv4 candidate is never even
/// attempted: every dual-stack destination (i.e. most popular sites) never
/// loads, while IPv4-only destinations work fine - exactly the "traffic
/// counter moves but sites don't open" symptom this fixes.
async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpStream> {
tokio::time::timeout(connect_timeout, async {
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
.await
.map_err(|e| anyhow::anyhow!("dns resolution failed for {}: {}", target, e))?
.collect();
if addrs.is_empty() {
return Err(anyhow::anyhow!("no addresses resolved for {}", target));
}
prefer_ipv4_first(&mut addrs);
let mut last_err = None;
for addr in addrs {
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => return Ok(stream),
Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)),
Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())),
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all candidates failed for {}", target)))
})
.await
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
}
/// Stable-sort so IPv4 candidates come before IPv6 ones, without otherwise
/// disturbing the resolver's original ordering within each family.
fn prefer_ipv4_first(addrs: &mut [std::net::SocketAddr]) {
addrs.sort_by_key(|a| a.is_ipv6());
} }
// ── Rule matching ──────────────────────────────────────────────────────────── // ── Rule matching ────────────────────────────────────────────────────────────
@ -540,4 +584,49 @@ mod tests {
fn test_match_domain_rule_empty() { fn test_match_domain_rule_empty() {
assert!(!match_domain_rule("example.com", &[])); assert!(!match_domain_rule("example.com", &[]));
} }
#[test]
fn test_prefer_ipv4_first_reorders_mixed_list() {
let v6: std::net::SocketAddr = "[2001:db8::1]:443".parse().unwrap();
let v4: std::net::SocketAddr = "192.0.2.1:443".parse().unwrap();
let mut addrs = vec![v6, v4];
prefer_ipv4_first(&mut addrs);
assert_eq!(addrs, vec![v4, v6], "IPv4 candidate must sort before IPv6");
}
#[test]
fn test_prefer_ipv4_first_preserves_order_within_family() {
// Two IPv4 addresses: relative order should be untouched (stable sort).
let a: std::net::SocketAddr = "192.0.2.1:443".parse().unwrap();
let b: std::net::SocketAddr = "192.0.2.2:443".parse().unwrap();
let mut addrs = vec![a, b];
prefer_ipv4_first(&mut addrs);
assert_eq!(addrs, vec![a, b]);
}
#[tokio::test]
async fn test_connect_direct_succeeds_against_live_listener() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = listener.accept().await;
});
let result = connect_direct(&addr.to_string(), Duration::from_secs(2)).await;
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
}
#[tokio::test]
async fn test_connect_direct_fails_fast_on_refused_port() {
// Bind and immediately drop to get a port nothing is listening on,
// so the OS sends RST and the attempt fails well under the timeout.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let start = std::time::Instant::now();
let result = connect_direct(&addr.to_string(), Duration::from_secs(5)).await;
assert!(result.is_err(), "connecting to a closed port should fail");
assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
}
} }