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",
"http",
"http-body-util",
"httparse",
"hyper",
"hyper-util",
"inquire",
+1
View File
@@ -42,6 +42,7 @@ dashmap = "6"
bytes = "1"
tokio-util = { version = "0.7", features = ["io"] }
http = "1"
httparse = "1"
http-body-util = "0.1"
rustls-pemfile = "2"
ring = "0.17"
+86 -21
View File
@@ -2,6 +2,8 @@ use std::net::SocketAddr;
use std::sync::Arc;
use color_eyre::Result;
use color_eyre::eyre::{bail, eyre};
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
@@ -41,26 +43,73 @@ pub async fn run(
Ok(())
}
/// Extract the Host header from raw HTTP bytes without consuming them.
/// Returns (subdomain, peer_display_string).
fn extract_host_from_headers(buf: &[u8], base_domain: &str) -> Option<String> {
let header_str = std::str::from_utf8(buf).ok()?;
/// Largest request head we will buffer before giving up.
const MAX_HEAD: usize = 64 * 1024;
const MAX_HEADERS: usize = 96;
// Find Host header (case-insensitive)
for line in header_str.split("\r\n").skip(1) {
if line.is_empty() {
break;
/// Extract the routing subdomain from a (possibly incomplete) request head.
///
/// `Ok(None)` means the head has not fully arrived yet and more bytes are
/// 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
let host = host.split(':').next().unwrap_or(host);
let suffix = format!(".{base_domain}");
let subdomain = host.strip_suffix(&suffix).filter(|s| !s.is_empty())?;
return Some(subdomain.to_string());
host.strip_suffix(&format!(".{base_domain}"))
.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(
@@ -68,14 +117,27 @@ async fn handle_connection(
peer: SocketAddr,
state: Arc<ServerState>,
) -> Result<()> {
// Peek at the beginning of the HTTP request to extract the Host header.
// We read into a buffer but then send ALL of it through the tunnel.
let mut buf = vec![0u8; 8192];
let n = stream.peek(&mut buf).await?;
let buf = &buf[..n];
// Consume the request head so a head split across several segments still
// parses — peeking only ever returns whatever happens to be buffered right
// now. Everything read here is replayed into the tunnel verbatim below.
let mut stream = stream;
let mut head = Vec::with_capacity(8192);
let mut chunk = [0u8; 4096];
let subdomain = extract_host_from_headers(buf, &state.base_domain)
.ok_or_else(|| color_eyre::eyre::eyre!("no matching Host header in HTTP request"))?;
let subdomain = loop {
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
let tunnel_id = *state
@@ -102,6 +164,9 @@ async fn handle_connection(
)
.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
// including upgrades (WebSocket), SSE, chunked responses, etc.
let quic_stream = QuicBiStream {