找回密码
立即注册
搜索
发回帖 发新帖

5287

积分

0

好友

683

主题
发表于 昨天 23:15 | 查看: 2| 回复: 0

反向代理听起来很简单:收到请求,转发给后端,把响应带回来。

但作为中间人,代理要同时面对两侧的失败。客户端慢、后端慢、后端连接被复用、需要升级协议……这些问题在直连场景下都不存在,只有站在中间才会遇到。

这篇从零写一个 HTTP 反向代理,重点放在那些"写起来才会发现"的地方。

最小版本:一个请求一个连接

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

pub fn serve(listen: &str, backend: &str) -> std::io::Result<()> {
    let listener = TcpListener::bind(listen)?;

    for client in listener.incoming() {
        let mut client = client?;
        let backend = backend.to_string();

        std::thread::spawn(move || {
            let _ = handle(client, &backend);
        });
    }
    Ok(())
}

fn handle(mut client: TcpStream, backend: &str) -> std::io::Result<()> {
    // 1. 读客户端请求
    let request = read_request(&mut client)?;

    // 2. 连后端,转发
    let mut upstream = TcpStream::connect(backend)?;
    upstream.write_all(&request.raw)?;

    // 3. 把响应原样带回去
    let mut buf = [0u8; 8192];
    loop {
        let n = upstream.read(&mut buf)?;
        if n == 0 { break; }
        client.write_all(&buf[..n])?;
    }
    Ok(())
}

这版能跑,但有五个问题,下面逐个解决。

问题一:每个请求都新建一条到后端的连接。 高并发下会耗尽后端端口。

问题二:没有超时。 后端卡住,代理线程永久挂起。

问题三:read 到 0 就 break 的判断不对。 用 keep-alive 时后端不会关连接,代理会一直等。

问题四:头部没改写。 后端看不到真实客户端 IP。

问题五:不支持协议升级。 WebSocket 一转就断。

问题一:连接池

复用后端连接是代理性能的第一杠杆。TCP 三次握手 + TLS 握手的开销,在大 QPS 下非常可观。

最朴素但有效的实现是每后端一个空闲连接队列:

use std::collections::VecDeque;
use std::sync::{Arc, Mutex};

pub struct ConnectionPool {
    backend: String,
    idle: Mutex<VecDeque<TcpStream>>,
    max_idle: usize,
}

impl ConnectionPool {
    pub fn new(backend: &str, max_idle: usize) -> Self {
        Self {
            backend: backend.to_string(),
            idle: Mutex::new(VecDeque::new()),
            max_idle,
        }
    }

    pub fn get(&self) -> std::io::Result<TcpStream> {
        // 从空闲队列里取一个还活着的
        loop {
            let stream = self.idle.lock().unwrap().pop_front();
            match stream {
                None => return TcpStream::connect(&self.backend),
                Some(s) => {
                    // 检查连接是否已被对端关闭
                    if is_alive(&s) {
                        return Ok(s);
                    }
                }
            }
        }
    }

    pub fn put(&self, stream: TcpStream) {
        let mut idle = self.idle.lock().unwrap();
        if idle.len() < self.max_idle {
            idle.push_back(stream);
        }
        // 超出上限就直接 drop,关闭连接
    }
}

关键点:从池里取出的连接可能已经被对端关闭了。 HTTP 服务器通常有 idle timeout(比如 60 秒),超过就自己关掉。代理不知情,拿出来直接用会写失败。

检测连接存活有两种方式:

fn is_alive(stream: &TcpStream) -> bool {
    // 方式一:非阻塞 peek,读到 0 说明对端已关闭
    stream.set_nonblocking(true).ok();
    let mut buf = [0u8; 1];
    let alive = match stream.peek(&mut buf) {
        Ok(0) => false,              // 对端关闭
        Ok(_) => true,               // 有数据(可能是关闭前的残留)
        Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => true, // 没数据,活着
        Err(_) => false,
    };
    stream.set_nonblocking(false).ok();
    alive
}

peek 而不是 read:peek 不消费数据,只是看一眼。用 read 会把后端发来的第一个字节吃掉——如果是数据就丢包了。

连接池最难的不是"放进去"和"拿出来",而是"怎么知道拿出来的还可用"。 HTTP 的 keep-alive 让连接的生命周期不由自己掌控。

问题二:超时——代理必须有三类超时

一个没配超时的代理,在后端 hang 住时会把所有工作线程耗光,然后整个服务不可用。这是最典型的级联故障。

pub struct Timeouts {
    /// 建连超时:后端不可达时快速失败
    pub connect: std::time::Duration,
    /// 读超时:后端迟迟不返回响应
    pub read: std::time::Duration,
    /// 写超时:客户端接收慢(慢客户端攻击)
    pub write: std::time::Duration,
}

应用方式:

upstream.set_read_timeout(Some(timeouts.read))?;
upstream.set_write_timeout(Some(timeouts.write))?;
client.set_write_timeout(Some(timeouts.write))?;
client.set_read_timeout(Some(timeouts.read))?;

