用Rust手把手编写一个Proxy(代理), 动工

用Rust手把手编写一个Proxy(代理), 动工

项目 ++wmproxy++

gitee 传送门
github 传送门

设计流程图

flowchart LR A[客户端] -->|Http| B[代理端] --> C[代理服务端] --> D[服务端] B -->|直达| D A -->|Https| B A -->|Socks5| B

代理端和代理服务端之间可用自有格式来实现多路复用以减少连接的建立断开的开销,目前暂未实现代理服务端。

类结构

  • proxy.rs 负责代理结构的存储,监听类型,监听地址,是否有父级地址,认证账号密码等。
  • flag.rs 监听类型的二进制结构,可同时支持多结构比较http/https/socks5,如果解析http失败则尝试socks5格式,从而实现多种代理方式的同时支持
  • http.rs http及https代理的实现,如果解析失败则返回ProxyError::Continue,并把已经读取的数据带回,以便后续解析
  • socks5.rs socks5的代理实现,如果数据正确,则均在此处进行转发,解析失败返回Continue

命令行解析

使用Commander对命令行的的数据处理,如-p 8090,-b 127.0.0.1,完整的命令行如wmproxy -p 8090,则可在8090端口上实现http及https的转发,代码示例

rust 复制代码
let command = Commander::new()
            .version(&env!("CARGO_PKG_VERSION").to_string())
            .usage("-b 127.0.0.1 -p 8090")
            .usage_desc("use http proxy")
            .option_list(
                "-f, --flag [value]",
                "可兼容的方法, 如http https socks5",
                None,
            )
            .option_int("-p, --port [value]", "listen port", Some(8090))
            .option_str(
                "-b, --bind [value]",
                "bind addr",
                Some("0.0.0.0".to_string()),
            )
            .parse_env_or_exit();

let listen_port: u16 = command.get_int("p").unwrap() as u16;
let listen_host = command.get_str("b").unwrap();

启动入口

启动通过tokio的异步协议进行数据的处理,逻辑均在tokio::spawn的异步函数中,所有针对句柄数据的读取写入均由异步完成,从而实现高效率的处理。

rust 复制代码
while let Ok((mut inbound, _)) = listener.accept().await {
    tokio::spawn(async move {
        // tcp的连接被移动到该协程中,我们只要专注的处理该stream即可
    })
}

HTTP代理

如果该代理信息配置支持http/https则会尝试进行http解析,代码实现在proxy.rs中的process方法,

rust 复制代码
pub async fn process(mut inbound: TcpStream) -> ProxyResult<()> {
    let request = webparse::Request::new();
    // 通过该方法解析标头是否合法, 若是partial(部分)则继续读数据
    // 若解析失败, 则表示非http协议能处理, 则抛出错误
    match request.parse_buffer(&mut buffer.clone()) {
    }
}

该方法会循环的读取客户端的内容,如果内容为

GET / HTTP/1.1\r\nHost: wwww.baidu.com\r\n\r\n

这表示该请求为普通的http代理,我们解析完HTTP的头文件信息,得出包含的头信息,如果无法解析完整的地址(域名加端口或者ip加端口),则返回错误,无法处理该http信息。
flowchart TD A[客户端] --> B[代理] --> C[读取头信息] --> D[取得地址] -->|成功| E[连接目标地址] -->|成功| F[写入头信息] --> G[双向通道] D -->|不合法关闭| A E -->|连接失败关闭| A A <-->|双向| G

注意:客户端和服务端之前可能会存在大数据上传下载的情况,超过百兆数据的上传下载,所以我们为了减少序列化带来的性能损失和保证在低内存能正确运行,不做http的完整解析,仅仅只处理http头信息。

curl测试

bash 复制代码
export http_proxy=http://127.0.0.1:8090
curl http://www.baidu.com -I

可以正常的返回

HTTP/1.1 200 OK...

HTTPS代理

https处理是在http的基础在在额外解析connect协议来实现, 代理是客户端优先给代理发送connect协议,比如访问https://www.baidu.com那么先优先发如下消息

CONNECT www.baidu.com:443 HTTP/1.1\r\n
Host: www.baidu.com:443\r\n\r\n

如果收到HTTP的CONNECT的方法则表示他是https的代理协议,那么此时对PATH提示的地址进行连接,连接成功后只需对该连接和客户端做双向绑定即可实现HTTPS代理协议。

curl测试

bash 复制代码
export https_proxy=http://127.0.0.1:8090
curl https://www.baidu.com -I

可以正常的返回两次,因为在connect的时候要求代理返回一次数据,另一次是https服务器返回,故而显示g

HTTP/1.1 200 OK
HTTP/1.1 200 OK
...

socks5协议

socks5由rfc1928进行定义

代码实现在socks5.rs中的process方法实现

因为在处理socks5之前可能进行过http的尝试,所以socket中的内容已经被读出了一部分,在处理时则带上了Option<BinaryMut>,表示预读的内容。

在socks5中通常需要预读一个字节来获取后续的长度,比如NMethod,或者用户名长度等,所以我们定义了函数

rust 复制代码
/// 读取至少长度为size的大小的字节数, 如果足够则返回Ok(())
pub async fn read_len<T>(stream: &mut T, buffer: &mut BinaryMut, size: usize) -> ProxyResult<()>
where
    T: AsyncRead + Unpin {
        
    }

这里的stream用的是泛型,只要具有异步读的类型都可以

保证已读内容须不少于多少字节数,然后再进行数据的预处理。

根据我们是否传用用户密码信息来确定socks5的验证方式,如果我们传入了用户密码,如果客户端不支持2的验证方式,则返回(0xFF)表示无验证方法。

bash 复制代码
curl http://www.baidu.com --socks5 127.0.0.1:8090
## curl: (97) No authentication method was acceptable.

验证成功或者无需验证后
graph TD A[验证成功] --> B[读取地址] --> C[连接地址] -->|连接成功| D[双向通道] E[返回失败] B -->|读取失败| E C -->|连接失败| E A <-->|双向| D

双向通道建立后,客户端已和服务器能正常的TCP操作,包括Http/Https/Websocket/自定义tcp信息,代理直到一方关闭则正常后续关闭。

错误处理方法

这里主要说明如何多协议兼容处理代理协议。以下定义的Continue协议包含了一个已读的字节表和当前的Tcp连接。

rust 复制代码
pub enum ProxyError {
    /// 该错误发生协议不可被解析,则尝试下一个协议
    Continue((Option<BinaryMut>, TcpStream)),
}

例如在http里协议解决头失败,

// 此处clone为浅拷贝,不确定是否一定能解析成功,不能影响偏移
match request.parse_buffer(&mut buffer.clone()) {
    Err(_) => {
        return Err(ProxyError::Continue((Some(buffer), inbound)));
    }
}

则返回当前已读的buffer和tcp连接,且游标为初始位置,buffer并未被读取过。下个解析器可以拿到完整的数据进行解析。