replace manual header parse with lib

This commit is contained in:
2026-08-21 23:43:24 +04:00
parent b1ed2c1064
commit b9896cb918
3 changed files with 92 additions and 25 deletions
Generated
+1
View File
@@ -236,6 +236,7 @@ dependencies = [
"dirs", "dirs",
"http", "http",
"http-body-util", "http-body-util",
"httparse",
"hyper", "hyper",
"hyper-util", "hyper-util",
"inquire", "inquire",
+1
View File
@@ -42,6 +42,7 @@ dashmap = "6"
bytes = "1" bytes = "1"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
http = "1" http = "1"
httparse = "1"
http-body-util = "0.1" http-body-util = "0.1"
rustls-pemfile = "2" rustls-pemfile = "2"
ring = "0.17" ring = "0.17"
+86 -21
View File
@@ -2,6 +2,8 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use color_eyre::eyre::{bail, eyre};
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -41,26 +43,73 @@ pub async fn run(
Ok(()) Ok(())
} }
/// Extract the Host header from raw HTTP bytes without consuming them. /// Largest request head we will buffer before giving up.
/// Returns (subdomain, peer_display_string). const MAX_HEAD: usize = 64 * 1024;
fn extract_host_from_headers(buf: &[u8], base_domain: &str) -> Option<String> { const MAX_HEADERS: usize = 96;
let header_str = std::str::from_utf8(buf).ok()?;
// Find Host header (case-insensitive) /// Extract the routing subdomain from a (possibly incomplete) request head.
for line in header_str.split("\r\n").skip(1) { ///
if line.is_empty() { /// `Ok(None)` means the head has not fully arrived yet and more bytes are
break; /// needed. Parsing is done over raw bytes by `httparse`, so a body that spills
/// into the buffer — binary or otherwise — is never part of the parse.
fn parse_subdomain(buf: &[u8], base_domain: &str) -> Result<Option<String>> {
let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
let mut req = httparse::Request::new(&mut headers);
if req.parse(buf)?.is_partial() {
return Ok(None);
} }
if let Some(value) = line.strip_prefix("Host:").or_else(|| line.strip_prefix("host:")) {
let host = value.trim(); let host = req
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("host"))
.ok_or_else(|| eyre!("HTTP request has no Host header"))?;
let host = std::str::from_utf8(host.value)?.trim();
// Strip port if present // Strip port if present
let host = host.split(':').next().unwrap_or(host); let host = host.split(':').next().unwrap_or(host);
let suffix = format!(".{base_domain}");
let subdomain = host.strip_suffix(&suffix).filter(|s| !s.is_empty())?; host.strip_suffix(&format!(".{base_domain}"))
return Some(subdomain.to_string()); .filter(|s| !s.is_empty())
.map(|s| Some(s.to_string()))
.ok_or_else(|| eyre!("Host {host:?} is not a {base_domain} subdomain"))
} }
#[cfg(test)]
mod tests {
use super::parse_subdomain;
const HEAD: &[u8] =
b"POST /upload HTTP/1.1\r\nHOST: 78466f33.fwds.scug.io:443\r\nContent-Length: 4\r\n\r\n";
/// The upload-502 regression: a buffer that spills into a binary body must
/// still route.
#[test]
fn binary_body_still_routes() {
let mut buf = HEAD.to_vec();
buf.extend_from_slice(&[0xff, 0xfe, 0xff, 0xfe]);
let got = parse_subdomain(&buf, "fwds.scug.io").unwrap();
assert_eq!(got.as_deref(), Some("78466f33"));
}
/// A head split across segments asks for more bytes instead of failing.
#[test]
fn partial_head_is_not_an_error() {
let cut = HEAD.len() - 20;
assert_eq!(parse_subdomain(&HEAD[..cut], "fwds.scug.io").unwrap(), None);
assert_eq!(
parse_subdomain(HEAD, "fwds.scug.io").unwrap().as_deref(),
Some("78466f33")
);
}
#[test]
fn foreign_host_is_rejected() {
let buf = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
assert!(parse_subdomain(buf, "fwds.scug.io").is_err());
} }
None
} }
async fn handle_connection( async fn handle_connection(
@@ -68,14 +117,27 @@ async fn handle_connection(
peer: SocketAddr, peer: SocketAddr,
state: Arc<ServerState>, state: Arc<ServerState>,
) -> Result<()> { ) -> Result<()> {
// Peek at the beginning of the HTTP request to extract the Host header. // Consume the request head so a head split across several segments still
// We read into a buffer but then send ALL of it through the tunnel. // parses — peeking only ever returns whatever happens to be buffered right
let mut buf = vec![0u8; 8192]; // now. Everything read here is replayed into the tunnel verbatim below.
let n = stream.peek(&mut buf).await?; let mut stream = stream;
let buf = &buf[..n]; let mut head = Vec::with_capacity(8192);
let mut chunk = [0u8; 4096];
let subdomain = extract_host_from_headers(buf, &state.base_domain) let subdomain = loop {
.ok_or_else(|| color_eyre::eyre::eyre!("no matching Host header in HTTP request"))?; let n = stream.read(&mut chunk).await?;
if n == 0 {
bail!("connection closed before the HTTP request head was complete");
}
head.extend_from_slice(&chunk[..n]);
if let Some(subdomain) = parse_subdomain(&head, &state.base_domain)? {
break subdomain;
}
if head.len() > MAX_HEAD {
bail!("HTTP request head exceeds {MAX_HEAD} bytes");
}
};
// Look up the tunnel // Look up the tunnel
let tunnel_id = *state let tunnel_id = *state
@@ -102,6 +164,9 @@ async fn handle_connection(
) )
.await?; .await?;
// Replay the head we consumed, then hand the rest to the raw relay.
quic_send.write_all(&head).await?;
// Raw bidirectional relay — TCP stream carries the full HTTP conversation // Raw bidirectional relay — TCP stream carries the full HTTP conversation
// including upgrades (WebSocket), SSE, chunked responses, etc. // including upgrades (WebSocket), SSE, chunked responses, etc.
let quic_stream = QuicBiStream { let quic_stream = QuicBiStream {