三者的语义完全不同,不能用一个值:

超时 典型值 太短会怎样 太长会怎样
connect 1-3s 后端偶发慢时误杀 连接堆积
read 按后端 P99 × 2 正常请求被切断 线程被占用
write 5-30s 大响应传不完 慢客户端耗资源

代理的所有超时都要有值。 "不设超时"在代理里等于"把后端的故障放大到全站"。

set_read_timeout 触发的错误是 WouldBlock 或 TimedOut,要当正常分支处理:

match upstream.read(&mut buf) {
    Ok(0) => break, // 后端关闭
    Ok(n) => client.write_all(&buf[..n])?,
    Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
        // 后端超时,返回 504
        respond_gateway_timeout(&mut client)?;
        break;
    }
    Err(e) => return Err(e),
}

注意:Ok(0) 只表示后端关了连接,不表示响应结束。 响应是否结束,要看 Content-Length 或 chunked 的终止块。

问题三:响应怎么才算结束

这正是最小版本的根本缺陷。用一个正确的判断逻辑:

enum BodyLength {
    /// Content-Length 指定了多少字节
    Fixed(usize),
    /// chunked 编码,靠 0 长度块结束
    Chunked,
    /// 没有长度信息,只能读到连接关闭
    UntilClose,
    /// 无 body(如 204、304、HEAD 请求)
    None,
}

fn body_length(headers: &[(String, String)], status: u16, method: &str) -> BodyLength {
    // 204/304 和 HEAD 响应永远没有 body
    if status == 204 || status == 304 || method == "HEAD" {
        return BodyLength::None;
    }

    // Transfer-Encoding 优先于 Content-Length
    let te = header(headers, "transfer-encoding").unwrap_or("");
    if te.to_lowercase().contains("chunked") {
        return BodyLength::Chunked;
    }

    match header(headers, "content-length").and_then(|v| v.parse().ok()) {
        Some(n) => BodyLength::Fixed(n),
        None => BodyLength::UntilClose,
    }
}

Transfer-Encoding 的优先级高于 Content-Length——这是 HTTP 规范的明确要求,也是为了防请求走私(request smuggling)。如果两者同时存在,说明要么是错误,要么是攻击,正确做法是拒绝这个响应。

if has_te && has_cl {
    // 两个都存在,这是可疑的,直接拒
    return Err(Smuggling);
}

请求走私是代理必须防的一类攻击:攻击者构造 Content-Length 和 Transfer-Encoding 不一致的请求,让前端和后端对"请求边界"产生不同理解。 严格按规范处理这两者,是防它的基础。

按长度读取:

fn forward_body(
    upstream: &mut TcpStream,
    client: &mut TcpStream,
    length: BodyLength,
) -> std::io::Result<()> {
    match length {
        BodyLength::None => Ok(()),

        BodyLength::Fixed(n) => {
            let mut remaining = n;
            let mut buf = [0u8; 16384];
            while remaining > 0 {
                let want = remaining.min(buf.len());
                let read = upstream.read(&mut buf[..want])?;
                if read == 0 {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::UnexpectedEof,
                        "upstream closed before sending full body",
                    ));
                }
                client.write_all(&buf[..read])?;
                remaining -= read;
            }
            Ok(())
        }

        BodyLength::Chunked => forward_chunked(upstream, client),

        BodyLength::UntilClose => {
            let mut buf = [0u8; 16384];
            loop {
                let n = upstream.read(&mut buf)?;
                if n == 0 { break; }
                client.write_all(&buf[..n])?;
            }
            Ok(())
        }
    }
}

Fixed 分支里那个 UnexpectedEof 很关键:后端声称发 N 字节但提前关闭了,代理不能把这个残缺响应当成功——必须让客户端感知到失败(通过关闭连接),否则客户端会拿到半截数据当成功。

问题四:头部改写

代理至少要改这几处:

1. Host 头。 客户端发的 Host 是代理的地址,后端通常需要知道自己的真实主机名。

2. X-Forwarded-For。 追加客户端 IP:

fn rewrite_headers(request: &mut HttpRequest, client_ip: std::net::IpAddr) {
    // 追加而不是覆盖:保留链路
    let existing = header(&request.headers, "x-forwarded-for")
        .map(|v| format!("{v}, {client_ip}"))
        .unwrap_or_else(|| client_ip.to_string());
    set_header(&mut request.headers, "X-Forwarded-For", &existing);

    // 协议和端口
    set_header(&mut request.headers, "X-Forwarded-Proto", "http");
    set_header(&mut request.headers, "X-Forwarded-Host", &request.host);
}

X-Forwarded-For 必须是"追加"。如果直接覆盖,多级代理链路上的信息就丢了。

3. Connection 头。 代理是独立连接,不能把客户端的 Connection: keep-alive 原样传给后端:

// 逐跳头部(hop-by-hop)不能转发
const HOP_BY_HOP: &[&str] = &[
    "connection",
    "keep-alive",
    "proxy-authenticate",
    "proxy-authorization",
    "te",
    "trailer",
    "transfer-encoding",
    "upgrade",
];

