replace manual header parse with lib
This commit is contained in:
Generated
+1
@@ -236,6 +236,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"httparse",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"inquire",
|
"inquire",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
if let Some(value) = line.strip_prefix("Host:").or_else(|| line.strip_prefix("host:")) {
|
fn parse_subdomain(buf: &[u8], base_domain: &str) -> Result<Option<String>> {
|
||||||
let host = value.trim();
|
let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
|
||||||
// Strip port if present
|
let mut req = httparse::Request::new(&mut headers);
|
||||||
let host = host.split(':').next().unwrap_or(host);
|
|
||||||
let suffix = format!(".{base_domain}");
|
if req.parse(buf)?.is_partial() {
|
||||||
let subdomain = host.strip_suffix(&suffix).filter(|s| !s.is_empty())?;
|
return Ok(None);
|
||||||
return Some(subdomain.to_string());
|
}
|
||||||
}
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user