TCP 代理服务器浅析

1.前言

代理服务器可谓是无处不在。许多爬虫需要大量的 TCP 代理来伪造自己的 IP 地址,从而防止因为访问过快而被网站拉入黑名单。这里主要介绍代理服务器原理以及服务器端的基础。

2.简单的代理模型介绍

2.1 使用 Socks5 协议的架构

Chrome 可以安装一个 SwitchyOmega 插件来更好地支持 Socks5 协议的代理。他的架构如下:

浏览器<--->代理服务器<--->饿了么网站

浏览器在开启代理模式下,当打开一个网站时,会先连上代理服务器,并进行必要的握手步骤。 Socks5 的握手分两步。

1. 第一步握手是无聊且几乎固定的问答,俗称「对暗号」。

2. 第二步握手, 浏览器发给代理服务器的报文中会包含需要代访问的 IP 地址(或者域名)。代理服务器连接(connect)上饿了么网站服务器后,会发送响应报文给浏览器,告诉他成功了 。

到现在,握手完成。浏览器会发送数据给代理服务器,代理服务器将 TCP 报文原封不动发送给饿了么网站, 再将饿了么网站返回的数据也原封不动返回给浏览器。浏览器最终将页面渲染并显示在屏幕上。代理服务器甚至感知不到 HTTP 协议的存在,因为它只做 TCP 字节流的转发。

2.2 使用 Shadowsocks 协议的架构

Shadowsocks 将 2.2 的代理服务器拆分成两部分,一部分依然在本机,我们称之为 SSlocal ,一部分设在远程,我们称它为 SSServer 。由于 2.2 的协议传输的都是明文,Shadowsocks 对数据的转发进行了加密混淆处理。他虽然不能保证绝对安全,但至少能尽量混淆数据。

浏览器<--->SSlocal<--->SSServer<--->网站

Shadowsocks 也有握手过程,和 Socks5 的协议比较像,这节不做重点介绍。在握手成功后,代理会执行转发任务。SSlocal 会将浏览器发来的数据加密,发送给 SSServer , SSServer 把数据解密后发给网站。返回来的数据也是同理,都会有个加密解密的过程。一般来说比较建议用 AES-256-CFB 这种比较安全的加密方式。

3. TCP 服务端程序并发模型

代理服务器如果用的人多,那么并发压力就会很大。不同的语言有不同的最佳模型。

3.1 多线程同步阻塞式

多线程同步式通常用一个线程处理一个 TCP 连接。现如今的多线程同步,很少再用 pthread 里的线程去写并发,因为一个线程栈可能 8 Mib,线程一多内存就撑不住了。再加上线程切换的开销,使得这种传统的并发方式连 10k 都够呛。一般来说,线程数等于 CPU 核心数的时候,线程切换的开销最小。

好在 Golang 里有轻量级的 Goroutine 替代传统线程,使得这种同步模型实现高并发成为可能。通常这种同步阻塞式写法如下:

// go 伪代码 func main() { while (true) { conn = acceptor.accept() go handleConn(conn) } } func handleConn(conn) { // 第一次握手 err = handshake(conn) checkError(err) addr, port = getAddr(conn) // 尝试连接客户端发来的 IP 地址 server, err = connect(addr, port) checkError(err) // 成功连上要通知客户端,这里省略代码 ... // 将客户端发来的消息发送至远程服务器 go io.Copy(server, conn) // 将服务端发来的消息转发至客户端 io.Copy(conn, server) }

说明:通常主函数就是一个大循环,有新连接就开个 Goroutine 处理这个客户端连接。客户端连接先进行握手后会发送想要访问的目的服务器地址,代理服务器先尝试 connect ,成功连接上则通知客户端,客户端开始发送真正的数据。这时候做一下数据的转发就可以了。由于 TCP 是全双工的协议,收发独立,再加上 Goroutine 已经相当廉价了,所以可以开启两个 Goroutine, 一个负责收,一个负责发,互相不影响。不可以开启多个线程(Goroutine)去对同一个 TCP 连接并行地发送数据,因为这样发送的数据是交错在一起的,是错误的。

3.2 Reactor 同步非阻塞

一般来说,在 Linux 下,C/C++,Python 这种比较多使用 Reactor 模式。 Reactor 通常是一个主线程大循环,由于使用了 I/O 多路复用技术,使得单线程也能有很好的 I/O 性能。

