Skip to content

Commit 92951e7

Browse files
authored
feat: H1 container keepalive + 431 oversized-headers (#438)
Cherry-picks of stability/hardening fixes from upstream Python (masterking32/MasterHttpRelayVPN), Apr 23-26 window. By @dazzling-no-more. - 240s H1 container keepalive prevents Apps Script V8 cold-start stalls - 64 KB header-block cap returns explicit HTTP/1.1 431 instead of socket drop - Clearer port-collision error message Local verification: cargo build clean, cargo test --lib 154/154 passing.
2 parents 4c7c90a + 3ce3d81 commit 92951e7

3 files changed

Lines changed: 148 additions & 23 deletions

File tree

src/config.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,11 @@ impl Config {
268268
));
269269
}
270270
if self.socks5_port == Some(self.listen_port) {
271-
return Err(ConfigError::Invalid(
272-
"listen_port and socks5_port must be different".into(),
273-
));
271+
return Err(ConfigError::Invalid(format!(
272+
"listen_port and socks5_port must differ on the same host \
273+
(both set to {} on {}). Change one of them in config.json.",
274+
self.listen_port, self.listen_host
275+
)));
274276
}
275277
Ok(())
276278
}

src/domain_fronter.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ const POOL_TTL_SECS: u64 = 45;
6161
const POOL_MAX: usize = 80;
6262
const REQUEST_TIMEOUT_SECS: u64 = 25;
6363
const RANGE_PARALLEL_CHUNK_BYTES: u64 = 256 * 1024;
64+
/// Cadence for Apps Script container keepalive pings. Apps Script
65+
/// containers go cold after ~5min idle and cost 1-3s on the first
66+
/// request to wake back up — most painful on YouTube / streaming where
67+
/// the first chunk after a quiet pause stalls the player.
68+
const H1_KEEPALIVE_INTERVAL_SECS: u64 = 240;
6469
// Keep synthetic range stitching bounded. Without this, a buggy or hostile
6570
// origin can advertise `Content-Range: bytes 0-1/<huge>` and make us build a
6671
// massive range plan or preallocate an enormous response buffer.
@@ -595,6 +600,45 @@ impl DomainFronter {
595600
}
596601
}
597602

603+
/// Keep the Apps Script container warm with a periodic HEAD ping.
604+
///
605+
/// `acquire()` keeps the *TCP/TLS pool* warm but does nothing for the
606+
/// V8 container Apps Script runs in: that goes cold ~5min after the
607+
/// last UrlFetchApp call and costs 1-3s to spin back up. The symptom
608+
/// is "first request after a quiet period stalls" — most visible on
609+
/// YouTube where the player gives up on a 1.5s `googlevideo.com`
610+
/// chunk that's actually waiting on a cold-start.
611+
///
612+
/// Bypasses the response cache (`cache_key_opt = None`) and the
613+
/// inflight coalescer — otherwise the second iteration would just
614+
/// hit the cached response from the first and never reach Apps
615+
/// Script. The relay payload itself is the cheapest non-error one
616+
/// we can build: a HEAD against `http://example.com/` returns a few
617+
/// hundred bytes, no body decode, no auth.
618+
///
619+
/// Best-effort. Failures are debug-logged so a flaky network or
620+
/// quota-exhausted account doesn't spam warnings every 4 minutes.
621+
/// Loops forever — caller is expected to drop the JoinHandle on
622+
/// shutdown (the task lives as long as the process).
623+
pub async fn run_h1_keepalive(self: Arc<Self>) {
624+
loop {
625+
tokio::time::sleep(Duration::from_secs(H1_KEEPALIVE_INTERVAL_SECS)).await;
626+
let t0 = Instant::now();
627+
// relay_uncoalesced returns Vec<u8> (always — errors are
628+
// baked into 5xx responses), so just observe the duration
629+
// for the debug line. We intentionally don't use relay()
630+
// here because that path goes through the cache + coalesce
631+
// layer, which would short-circuit subsequent pings.
632+
let _ = self
633+
.relay_uncoalesced("HEAD", "http://example.com/", &[], &[], None)
634+
.await;
635+
tracing::debug!(
636+
"H1 container keepalive: {}ms",
637+
t0.elapsed().as_millis()
638+
);
639+
}
640+
}
641+
598642
async fn acquire(&self) -> Result<PoolEntry, FronterError> {
599643
{
600644
let mut pool = self.pool.lock().await;

src/proxy_server.rs

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,28 @@ impl ProxyServer {
327327
});
328328
}
329329

