Skip to content

feat: client-side UDP socket rebind for source-port rotation#127

Open
hrimfaxi wants to merge 4 commits into
spongebob888:mainfrom
hrimfaxi:rebind_port
Open

feat: client-side UDP socket rebind for source-port rotation#127
hrimfaxi wants to merge 4 commits into
spongebob888:mainfrom
hrimfaxi:rebind_port

Conversation

@hrimfaxi

@hrimfaxi hrimfaxi commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

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.

@hrimfaxi

Copy link
Copy Markdown
Contributor Author

Client example:

outbound:
    max-rebind-interval: 2m
    min-rebind-interval: 10s
    # rebind-interval: 3m

@hrimfaxi hrimfaxi force-pushed the rebind_port branch 3 times, most recently from e57d543 to cd800c6 Compare April 21, 2026 09:42
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>
@hrimfaxi

Copy link
Copy Markdown
Contributor Author

V2:

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.

@happytrudy

Copy link
Copy Markdown
Contributor

What is the purpose of source port switching? Can it enhance anti-blocking capabilities?

@hrimfaxi

Copy link
Copy Markdown
Contributor Author

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.

@spongebob888

spongebob888 commented Apr 22, 2026

Copy link
Copy Markdown
Owner

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

@happytrudy

happytrudy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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.

@happytrudy

happytrudy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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.

Comment thread shadowquic/src/shadowquic/outbound.rs Outdated
format_duration(hi)
);

let bind_addr = "[::]:0";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may fail if it's a ipv4-only device

Comment thread shadowquic/src/sunnyquic/outbound.rs Outdated
format_duration(hi)
);

let bind_addr = "[::]:0";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@hrimfaxi

Copy link
Copy Markdown
Contributor Author

use matching address family for QUIC rebind task

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_duration utilities 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.

Comment on lines +191 to +195
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")
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/shadowquic/outbound.rs Outdated
Comment on lines +107 to +110
let interval_ms = {
let mut rng = rand::rng();
rng.random_range(lo..=hi) as u64
};

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/shadowquic/outbound.rs Outdated
Comment on lines +111 to +115
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) => {

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +147
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)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/sunnyquic/outbound.rs Outdated
Comment on lines +115 to +119
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) => {

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/Cargo.toml
console-subscriber = { version = "0.5.0", optional = true }
serde-saphyr = "0.0.18"
ring = "0.17.14"
rand = "0.9.2"

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +148
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)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/shadowquic/outbound.rs Outdated
Comment on lines +85 to +89
let (min_ms, max_ms) = rebind_interval?;
let (lo, hi) = if min_ms <= max_ms {
(min_ms, max_ms)
} else {
(max_ms, min_ms)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/sunnyquic/outbound.rs Outdated
Comment on lines +89 to +93
let (min_ms, max_ms) = rebind_interval?;
let (lo, hi) = if min_ms <= max_ms {
(min_ms, max_ms)
} else {
(max_ms, min_ms)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/sunnyquic/outbound.rs Outdated
Comment on lines +112 to +113
let mut rng = rand::rng();
rng.random_range(lo..=hi) as u64

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread shadowquic/src/shadowquic/outbound.rs Outdated
- 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>
@hrimfaxi

Copy link
Copy Markdown
Contributor Author

unify endpoint socket rebind logic across shadowquic and sunnyquic

  • 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

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>
@spongebob888

Copy link
Copy Markdown
Owner

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants