Skip to content

Commit 2cd395d

Browse files
committed
fix(proxy): suppress HTTPS DNS hints with QUIC blocked
When block_quic is enabled, browsers should move to the TCP proxy path as early as possible instead of spending retry time on HTTP/3 discovery. SOCKS5 UDP already drops UDP/443 datagrams, but modern clients can learn HTTP/3 availability before opening UDP by querying DNS HTTPS or SVCB records over UDP/53. Add a local DNS classifier in the SOCKS5 UDP relay for single-question IN queries of type HTTPS (65) and SVCB (64). Matching queries receive an empty successful DNS response with the original question preserved and all answer, authority, and additional counts cleared. Ordinary A/AAAA and multi-question queries continue through the existing UDP tunnel path unchanged. The suppression only runs when block_quic is active and only on UDP target port 53. It does not change CONNECT handling, TCP DNS, DoH bypass/block policy, Full-mode UDP support, or the existing silent UDP/443 drop contract. Local counters record suppressed HTTPS/SVCB hints and dropped QUIC datagrams for diagnostics. Add focused unit coverage for HTTPS suppression, SVCB suppression, non-suppression of A queries, and refusal to rewrite multi-question packets.
1 parent 40b5386 commit 2cd395d

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

src/domain_fronter.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,8 @@ pub struct DomainFronter {
390390
/// h2_fallbacks)` ratio indicates an unhealthy h2 conn or a flaky
391391
/// middlebox eating h2 frames; consider `force_http1: true`.
392392
h2_fallbacks: AtomicU64,
393+
policy_quic_udp_drops: AtomicU64,
394+
policy_https_rr_suppressed: AtomicU64,
393395
/// Per-host breakdown of traffic going through this fronter. Keyed by
394396
/// the host of the URL (e.g. "api.x.com"). Read-mostly; only touched
395397
/// on the slow path (once per relayed request), so a plain Mutex is
@@ -626,6 +628,8 @@ impl DomainFronter {
626628
bytes_relayed: AtomicU64::new(0),
627629
h2_calls: AtomicU64::new(0),
628630
h2_fallbacks: AtomicU64::new(0),
631+
policy_quic_udp_drops: AtomicU64::new(0),
632+
policy_https_rr_suppressed: AtomicU64::new(0),
629633
per_site: Arc::new(std::sync::Mutex::new(HashMap::new())),
630634
today_calls: AtomicU64::new(0),
631635
today_bytes: AtomicU64::new(0),
@@ -778,9 +782,22 @@ impl DomainFronter {
778782
h2_calls: self.h2_calls.load(Ordering::Relaxed),
779783
h2_fallbacks: self.h2_fallbacks.load(Ordering::Relaxed),
780784
h2_disabled: self.h2_disabled.load(Ordering::Relaxed),
785+
policy_quic_udp_drops: self.policy_quic_udp_drops.load(Ordering::Relaxed),
786+
policy_https_rr_suppressed: self
787+
.policy_https_rr_suppressed
788+
.load(Ordering::Relaxed),
781789
}
782790
}
783791

792+
pub fn record_quic_udp_drop(&self) {
793+
self.policy_quic_udp_drops.fetch_add(1, Ordering::Relaxed);
794+
}
795+
796+
pub fn record_https_rr_suppressed(&self) {
797+
self.policy_https_rr_suppressed
798+
.fetch_add(1, Ordering::Relaxed);
799+
}
800+
784801
pub fn num_scripts(&self) -> usize {
785802
self.script_ids.len()
786803
}
@@ -4831,6 +4848,11 @@ pub struct StatsSnapshot {
48314848
/// switch set, or peer refused h2 during ALPN). All traffic on the
48324849
/// h1 path.
48334850
pub h2_disabled: bool,
4851+
/// UDP/443 datagrams dropped locally while `block_quic` is enabled.
4852+
pub policy_quic_udp_drops: u64,
4853+
/// DNS SVCB/HTTPS questions answered locally with an empty response
4854+
/// while `block_quic` is enabled.
4855+
pub policy_https_rr_suppressed: u64,
48344856
}
48354857

48364858
impl StatsSnapshot {
@@ -4864,7 +4886,7 @@ impl StatsSnapshot {
48644886
}
48654887
};
48664888
format!(
4867-
"stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{}",
4889+
"stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{} policy=quic-drops:{} https-rr:{}",
48684890
self.relay_calls,
48694891
self.bytes_relayed / 1024,
48704892
self.relay_failures,
@@ -4876,6 +4898,8 @@ impl StatsSnapshot {
48764898
self.total_scripts - self.blacklisted_scripts,
48774899
self.total_scripts,
48784900
h2_seg,
4901+
self.policy_quic_udp_drops,
4902+
self.policy_https_rr_suppressed,
48794903
)
48804904
}
48814905

@@ -4888,7 +4912,7 @@ impl StatsSnapshot {
48884912
s.replace('\\', "\\\\").replace('"', "\\\"")
48894913
}
48904914
format!(
4891-
r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{}}}"#,
4915+
r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{},"policy_quic_udp_drops":{},"policy_https_rr_suppressed":{}}}"#,
48924916
self.relay_calls,
48934917
self.relay_failures,
48944918
self.coalesced,
@@ -4905,6 +4929,8 @@ impl StatsSnapshot {
49054929
self.h2_calls,
49064930
self.h2_fallbacks,
49074931
self.h2_disabled,
4932+
self.policy_quic_udp_drops,
4933+
self.policy_https_rr_suppressed,
49084934
)
49094935
}
49104936
}