330+
// Apps Script container keepalive. `warm()` above keeps the TCP
331+
// pool warm at startup, but the V8 container behind UrlFetchApp
332+
// goes cold after ~5min idle and costs 1-3s to wake. A periodic
333+
// HEAD ping prevents the cold-start lag on the first request
334+
// after a quiet pause (most visible as YouTube player stalls).
335+
// Skipped in google_only mode for the same reason as warm —
336+
// there's no fronter to ping.
337+
//
338+
// The handle is captured (not fire-and-forget) so the shutdown
339+
// arm of the select! below can abort it. Without that, hitting
340+
// Stop in the UI would leave the keepalive holding an
341+
// Arc<DomainFronter> on stale config and pinging Apps Script
342+
// every 240s — same class of bug that issue #99 hit for the
343+
// accept loops.
344+
let keepalive_task = if let Some(keepalive_fronter) = self.fronter.clone() {
345+
tokio::spawn(async move {
346+
keepalive_fronter.run_h1_keepalive().await;
347+
})
348+
} else {
349+
tokio::spawn(async move { std::future::pending::<()>().await })
350+
};
351+
330352
let stats_task = if let Some(stats_fronter) = self.fronter.clone() {
331353
tokio::spawn(async move {
332354
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
@@ -434,6 +456,7 @@ impl ProxyServer {
434456
_ = &mut shutdown_rx => {
435457
tracing::info!("Shutdown signal received, stopping listeners");
436458
stats_task.abort();
459+
keepalive_task.abort();
437460
http_task.abort();
438461
socks_task.abort();
439462
}
@@ -507,8 +530,26 @@ async fn handle_http_client(
507530
tunnel_mux: Option<Arc<TunnelMux>>,
508531
) -> std::io::Result<()> {
509532
let (head, leftover) = match read_http_head(&mut sock).await? {
510-
Some(v) => v,
511-
None => return Ok(()),
533+
HeadReadResult::Got { head, leftover } => (head, leftover),
534+
HeadReadResult::Closed => return Ok(()),
535+
HeadReadResult::Oversized => {
536+
// Reply with 431 instead of just dropping the socket so the
537+
// browser shows a real error rather than retrying the same
538+
// oversized request in a loop.
539+
tracing::warn!(
540+
"request head exceeds {} bytes — refusing with 431",
541+
MAX_HEADER_BYTES
542+
);
543+
let _ = sock
544+
.write_all(
545+
b"HTTP/1.1 431 Request Header Fields Too Large\r\n\
546+
Connection: close\r\n\
547+
Content-Length: 0\r\n\r\n",
548+
)
549+
.await;
550+
let _ = sock.flush().await;
551+
return Ok(());
552+
}
512553
};
513554

514555
let (method, target, _version, _headers) = parse_request_head(&head)
@@ -1608,14 +1649,35 @@ fn looks_like_http(first_bytes: &[u8]) -> bool {
16081649
/// Read an HTTP head (request line + headers) up to the first \r\n\r\n.
16091650
/// Returns (head_bytes, leftover_after_head). The leftover may contain part
16101651
/// of the request body already received.
1611-
async fn read_http_head(sock: &mut TcpStream) -> std::io::Result<Option<(Vec<u8>, Vec<u8>)>> {
1652+
/// Maximum size of an HTTP request head (request line + all headers).
1653+
///
1654+
/// Set to match upstream Python's `MAX_HEADER_BYTES` (64 KB,
1655+
/// masterking32/MasterHttpRelayVPN constants.py). Real browsers
1656+
/// virtually never exceed ~16 KB; anything past 64 KB is either a
1657+
/// buggy client or a deliberate slowloris-style header bomb.
1658+
/// Previously 1 MB, which let a misbehaving client allocate a lot
1659+
/// of memory before failing.
1660+
const MAX_HEADER_BYTES: usize = 64 * 1024;
1661+
1662+
/// Result of `read_http_head` / `read_http_head_io`.
1663+
/// `Oversized` is distinct from other I/O errors so the caller can
1664+
/// reply with `431 Request Header Fields Too Large` instead of just
1665+
/// dropping the connection (which a browser would silently retry,
1666+
/// reproducing the same problem).
1667+
enum HeadReadResult {
1668+
Got { head: Vec<u8>, leftover: Vec<u8> },
1669+
Closed,
1670+
Oversized,
1671+
}
1672+
1673+
async fn read_http_head(sock: &mut TcpStream) -> std::io::Result<HeadReadResult> {
16121674
let mut buf = Vec::with_capacity(4096);
16131675
let mut tmp = [0u8; 4096];
16141676
loop {
16151677
let n = sock.read(&mut tmp).await?;
16161678
if n == 0 {
16171679
return if buf.is_empty() {
1618-
Ok(None)
1680+
Ok(HeadReadResult::Closed)
16191681
} else {
16201682
Err(std::io::Error::new(
16211683
std::io::ErrorKind::UnexpectedEof,
@@ -1627,13 +1689,10 @@ async fn read_http_head(sock: &mut TcpStream) -> std::io::Result<Option<(Vec<u8>
16271689
if let Some(pos) = find_headers_end(&buf) {
16281690
let head = buf[..pos].to_vec();
16291691
let leftover = buf[pos..].to_vec();
1630-
return Ok(Some((head, leftover)));
1692+
return Ok(HeadReadResult::Got { head, leftover });
16311693
}
1632-
if buf.len() > 1024 * 1024 {
1633-
return Err(std::io::Error::new(
1634-
std::io::ErrorKind::InvalidData,
1635-
"headers too large",
1636-
));
1694+
if buf.len() > MAX_HEADER_BYTES {
1695+
return Ok(HeadReadResult::Oversized);
16371696
}
16381697
}
16391698
}
@@ -1942,8 +2001,31 @@ where
19422001
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
19432002
{
19442003
let (head, leftover) = match read_http_head_io(stream).await? {
1945-
Some(v) => v,
1946-
None => return Ok(false),
2004+
HeadReadResult::Got { head, leftover } => (head, leftover),
2005+
HeadReadResult::Closed => return Ok(false),
2006+
HeadReadResult::Oversized => {
2007+
// Inside MITM: same reasoning as the plaintext path. Return
2008+
// 431 over the decrypted stream so the browser surfaces a
2009+
// real error to the user instead of looping a connection
2010+
// reset, which was the symptom upstream caught (Apps Script
2011+
// ate malformed JSON when truncated header blocks were
2012+
// forwarded blindly).
2013+
tracing::warn!(
2014+
"MITM header block exceeds {} bytes — closing ({}:{})",
2015+
MAX_HEADER_BYTES,
2016+
host,
2017+
port
2018+
);
2019+
let _ = stream
2020+
.write_all(
2021+
b"HTTP/1.1 431 Request Header Fields Too Large\r\n\
2022+
Connection: close\r\n\
2023+
Content-Length: 0\r\n\r\n",
2024+
)
2025+
.await;
2026+
let _ = stream.flush().await;
2027+
return Ok(false);
2028+
}
19472029
};
19482030

19492031
let (method, path, _version, headers) = match parse_request_head(&head) {
@@ -2064,7 +2146,7 @@ where
20642146
Ok(!connection_close)
20652147
}
20662148

2067-
async fn read_http_head_io<S>(stream: &mut S) -> std::io::Result<Option<(Vec<u8>, Vec<u8>)>>
2149+
async fn read_http_head_io<S>(stream: &mut S) -> std::io::Result<HeadReadResult>
20682150
where
20692151
S: tokio::io::AsyncRead + Unpin,
20702152
{
@@ -2074,7 +2156,7 @@ where
20742156
let n = stream.read(&mut tmp).await?;
20752157
if n == 0 {
20762158
return if buf.is_empty() {
2077-
Ok(None)
2159+
Ok(HeadReadResult::Closed)
20782160
} else {
20792161
Err(std::io::Error::new(
20802162
std::io::ErrorKind::UnexpectedEof,
@@ -2086,13 +2168,10 @@ where
20862168
if let Some(pos) = find_headers_end(&buf) {
20872169
let head = buf[..pos].to_vec();
20882170
let leftover = buf[pos..].to_vec();
2089-
return Ok(Some((head, leftover)));
2171+
return Ok(HeadReadResult::Got { head, leftover });
20902172
}
2091-
if buf.len() > 1024 * 1024 {
2092-
return Err(std::io::Error::new(
2093-
std::io::ErrorKind::InvalidData,
2094-
"headers too large",
2095-
));
2173+
if buf.len() > MAX_HEADER_BYTES {
2174+
return Ok(HeadReadResult::Oversized);
20962175
}
20972176
}
20982177
}

0 commit comments

Comments
 (0)