跳轉到

4.3 HTTP 伺服器實作:手寫最小 HTTP/1.1#

HTTP 的三十年#

1991 年 Tim Berners-Lee 發布 HTTP/0.9(只有 GET,回應是純 HTML);1996 年 RFC 1945 定稿 HTTP/1.0;1997 年 RFC 2068(HTTP/1.1)加入 keep-alive、chunked transfer、Host header(使一個 IP 服務多個網站,催生虛擬主機託管產業);2015 年 HTTP/2(多工復用,Google SPDY 計畫 2012 年的成果被標準化);2022 年 HTTP/3 基於 QUIC(UDP 之上,Google 2012 年起實驗)。

本節手寫 HTTP/1.1 的子集,目標不是取代 nginx,而是理解「應用層協定如何騎在 4.1 節的 TCP 位元組流上」。核心難點:TCP 不保訊息邊界,HTTP 用文字協定定界——

GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: curl/8\r\n
\r\n

請求 = 起始行 + 多個 Key: Value\r\n header + 空行 + body。空行 \r\n\r\n 是 header 的結束記號;body 長度由 Content-Length 或 chunked 編碼決定。

狀態機:從位元組流重建訊息#

讀 TCP 的迴圈會遇到「一次 read 可能只有半個請求」的問題(TCP 分段由 MSS/擁塞視窗決定,與應用無關),所以解析器必須是增量式的。用一個簡單的讀取器:

use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;

#[derive(Debug)]
struct Request {
    method: String,
    path: String,
    version: String,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

fn read_request(stream: &mut BufReader<TcpStream>) -> std::io::Result<Option<Request>> {
    let mut start_line = String::new();
    if stream.read_line(&mut start_line)? == 0 {
        return Ok(None);                       // 連線關閉
    }
    let mut parts = start_line.split_whitespace();
    let (method, path, version) = (
        parts.next().unwrap_or("").to_string(),
        parts.next().unwrap_or("/").to_string(),
        parts.next().unwrap_or("HTTP/0.9").to_string(),
    );

    let mut headers = Vec::new();
    loop {
        let mut line = String::new();
        if stream.read_line(&mut line)? == 0 { break; }
        let line = line.trim_end();            // 去掉 \r\n
        if line.is_empty() { break; }          // 空行 = header 結束
        if let Some((k, v)) = line.split_once(':') {
            headers.push((k.trim().to_string(), v.trim().to_string()));
        }
    }

    let len = headers.iter()
        .find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
        .and_then(|(_, v)| v.parse::<usize>().ok())
        .unwrap_or(0);
    let mut body = vec![0u8; len];
    stream.read_exact(&mut body)?;             // 正好讀 len 位元組

    Ok(Some(Request { method, path, version, headers, body }))
}

read_exact 是關鍵:它迴圈讀到填滿為止,吸收了 TCP 分段的複雜性——而它回傳 Result,短讀(對端斷線)必須處理,這種「不檢查編譯不過」的特性在 C 的 read() 世界裡是日常漏洞來源。

伺服器主體#

use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;

fn respond(stream: &mut TcpStream, status: &str, body: &str, keep_alive: bool) -> std::io::Result<()> {
    let resp = format!(
        "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
         Content-Length: {}\r\nConnection: {}\r\n\r\n{body}",
        body.len(),
        if keep_alive { "keep-alive" } else { "close" },
    );
    stream.write_all(resp.as_bytes())
}

fn handle(stream: TcpStream) -> std::io::Result<()> {
    let peer = stream.peer_addr()?;
    let mut writer = stream.try_clone()?;      // 讀寫分離:複製 fd,各一個所有權
    let mut reader = BufReader::new(stream);

    while let Some(req) = read_request(&mut reader)? {
        println!("{peer} {} {}", req.method, req.path);
        let body = match (req.method.as_str(), req.path.as_str()) {
            ("GET", "/") => "Hello from a hand-written Rust HTTP server!\n".to_string(),
            ("GET", p) if p.starts_with("/hello/") => {
                format!("Hi, {}\n", &p["/hello/".len()..])
            }
            ("POST", "/echo") => String::from_utf8_lossy(&req.body).into_owned(),
            _ => { respond(&mut writer, "404 Not Found", "not found\n", false)?; continue; }
        };
        respond(&mut writer, "200 OK", &body, true)?;   // keep-alive:同一連線續讀下一請求
    }
    Ok(())
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:8080")?;
    for stream in listener.incoming() {
        let stream = stream?;
        thread::spawn(move || { let _ = handle(stream); });
    }
    Ok(())
}

測試:

$ curl -v http://localhost:8080/hello/world
$ curl -X POST -d 'data=123' http://localhost:8080/echo

幾個設計點對應歷史:

  • Content-Length 必填:HTTP/1.0 沒有它就得靠「關連線」表示 body 結束(無法 keep-alive);HTTP/1.1 加 Content-Length/chunked 才使持久連線可行,網頁載入速度因此大幅提升。
  • Connection: keep-alive:1997 年 RFC 2068 將其定為預設;本例的 while let 迴圈就是在實作它——每個請求讀完繼續等下一個,直到對端關閉。
  • try_clone:讀寫各持一個 fd 拷貝(dup),所有權各自獨立,借用檢查不會把讀寫一起卡死。

借用檢查如何影響協定解析器#

寫 HTTP 解析時 Rust 會逼你做出正確的架構選擇。常見錯誤情境:

// 想邊讀邊存「指向緩衝區的切片」:
// let headers: Vec<&str> = buf.split(...).collect();
// buf 再 read 一次 → 借用錯誤:buf 被 &mut 借走

編譯器拒絕「拿舊緩衝區的引用繼續寫緩衝區」——而這正是 HTTP 請求走私(request smuggling,2005 年 Amit Klein 提出、2019 年 PortSwigger 系統性研究)等攻擊的土壤:解析器對「已解析內容」與「緩衝區狀態」的假設不一致。Rust 的解法是二選一:要嘛 .to_string() 拷貝出所有權(本節做法,慢一點但安全),要嘛用 httparse 這類「索引而非引用」的零拷貝解析器(2015 年由 pyfisch 等開發,hyperfine/servo 系生態標配),在解析完成的瞬間把 (start, len) 轉成 &str,生命週期由型別標註。

進一步:HTTP/2 與二進制化#

HTTP/2(2015)把文字協定換成二進制幀,解決 HTTP/1.1 的隊頭阻塞(一條 TCP 連線上多個 stream 交錯)。但 TCP 層的丟包重傳仍會卡住所有 stream,所以 HTTP/3 乾脆換 QUIC/UDP。本節的文字協定便是這段演化的起點:協定設計的每個決定(文字 vs 二進制、有無邊界、連線複用)都直接影響之後十年能否修漏洞。

習題#

  1. 加入 chunked 請求的解析(Transfer-Encoding: chunked),格式:<hex>\r\n<data>\r\n,以 0\r\n\r\n 結束。
  2. 用 httparse 重寫解析,對比手寫版在 10k 請求/秒下的差異(可用 wrk)。
  3. 嘗試走私攻擊的思路:若一個 header 值裡含 \r\n(未驗證),會發生什麼?在你的解析器加防護。