replace manual header parse with lib
This commit is contained in:
Generated
+1
@@ -236,6 +236,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"inquire",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user