feat: add block_quic config option (#213)

w0l4i has been asking for client-side QUIC block since #213. Now
implemented as a small config flag.

When `block_quic = true`, the SOCKS5 UDP relay drops any datagram
destined for port 443 — that's HTTP/3-over-UDP. The client's QUIC
stack retries a couple of times and then falls back to TCP/HTTPS
through the regular CONNECT path (which goes through the relay
normally).

Why client-side rather than server-side udpgw block: the udpgw
block in #222 is bound to Full mode + Android tun2proxy. This
covers everyone — apps_script users, desktop, Full mode, all the
same path. Skipping at the SOCKS5 layer rather than the tunnel-node
layer also avoids paying 200–500 ms tunnel-node round-trip per
QUIC datagram drop, which compounds during browser retries.

Silent drop is the contractually correct shape: SOCKS5 UDP wire
has no `host unreachable` reply (RFC 1928 §6 only defines that for
TCP CONNECT). Browsers' QUIC stacks have a "no response → fall
back" timeout, so silent drop matches what the protocol expects.

Default false (opt-in) — udpgw mitigates QUIC partly via persistent
sockets, and a tiny minority of sites only support HTTP/3.

Will ship in v1.7.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
therealaleph
2026-04-26 23:50:18 +03:00
parent 2a5946f457
commit 124d0c378d
2 changed files with 56 additions and 0 deletions
+27
View File
@@ -163,6 +163,33 @@ pub struct Config {
/// Issues #39, #127.
#[serde(default)]
pub passthrough_hosts: Vec<String>,
/// Block outbound QUIC (UDP/443) at the SOCKS5 listener.
///
/// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless —
/// Apps Script is HTTP-only, so QUIC datagrams either get refused
/// outright (UDP ASSOCIATE rejected) or silently fall through to
/// `raw-tcp direct` and fail in interesting ways. In `full` mode
/// the tunnel-node CAN carry UDP, but QUIC's congestion control
/// stacked on top of TCP-encapsulated transport produces TCP
/// meltdown for any non-trivial bandwidth — browsers see <1 Mbps
/// where the same site over plain HTTPS would do >50.
///
/// With `block_quic = true`, the SOCKS5 UDP relay drops any
/// datagram destined for port 443 (silent UDP — caller's stack
/// retries a few times then falls back). Browsers then re-issue
/// the same request as TCP/HTTPS through the regular CONNECT
/// path, which goes through the relay normally.
///
/// Why this is opt-in rather than always-on: for users on Full
/// mode + udpgw (a recent path; v1.7.0+) the QUIC TCP-meltdown
/// is partially mitigated by udpgw's persistent-socket reuse,
/// and a tiny minority of sites only support HTTP/3 (rare). The
/// flag lets users who care about consistency over peak speed
/// opt out of QUIC at the source rather than discovering its
/// failure modes later. Issue #213.
#[serde(default)]
pub block_quic: bool,
}
fn default_fetch_ips_from_api() -> bool { false }
+29
View File
@@ -195,6 +195,10 @@ pub struct RewriteCtx {
/// and pass through as plain TCP (optionally via upstream_socks5).
/// See config.rs `passthrough_hosts` for matching rules. Issues #39, #127.
pub passthrough_hosts: Vec<String>,
/// If true, drop SOCKS5 UDP datagrams destined for port 443 so
/// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
/// the trade-off. Issue #213.
pub block_quic: bool,
}
/// True if `host` matches any entry in the user's passthrough list.
@@ -263,6 +267,7 @@ impl ProxyServer {
mode,
youtube_via_relay: config.youtube_via_relay,
passthrough_hosts: config.passthrough_hosts.clone(),
block_quic: config.block_quic,
});
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
@@ -864,6 +869,30 @@ async fn handle_socks5_udp_associate(
continue;
};
// Issue #213: client-side QUIC block. UDP/443 is
// HTTP/3 — drop the datagram silently so the client
// stack retries a couple of times and then falls back
// to TCP/HTTPS, which goes through the regular CONNECT
// path. Skipping this at the SOCKS5 layer (rather than
// letting it hit the tunnel-node) avoids paying the
// 200500 ms tunnel-node round-trip per dropped QUIC
// datagram, which would otherwise compound during the
// 13 retries before the browser falls back.
//
// Silent drop instead of an explicit error reply: the
// SOCKS5 UDP wire has no "destination unreachable"
// datagram — `0x04` only exists in TCP CONNECT replies
// (RFC 1928 §6). The browser's QUIC stack already has
// a "no response → fall back" timeout, so silent drop
// is the contractually correct shape.
if rewrite_ctx.block_quic && target.port == 443 {
tracing::debug!(
"udp dropped: block_quic=true, target {}:443",
target.host
);
continue;
}
// RFC 1928 §6: lock to the first VALID datagram's source
// port. Subsequent datagrams must come from the same
// (ip, port) pair.