feat: client-side UDP socket rebind for source-port rotation#127
feat: client-side UDP socket rebind for source-port rotation#127hrimfaxi wants to merge 4 commits into
Conversation
|
Client example: |
e57d543 to
cd800c6
Compare
Add periodic rebind of the underlying UDP socket via quinn::Endpoint::rebind(). The QUIC connection itself is preserved across rebinds—no reconnect, no re-authentication, and no stream interruption. Only the local source port is changed. Configuration (all optional): - rebind_interval / min_rebind_interval / max_rebind_interval Accept human-readable durations such as "30s", "5m", "1h". When configured, a background task sleeps for a random duration inside the range, binds a new ephemeral UDP socket, and calls endpoint.rebind() atomically. Other changes: - Use a single dual-stack endpoint backed by an IPv6 socket with IPV6_V6ONLY=false, transparently handling both IPv4 and IPv6 traffic. This removes the previous v4/v6 split and avoids address-family mismatch when DNS result ordering changes. - init_endpoint() always tries IPv6 dual-stack first, falling back to plain IPv4 only when dual-stack is unavailable. - resolve_addrs() now tries all resolved addresses instead of only the first one, improving dual-stack connectivity. - Abort rebind background tasks on Drop to prevent tokio task and FD leakage. - Add parse_duration_str() and format_duration() with unit tests for ms/s/m/h/d suffixes and fractional values. Signed-off-by: hrimfaxi <outmatch@gmail.com>
|
V2: |
|
What is the purpose of source port switching? Can it enhance anti-blocking capabilities? |
Source port switching is mainly a lightweight path/flow perturbation mechanism. It may improve resistance to simple 5-tuple-based blocking, but it should not be expected to significantly strengthen anti-blocking capability against more advanced DPI systems. |
|
Isn't this client-side port hopping. Server-side port hopping is abnormal for a general website. I'd rather not implement it. Most port hopping is implemented by simple reforwarding traffic from one port to the real quic port. This exploits the passive migration feature of QUIC, but it still can be tracked by connection ID. This implementation is better. It uses rebind which is QUIC stack aware and internally it is implemented by active migration of QUIC feature. After changing to the new port, it instantly uses a new connection ID and can't be tracked(since connection ID negotiation is encrypted ). From the side of an observer, it sees an QUIC connection vanlished and regard this connection from new port an unknown connection. The privacy is much better |
|
My understanding is that this is a feature derived from QUIC, used solely to improve privacy. It allows setting default values for configurations that are imperceptible to the user. |
|
The previous implementation involved the client periodically or randomly hopping to connect to the server port, primarily to combat QoS and enhance anti-blocking capabilities, supporting random hopping within a specified time period. However, re-establishing the connection using QUIC resulted in frequent interruptions, with the client logging a timeout. To achieve seamless switching to the new port, the design of HY2 was referenced, handling the dual-connection mode at the UDP layer. This inevitably led to a significant amount of code being written. |
| format_duration(hi) | ||
| ); | ||
|
|
||
| let bind_addr = "[::]:0"; |
There was a problem hiding this comment.
it may fail if it's a ipv4-only device
| format_duration(hi) | ||
| ); | ||
|
|
||
| let bind_addr = "[::]:0"; |
There was a problem hiding this comment.
Similarly, ipv4 only device may have problem
Hardcoding "[::]:0" causes rebind failures on IPv4-only hosts. This patch tracks the address family from `init_endpoint` and ensures the background rebind task uses the correct socket type (IPv4 or IPv6) to maintain compatibility across different network environments Signed-off-by: hrimfaxi <outmatch@gmail.com>
|
use matching address family for QUIC rebind task |
There was a problem hiding this comment.
Pull request overview
Implements client-side UDP socket rebinding to rotate the local source port while keeping existing QUIC connections alive, and adds configuration support for human-readable duration intervals.
Changes:
- Add a background task that periodically binds a new UDP socket and calls
Endpoint::rebind()to rotate the source port. - Move toward a single dual-stack endpoint approach and try all resolved target addresses when connecting.
- Add
parse_duration_str/format_durationutilities plus serde deserialization support and unit tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| shadowquic/tests/parse_duration.rs | Adds integration tests for duration string parsing. |
| shadowquic/src/sunnyquic/outbound.rs | Adds rebind background task, multi-address connect attempts, and Drop abort logic. |
| shadowquic/src/shadowquic/outbound.rs | Adds rebind background task, multi-address connect attempts, and Drop abort logic. |
| shadowquic/src/sunnyquic/iroh_wrapper/wrapper.rs | Makes Endpoint clonable to support sharing into the rebind task. |
| shadowquic/src/shadowquic/quinn_wrapper/wrapper.rs | Makes Endpoint clonable to support sharing into the rebind task. |
| shadowquic/src/config/sunnyquic.rs | Adds rebind_interval/min/max config fields with duration deserialization. |
| shadowquic/src/config/shadowquic.rs | Adds duration parse/format helpers and a custom serde deserializer for duration-ms fields. |
| shadowquic/Cargo.toml | Adds rand dependency for randomized rebind scheduling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| impl<'de> serde::de::Visitor<'de> for OptDurationMs { | ||
| type Value = Option<u32>; | ||
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { | ||
| f.write_str("integer or duration string like 30s, 500ms") | ||
| } |
There was a problem hiding this comment.
deserialize_duration_ms's Visitor only implements visit_u64, visit_str, and visit_none. Some deserializers provide strings via visit_string (and integers via visit_u32/visit_i64), which would currently reject otherwise-valid config values. Add visit_string (delegate to parse_duration_str), and consider visit_u32/visit_i64 to accept integer durations consistently and reject negatives cleanly (similar to deserialize_bps).
| let interval_ms = { | ||
| let mut rng = rand::rng(); | ||
| rng.random_range(lo..=hi) as u64 | ||
| }; |
There was a problem hiding this comment.
The rebind loop uses rand::rng() and rng.random_range(lo..=hi). These APIs don't match the standard rand Rng API (thread_rng() + gen_range), so this is likely a compile error. Use the supported RNG constructor and range method for the rand version in this workspace.
| sleep(Duration::from_millis(interval_ms)).await; | ||
|
|
||
| match tokio::net::UdpSocket::bind(bind_addr).await { | ||
| Ok(tokio_socket) => match tokio_socket.into_std() { | ||
| Ok(std_socket) => { |
There was a problem hiding this comment.
When rebinding with an IPv6 bind address, the code uses tokio::net::UdpSocket::bind("[::]:0"), but this does not ensure IPV6_V6ONLY=false. The endpoint init path explicitly sets only_v6(false) for dual-stack; after a rebind, IPv4 traffic may stop working on platforms where v6 sockets default to v6-only. Create/configure the rebind socket the same way as endpoint initialization (e.g., via socket2 + set_only_v6(false)).
| let (end, is_ipv6) = match self.init_endpoint(true).await { | ||
| Ok(end) => (end, true), | ||
| Err(_) => { | ||
| let end = self | ||
| .init_endpoint(false) | ||
| .await | ||
| .expect("error during initialize quic endpoint"); | ||
| (end, false) |
There was a problem hiding this comment.
This endpoint initialization calls init_endpoint(true) first. In the endpoint implementation, ipv6=true means “try dual-stack, else IPv6-only”, not “else IPv4-only”, so on hosts without dual-stack you can end up with an IPv6-only endpoint and fail to connect to IPv4-only servers/DNS results. If the intended fallback is IPv4, consider calling init_endpoint(false) (which still attempts dual-stack first) or making the fallback explicitly IPv4-only.
| let (end, is_ipv6) = match self.init_endpoint(true).await { | |
| Ok(end) => (end, true), | |
| Err(_) => { | |
| let end = self | |
| .init_endpoint(false) | |
| .await | |
| .expect("error during initialize quic endpoint"); | |
| (end, false) | |
| let (end, is_ipv6) = match self.init_endpoint(false).await { | |
| Ok(end) => (end, false), | |
| Err(_) => { | |
| let end = self | |
| .init_endpoint(true) | |
| .await | |
| .expect("error during initialize quic endpoint"); | |
| (end, true) |
| sleep(Duration::from_millis(interval_ms)).await; | ||
|
|
||
| match tokio::net::UdpSocket::bind(bind_addr).await { | ||
| Ok(tokio_socket) => match tokio_socket.into_std() { | ||
| Ok(std_socket) => { |
There was a problem hiding this comment.
When rebinding with an IPv6 bind address, the code uses tokio::net::UdpSocket::bind("[::]:0"), but this does not ensure IPV6_V6ONLY=false. The endpoint init path explicitly sets only_v6(false) for dual-stack; after a rebind, IPv4 traffic may stop working on platforms where v6 sockets default to v6-only. Create/configure the rebind socket the same way as endpoint initialization (e.g., via socket2 + set_only_v6(false)).
| console-subscriber = { version = "0.5.0", optional = true } | ||
| serde-saphyr = "0.0.18" | ||
| ring = "0.17.14" | ||
| rand = "0.9.2" |
There was a problem hiding this comment.
rand is declared under [dependencies], but it is already present under [dev-dependencies] in this Cargo.toml. Unless you specifically need different features/versions for dev vs prod, keep a single declaration to avoid accidental version skew (move it to [dependencies] only and remove the dev-dependency entry, or vice versa).
| let (end, is_ipv6) = match self.init_endpoint(true).await { | ||
| Ok(end) => (end, true), | ||
| Err(_) => { | ||
| let end = self.init_endpoint(false).await.expect("..."); | ||
| (end, false) |
There was a problem hiding this comment.
This endpoint initialization calls init_endpoint(true) first. In the endpoint implementation, ipv6=true means “try dual-stack, else IPv6-only”, not “else IPv4-only”, so on hosts without dual-stack you can end up with an IPv6-only endpoint and fail to connect to IPv4-only servers/DNS results. If the intended fallback is IPv4, consider calling init_endpoint(false) (which still attempts dual-stack first) or making the fallback explicitly IPv4-only.
| let (end, is_ipv6) = match self.init_endpoint(true).await { | |
| Ok(end) => (end, true), | |
| Err(_) => { | |
| let end = self.init_endpoint(false).await.expect("..."); | |
| (end, false) | |
| let (end, is_ipv6) = match self.init_endpoint(false).await { | |
| Ok(end) => (end, false), | |
| Err(_) => { | |
| let end = self.init_endpoint(true).await.expect("..."); | |
| (end, true) |
| let (min_ms, max_ms) = rebind_interval?; | ||
| let (lo, hi) = if min_ms <= max_ms { | ||
| (min_ms, max_ms) | ||
| } else { | ||
| (max_ms, min_ms) |
There was a problem hiding this comment.
If lo is configured as 0 while hi is > 0, the random interval can be 0, causing an immediate wakeup and potentially a tight rebind loop. Consider clamping lo to 1 when hi > 0, or explicitly rejecting a 0 minimum interval.
| let (min_ms, max_ms) = rebind_interval?; | ||
| let (lo, hi) = if min_ms <= max_ms { | ||
| (min_ms, max_ms) | ||
| } else { | ||
| (max_ms, min_ms) |
There was a problem hiding this comment.
If lo is configured as 0 while hi is > 0, the random interval can be 0, causing an immediate wakeup and potentially a tight rebind loop. Consider clamping lo to 1 when hi > 0, or explicitly rejecting a 0 minimum interval.
| let mut rng = rand::rng(); | ||
| rng.random_range(lo..=hi) as u64 |
There was a problem hiding this comment.
The rebind loop uses rand::rng() and rng.random_range(lo..=hi). These APIs don't exist in the commonly-used rand Rng API (e.g., thread_rng() + gen_range), so this is likely a compile error or at least inconsistent with the rest of the ecosystem. Switch to the supported RNG constructor and range method for your chosen rand version.
| let mut rng = rand::rng(); | |
| rng.random_range(lo..=hi) as u64 | |
| let mut rng = rand::thread_rng(); | |
| rand::Rng::gen_range(&mut rng, lo..=hi) as u64 |
- Consolidate duplicated QUIC socket rebinding implementations into a shared abstraction - Replace per-client rebind task spawning logic with a single reusable implementation - Introduce unified configuration handling for rebind intervals (min/max/default) - Move rebind task lifecycle management into RAII-based automatic cleanup - Remove manual rebind task management from both ShadowQuic and SunnyQuic clients - Simplify endpoint initialization by delegating rebinding responsibility to the shared implementation - Ensure consistent socket rebinding behavior across both QUIC stacks - Reduce code duplication and improve maintainability of endpoint management logic Signed-off-by: hrimfaxi <outmatch@gmail.com>
|
unify endpoint socket rebind logic across shadowquic and sunnyquic
|
Move Rebindable, RebindConfig, and RebindEndpoint from duplicated per-wrapper modules into a single crate-level rebind module. - Delete shadowquic/src/sunnyquic/iroh_wrapper/rebind_endpoint.rs - Rename shadowquic/src/shadowquic/quinn_wrapper/rebind_endpoint.rs to shadowquic/src/rebind.rs - Update imports in outbound.rs and wrapper.rs files - Add `pub mod rebind` to lib.rs This eliminates the copy-paste duplication between quinn_wrapper and iroh_wrapper, making the rebind logic truly shared and maintainable in one place. Signed-off-by: hrimfaxi <outmatch@gmail.com>
|
This is a big change and need some refactor work to contain protect socket on android and fw_mark in the future or any other user-defined socket options for other tools like clash-rs. I will consider refactor this |
Add periodic rebind of the underlying UDP socket via quinn::Endpoint::rebind().
The QUIC connection itself is preserved across rebinds—no reconnect, no
re-authentication, and no stream interruption. Only the local source port
is changed.
Configuration (all optional):
Accept human-readable durations such as "30s", "5m", "1h".
When configured, a background task sleeps for a random duration inside
the range, binds a new ephemeral UDP socket, and calls
endpoint.rebind() atomically.
Other changes:
IPV6_V6ONLY=false, transparently handling both IPv4 and IPv6 traffic.
This removes the previous v4/v6 split and avoids address-family
mismatch when DNS result ordering changes.
plain IPv4 only when dual-stack is unavailable.
first one, improving dual-stack connectivity.
leakage.
ms/s/m/h/d suffixes and fractional values.