//c++伪代码 int main() { while (true) { err, events = poller.wait(interval) processTimerTask() //处理定时器任务 if (err) { //处理错误 continue } for (event : events) { if (event.isReadable()) { //处理读事件 handleRead(event) } if (event.isWriteable()) { //处理写事件 } if (event.isClosed()) { //处理关闭套接字事件 } if (event.hasError()) { //处理错误事件 } } } }

poller 底层一般有 select,epoll 等。通常情况下使用 epoll 性能最好。单线程可以很容易支撑好几万并发。

3.2.1 「异步」代码的「保存上下文」

无论是 Proactor 还是 Reactor ,凡是接近 Node.js 那种异步的写法,都需要主动保存「上下文」在自己的内存中。「上下文」通常包含没收完整的数据 buffer ,目前的状态 state。

//c++伪代码 struct ConnContext { Buffer buffer; // 接收的消息(可能还不是一个完整的消息) State state; // 状态 }

read 函数一次性读的字节数也是不确定的,有时需要多次调用 read 才能接受完完整的数据。由于数据不完整,并不能执行接下来的流程,所以要先把数据缓存在一个地方,然后无奈返回。等数据接收完整了,才能进入下一个处理程序。一般每个连接都有一个上下文,由 map 保存对应关系。有些协议实现起来状态比较多,比如有好几次握手,必要时还需要使用状态机保存状态。每次有读事件的时候,都会调用 handleRead 函数,这时候根据之前保存的状态,很容易恢复到之前执行的函数的位置(通过switch case分发)。

// c++ 伪代码 void handleRead(conn) { context = contexts[conn] switch (context.state) { case eSTATE_HANDSHAKE1: handshake1(conn, context) break case eSTATE_HANDSHAKE2: handshake2(conn, context) break // 省略 // ... } } void handshake1(conn, context) { data = read_some(conn) append(context.buffer, data) if (context.buffer 不是一个完整的数据) { return } //发送一些东西 send_some(...) // 将 context.buffer 处理过的数据清理掉 // 现在handleshake1状态结束了,更改为下一个状态 context.state = eSTATE_HANDSHAKE2 // 下一次读事件将会调用 handshake2 函数 }

3.2.2 gethostbyname 是阻塞的

由于用到了域名,所以需要 DNS 服务将域名转换成为 IP 地址。

Linux 下 gethostbyname 似乎是阻塞的。这与同步非阻塞式的代码可谓格格不入。可以换一个非阻塞的实现。除此之外,Golang的 DNS 函数库并没有做缓存处理,这就意味着可能会频繁访问 DNS 服务器,速度没有优势,可能对 DNS 服务器也是个不小的负担。所以可以自行设计一个 DNS 缓存,是个不错的主意。

4. TCP协议

假设读者对 TCP 协议有了基本了解,知道滑动窗口,知道拥塞控制。

4.1 滑动窗口与数据转发

我们再看一下这个结构:

浏览器<--->代理服务器<--->饿了么网站

考虑一下这个情况,浏览器和代理服务器连通速度很好,收发很快;而代理服务器和饿了么站点收发很慢。这样一个收发速度不相等的情况,会出现怎样的问题?

1. 对于阻塞同步模型,基本上不用考虑这个问题。因为他的收和发是串行的,这意味着它会自动调整滑动窗口大小。当代理服务器收到了浏览器的10Kib数据,代理服务器就会慢慢发送这10Kib数据给饿了么网站,这时候如果浏览器还想发数据给代理服务器,只会保存在代理服务器的内核缓冲区里,由于代理服务器程序在执行发送的任务(顾不上收数据),并没有从缓冲区取数据,缓冲区的数据会越来越多,剩余空间越来越小,在TCP层面,就会通知调整滑动窗口大小。当读缓冲区满了以后,通知滑动窗口为0,客户端就会停止发送数据。等代理服务器发送完数据,开始从缓冲区取浏览器的数据,浏览器到代理服务器的发送窗口又会从0变大,浏览器又可以发送数据了。