fn strip_hop_by_hop(headers: &mut Vec<(String, String)>) {
    headers.retain(|(k, _)| !HOP_BY_HOP.iter().any(|h| k.eq_ignore_ascii_case(h)));
}

这里有个安全约束:X-Forwarded-For 不能盲目信任客户端传入的值。

如果攻击者直接发 X-Forwarded-For: 1.2.3.4,而后端用它做限流或鉴权,就能被轻易伪造。正确做法是:

  • 代理追加自己的观测值
  • 后端只信任最右边(离它最近)的那个 IP,或者信任"代理数量"个右侧条目
// 后端侧的解析:从右往左数,跳过可信代理的数量
fn real_client_ip(xff: &str, trusted_proxies: usize) -> Option<&str> {
    let parts: Vec<&str> = xff.split(',').map(|s| s.trim()).collect();
    parts.get(parts.len().checked_sub(trusted_proxies + 1)?).copied()
}

问题五:协议升级(WebSocket)

WebSocket 要经过代理,必须处理 101 响应,并把连接切换成双向透传。

fn forward_upgrade(
    mut client: TcpStream,
    mut upstream: TcpStream,
) -> std::io::Result<()> {
    // 双向透传:两个线程,各管一个方向
    let mut client_read = client.try_clone()?;
    let mut upstream_read = upstream.try_clone()?;

    let t = std::thread::spawn(move || {
        // 客户端 -> 后端
        let _ = std::io::copy(&mut client_read, &mut upstream_read);
        // 一个方向结束,通知另一个方向(Half-Close)
        let _ = upstream_read.shutdown(std::net::Shutdown::Write);
    });

    // 后端 -> 客户端
    let _ = std::io::copy(&mut upstream, &mut client);
    let _ = client.shutdown(std::net::Shutdown::Write);

    let _ = t.join();
    Ok(())
}

Half-Close(半关闭)是这里的关键。 WebSocket 关闭时,一方发 close 帧后可能还需接收对方的数据。如果直接 shutdown(Both),就会把还在路上的数据切断。

Shutdown::Write 只关闭写方向,读方向继续工作——这正是 TCP 四次挥手的语义。

代理转发升级协议时,超时必须撤掉或者设得很长。 WebSocket 连接可能空闲几十分钟,用 HTTP 的 30 秒读超时会把正常连接全掐断。

一个完整的转发流程

把上面五块拼起来:

fn proxy_request(client: &mut TcpStream, pool: &ConnectionPool, cfg: &Config) -> std::io::Result<()> {
    loop {
        // 1. 读客户端请求(含 keep-alive 循环)
        let mut request = match read_request(client)? {
            Some(r) => r,
            None => return Ok(()), // 客户端关闭
        };

        // 2. 改写头部
        strip_hop_by_hop(&mut request.headers);
        rewrite_headers(&mut request, client.peer_addr()?.ip());
        set_header(&mut request.headers, "Host", &cfg.backend_host);

        // 3. 从池里取连接
        let mut upstream = pool.get()?;
        upstream.write_all(&request.serialize())?;
        if !request.body.is_empty() {
            upstream.write_all(&request.body)?;
        }

        // 4. 读响应头
        let response = read_response_head(&mut upstream)?;

        // 5. 检查是否升级
        if response.status == 101 {
            client.write_all(&response.serialize())?;
            return forward_upgrade(client.try_clone()?, upstream);
        }

        // 6. 转发响应
        client.write_all(&response.serialize())?;
        let body_len = body_length(&response.headers, response.status, &request.method);
        forward_body(&mut upstream, client, body_len)?;

        // 7. 决定连接去向
        if response_allows_reuse(&response) && request_allows_keepalive(&request) {
            pool.put(upstream);
        } else {
            let _ = upstream.shutdown(std::net::Shutdown::Both);
        }

        if !client_allows_keepalive(&request) {
            return Ok(());
        }
    }
}

连接是否可复用的判断,要同时看两侧。 客户端说 Connection: close,或者后端响应带 Connection: close,都不能复用。任意一侧不可复用,整条路径就断开。

结论

反向代理是"石头缝里求生存"的典型:

连接池要能识别死连接;三类超时缺一不可;响应结束靠 Content-Length / chunked / 连接关闭三者判断,且 TE 与 CL 并存必须拒绝;逐跳头部不能转发,XFF 只能追加不能信任;升级协议后要撤掉 HTTP 超时并支持 Half-Close。

这五条每一条都对应一类线上事故。写完这个代理,你再看 Nginx 的 proxy_* 配置项,会明白它们为什么存在——每一个参数背后,都是一次真实的故障。




上一篇:Starlink用户被广告竞价追踪:间谍软件公司成105国最大广告买方
下一篇:AI 证伪指数时间假说 ETH,数学根基要动摇了?想给 AI 加油
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-1 00:18 , Processed in 0.083393 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表