src/proxy_server.rs

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,7 +924,7 @@ async fn handle_socks5_client(
924924

925925
if cmd == 0x03 {
926926
tracing::info!("SOCKS5 UDP ASSOCIATE requested for {}:{}", host, port);
927-
return handle_socks5_udp_associate(sock, rewrite_ctx, tunnel_mux).await;
927+
return handle_socks5_udp_associate(sock, fronter, rewrite_ctx, tunnel_mux).await;
928928
}
929929

930930
// Negative-cache short-circuit: if the tunnel-node just failed to reach
@@ -1095,6 +1095,7 @@ const MAX_UDP_PAYLOAD_BYTES: usize = 9 * 1024;
10951095

10961096
async fn handle_socks5_udp_associate(
10971097
mut control: TcpStream,
1098+
fronter: Option<Arc<DomainFronter>>,
10981099
rewrite_ctx: Arc<RewriteCtx>,
10991100
tunnel_mux: Option<Arc<TunnelMux>>,
11001101
) -> std::io::Result<()> {
@@ -1190,6 +1191,23 @@ async fn handle_socks5_udp_associate(
11901191
};
11911192
let payload_slice = &recv_buf[payload_off..n];
11921193

1194+
if rewrite_ctx.block_quic && target.port == 53 {
1195+
if let Some(response) = dns_empty_response_for_https_or_svcb(payload_slice) {
1196+
if let Some(f) = &fronter {
1197+
f.record_https_rr_suppressed();
1198+
}
1199+
let framed = build_socks5_udp_packet(&target, &response);
1200+
if let Err(e) = udp.send_to(&framed, peer).await {
1201+
tracing::debug!(
1202+
"udp DNS HTTPS/SVCB suppression reply to {} failed: {}",
1203+
peer,
1204+
e
1205+
);
1206+
}
1207+
continue;
1208+
}
1209+
}
1210+
11931211
// Issue #213: client-side QUIC block. UDP/443 is
11941212
// HTTP/3 — drop the datagram silently so the client
11951213
// stack retries a couple of times and then falls back
@@ -1207,6 +1225,9 @@ async fn handle_socks5_udp_associate(
12071225
// a "no response → fall back" timeout, so silent drop
12081226
// is the contractually correct shape.
12091227
if rewrite_ctx.block_quic && target.port == 443 {
1228+
if let Some(f) = &fronter {
1229+
f.record_quic_udp_drop();
1230+
}
12101231
tracing::debug!(
12111232
"udp dropped: block_quic=true, target {}:443",
12121233
target.host
@@ -1596,6 +1617,69 @@ fn build_socks5_udp_packet(target: &SocksUdpTarget, payload: &[u8]) -> Vec<u8> {
15961617
out
15971618
}
15981619

1620+
const DNS_TYPE_SVCB: u16 = 64;
1621+
const DNS_TYPE_HTTPS: u16 = 65;
1622+
1623+
fn dns_empty_response_for_https_or_svcb(query: &[u8]) -> Option<Vec<u8>> {
1624+
let question_end = dns_single_question_https_or_svcb_end(query)?;
1625+
let mut out = Vec::with_capacity(question_end);
1626+
out.extend_from_slice(&query[..question_end]);
1627+
// QR=1, opcode copied, AA=0, TC=0, RD copied, RA=0, Z=0, RCODE=0.
1628+
out[2] = (query[2] & 0x78) | 0x80 | (query[2] & 0x01);
1629+
out[3] = 0x00;
1630+
// QDCOUNT stays 1. ANCOUNT, NSCOUNT, ARCOUNT become 0.
1631+
out[6] = 0;
1632+
out[7] = 0;
1633+
out[8] = 0;
1634+
out[9] = 0;
1635+
out[10] = 0;
1636+
out[11] = 0;
1637+
Some(out)
1638+
}
1639+
1640+
fn dns_single_question_https_or_svcb_end(query: &[u8]) -> Option<usize> {
1641+
if query.len() < 12 {
1642+
return None;
1643+
}
1644+
let qdcount = u16::from_be_bytes([query[4], query[5]]);
1645+
if qdcount != 1 {
1646+
return None;
1647+
}
1648+
if query[2] & 0x80 != 0 {
1649+
return None;
1650+
}
1651+
1652+
let mut pos = 12usize;
1653+
loop {
1654+
let len = *query.get(pos)?;
1655+
pos += 1;
1656+
if len == 0 {
1657+
break;
1658+
}
1659+
if len & 0xc0 != 0 {
1660+
return None;
1661+
}
1662+
let label_len = len as usize;
1663+
if label_len > 63 || pos.checked_add(label_len)? > query.len() {
1664+
return None;
1665+
}
1666+
pos += label_len;
1667+
}
1668+
if pos.checked_add(4)? > query.len() {
1669+
return None;
1670+
}
1671+
let qtype = u16::from_be_bytes([query[pos], query[pos + 1]]);
1672+
let qclass = u16::from_be_bytes([query[pos + 2], query[pos + 3]]);
1673+
if qclass != 1 {
1674+
return None;
1675+
}
1676+
if qtype == DNS_TYPE_SVCB || qtype == DNS_TYPE_HTTPS {
1677+
Some(pos + 4)
1678+
} else {
1679+
None
1680+
}
1681+
}
1682+
15991683
// ---------- Smart dispatch (used by both HTTP CONNECT and SOCKS5) ----------
16001684

16011685
fn should_use_sni_rewrite(
@@ -3501,6 +3585,52 @@ mod tests {
35013585
assert!(matches_passthrough("example.com.", &list));
35023586
}
35033587

3588+
fn dns_query(qtype: u16) -> Vec<u8> {
3589+
let mut q = Vec::new();
3590+
q.extend_from_slice(&[0x12, 0x34, 0x01, 0x00]);
3591+
q.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3592+
q.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
3593+
for label in ["www", "example", "com"] {
3594+
q.push(label.len() as u8);
3595+
q.extend_from_slice(label.as_bytes());
3596+
}
3597+
q.push(0);
3598+
q.extend_from_slice(&qtype.to_be_bytes());
3599+
q.extend_from_slice(&1u16.to_be_bytes());
3600+
q
3601+
}
3602+
3603+
#[test]
3604+
fn dns_https_question_gets_empty_success_response() {
3605+
let query = dns_query(DNS_TYPE_HTTPS);
3606+
let response = dns_empty_response_for_https_or_svcb(&query).unwrap();
3607+
assert_eq!(&response[0..2], &[0x12, 0x34]);
3608+
assert_eq!(response[2] & 0x80, 0x80);
3609+
assert_eq!(response[3] & 0x0f, 0);
3610+
assert_eq!(&response[4..6], &[0x00, 0x01]);
3611+
assert_eq!(&response[6..12], &[0, 0, 0, 0, 0, 0]);
3612+
assert_eq!(&response[12..], &query[12..]);
3613+
}
3614+
3615+
#[test]
3616+
fn dns_svcb_question_gets_empty_success_response() {
3617+
let query = dns_query(DNS_TYPE_SVCB);
3618+
assert!(dns_empty_response_for_https_or_svcb(&query).is_some());
3619+
}
3620+
3621+
#[test]
3622+
fn dns_a_question_is_not_suppressed() {
3623+
let query = dns_query(1);
3624+
assert!(dns_empty_response_for_https_or_svcb(&query).is_none());
3625+
}
3626+
3627+
#[test]
3628+
fn dns_multi_question_query_is_not_suppressed() {
3629+
let mut query = dns_query(DNS_TYPE_HTTPS);
3630+
query[5] = 2;
3631+
assert!(dns_empty_response_for_https_or_svcb(&query).is_none());
3632+
}
3633+
35043634
#[test]
35053635
fn doh_default_list_exact_matches() {
35063636
let extra: Vec<String> = vec![];

0 commit comments

Comments
 (0)