2. 对于非阻塞模型,这是一个大问题。由于没有阻塞功能,代理服务器会一个劲儿的收下浏览器的所有数据,读取内核缓冲区的数据,再转发数据,并保存在自己的某个缓冲区中(sendBuffer)。有点类似于生产者消费者模型,生产得快,消费得慢,内存会一直膨胀下去。其实我们很容易做1情况的模拟,只要发现 sendBuffer 过大就停止读缓冲区的数据,等 sendBuffer 消下去了再开始读就行了。

4.2 TCP 可靠性

TCP 可靠性体现在不乱,不重,不漏。他能很好地防止报文意外丢失,但不能100%防止人为篡改报文,当报文+校验和一起被替换,还是很难被察觉的。TCP 可靠传输并不等于他有数据安全,这是两个概念。但事实上,TCP 在不断发展。 它的 29 号选项 TCP Authentication Option 使用了 SHA 哈希大大提高了篡改数据的难度。对于一个代理服务器来说,只需要单纯转发数据就可以,可以不用过于关心数据的篡改问题。

5.协议报文基础

5.1 常用的两种方式

5.1.1 长度+数据

这种实现是开头几个字节指明报文的长度,之后变长发送消息。简单实现大致可以这样:

std::vector<char> msg {5, h, e, l, l, o}

第一字节表示长度(这里是 5 ),后面跟上这个长度的字节流。接收者先收一字节,然后动态开辟这个长度的缓冲区,把剩下的收完。

字节序

只用一个字节表示长度(0 ~ 255)似乎不太够,倘若设计报文要两个字节代表长度,很自然会选择 uint16_t 。但是超过一个字节就会存在一个大小端的问题。很有可能自己电脑的本地序和网络序不是一种。比方说:

uint16_t a = 0x01;

那么在有些机器上,a里存的是00001, 有些机器是00000。如果直接就把字节流传给对方,说不定对方不是和自己一种字节序,就会把数据认错。所以需要统一规定大小端顺序,传到网络上统一用一种端序,从网络到本机再转换到本机的端序。

这里用C++联合体能很好的展示字节序问题。

union Uint16 { uint16_t u; char c[2]; } Uint16 foo; foo.u = 0x01; std::cout<<static_cast<unsigned int>(foo.c[0]); std::cout<<static_cast<unsigned int>(foo.c[1]);

根据机器不同,有可能输出01,有可能输出10。

这里有一系列c语言的主机序转网络序,以及做相反事情的函数接口

u_long htonl(u_long hostlongvalue); u_short htons(u_short hostshortvalue); u_long ntohl(u_long netlongvalue); u_short hotns(u_short netshortvalue);

发送方代码如下:

// go 伪代码 // 协议格式 两个字节的长度 + 不定长数据 sendmsg = "hello sunfish gao!"; // 将本地序转换成网络序 len = htons(sendmsg.size()); conn.write([]byte(len)) conn.write([]byte(sendmsg))

接收方伪代码如下:

// go 伪代码 // 读两个字节,得到接下来的数据总长度 len = conn.read(2) // 网络序转主机序 len = ntons(len) // 再读剩下的字节数 data = conn.read(len)

5.1.2 以特殊符号分割

假设要设计一个键值对缓存服务器,命令以特殊符号分割,「存」与「取」命令可以设计成如下格式:

put key value\r\n

get key\r\n

当客户端向服务端分开发送如下两条命令:

“put key value\r\n”“get key\r\n”

极有可能在服务端收到一条粘起来的数据:

“put key value\r\nget key\r\n”

甚至是分两次收到奇怪的分割的数据:

“put key value\r\nget k”“ey\r\n”

其实各种可能都有,因为 TCP 是字节流协议,所以 read 函数每次读的长度不是确定的,他不像 WebSocket 能不用担心「粘包」问题。如果数据中存在「空格」、「换行符」这样的字符,则需要进行字符串替换,也就是「转义」。

转义其实生活比较常见,JSON 格式当出现 utf8 数据时,会将数据转成 \u006F 这种形式;在 HTTP 协议中,URL 含有非 ASCII 或者空格之类的特殊字符,也会转义成为百分号编码 %2a 这种形式。也就是说,可以将具有特殊意义的字符串替换成普通的字符串,就可以安全地被解析了。

6. 小结

本次小短文通过借着「TCP 代理服务器」的线索,串起来了一些 TCP 以及网络编程的一些小知识。若有错误还望海涵,祝各位读得开心。

7. 参考

陈硕《Linux 多线程服务端编程:使用 muduo C++ 网络库》