0x00 前情提要
因为需要做一个和代理有关的项目,想参考ss对于socks协议的处理部分。不过之前GitHub上ss的源码因为各种不可抗力已经被清空了,这里参考的是另外一个项目对ss做的备份。
本文关于有些比较细节的代码部分没有展开,主要是从整体上梳理了ss的源码结构,并阐述关键点的实现。本文不仅是分析代码,主要是想从理解的角度去分析ss为什么要这么做。
0x01 通信流程
在讨论源码结构之前,先介绍在ss网络通信中存在的四种角色:
- client : 通信客户端,指需要使用socks代理转发请求的应用、程序。
- sslocal : socks代理客户端,负责与client建立socks连接,并将请求加密转发到ssserver,再将ssserver返回的报文解密后传给client。
- ssserver : socks代理服务端,负责将sslocal发送的报文解密并转发给dest-server,以及将dest-server的响应报文加密传给sslocal。
- dest-server : 目标服务器,即通信客户端想要访问的服务器。
根据这四种角色,可以把通信过程简化为下图所示的流程。
可以看出在ss的架构中,只有client与sslocal之间有socks协议的握手、连接、通信过程,而在sslocal和ssserver之间只做加解密和转发(TCP/UDP),并不涉及socks协议。
之所以详细的介绍这四个角色,是因为在下面分析通信协议的过程中可能会因为对角色的理解不清楚产生误解。
0x02 源码架构
ss的源码结构如图所示,其主要逻辑代码都在ss文件夹下,核心逻辑的代码量在两千行左右。阅读的主要难度在于ss有sslocal和ssserver两个功能相似的模块,因此为了可复用性,代码中有大量的函数同时适用于这两个端,但在阅读过程中必须注意区分两者逻辑的不同(这也是ss值得学习的点)。
根据上一节所述,ss由sslocal和ssserver两个模块组成,其中sslocal的main函数位于local.py,ssserver的main函数位于server.py。这两个文件调用的函数基本相同,但server.py考虑到效率的问题做了多进程的处理。
除此之外,最重要的几个文件就是 eventloop.py、tcprelay.py、udprelay.py、asyncdns.py 。eventloop.py 使用 select、epoll、kqueue 等IO多路复用实现异步处理,实现事件的注册、监听等操作,并为其他文件的调用提供了统一的接口;tcprelay.py、udprelay.py、asyncdns.py分别用于处理 TCP、UDP、DNS 请求,包括接收和转发等操作。
其他文件包括 daemon.py (创建守护进程)、encrypt.py (加密/解密接口)、 lru_cache (缓存算法)、shell.py、common.py 等工具文件,这些都会在解释核心代码时涉及,但不会细讲。
0x03 源码详解
-
local.py / server.py
去掉多进程代码,以及一些错误处理的代码,local.py 与 server.py 的工作流程可以共同抽象为如下代码块。1-2 行是确认python版本,并读取配置文件。第3行则是为ss创建一个守护进程,守护进程可以在后台独立运行,linux 下许多重要服务器如 httpd、inetd 都基于此实现,但这个功能只在 UNIX 系统下启用,守护进程的详细介绍和实现看这里。
5-7 行创建并初始化 DNS 解析器、TCP 中继器、UDP 中继器,其中 DNS 解析器在初始化时读取系统保存的 resolv.conf (本机设置的域名服务器)和 hosts (缓存的域名-IP列表) 文件,并且存入ss自己维护的缓存中 (这里需要注意可能存在的缓存污染)。TCP 中继器和 UDP 中继器则初始化一些变量,并且创建 socket 用于监听输入,这里并不进行主动连接。
第9行创建一个 EventLoop 对象,并在初始化时选择系统支持的IO多路复用模型,一般来说 linux 下支持 epoll/poll/select,macOS 支持 kqueue/select。
10-12行则是将5-7行创建的 socket 交给 EventLoop 选定的多路复用模型,由其代理监听,并设置处理 socket 接收到数据的 handler(处理函数)。
16 行则开始一个死循环,阻塞地监听所有被加入多路复用模型的 socket ,在有数据到来时调用相应的中继器及其 handler 进行处理。1 shell.check_python() 2 config = shell.config(True) 3 daemon.daemon_exec(config) 4 try: 5 dns_resolver = asyncdns.DNSResolver() 6 tcp_server = tcprelay.TCPRelay(config, dns_resolver, True) 7 udp_server = udprelay.UDPRelay(config, dns_resolver, True) 8 9 loop = eventloop.EventLoop() 10 dns_resolver.add_to_loop(loop) 11 tcp_server.add_to_loop(loop) 12 udp_server.add_to_loop(loop) 13 14 daemon.set_user(config.get('user', None)) 15 16 loop.run() 17 except Exception as e: 18 shell.print_exception(e) 19 sys.exit(1)
这一小节只是大体阐述几个重要函数的作用,可以看到连接建立、数据转发等任务都没有提及,在下一节会具体解释以 eventloop 为核心构成的网络通信模型。
-
eventloop.py
-
IO多路复用
在分析 eventloop 之前,先解释一下为什么采用IO多路复用模型。 普通的 socket 通信可以是阻塞或者非阻塞的,但无论是哪一种都需要单个进程专门处理这个连接。当并发数量很高时进程数量也会非常多,对于代理这种应用,多进程或多线程的开销太大,因此就提出了IO多路复用模型。
IO多路复用 (IO multiplexing) 简单来说就是一个进程通过某种机制(比如轮询)同时监听多个文件描述符 (这里指 socket),当这些 socket 中的任意一个或多个进入就绪状态 (读/写/ERROR) 时监听函数就会返回一个 socket 列表,包含所有就绪状态的 socket,这时再调用相应的处理函数来接收/发送数据就可以了。
这里用一张图来解释整个过程(以 select 为例)。select 可以看作一个代理,用户进程将需要监听的 socket 注册到 select 模块中,select 代替用户去轮询所有 socket(注意,这个过程是阻塞的),一旦有某个 socket 有数据需要处理,select 就会返回并告诉用户有数据待处理。这时用户进程会调用 recvfrom 去缓冲区取得数据并进行下一步处理,对于用户程序来说这种方式是事件驱动的。
-
EventLoop
上面解释了IO多路复用的概念,在不同的系统中有对IO多路复用的不同实现。ss选择了 epoll、kqueue、select,并对三者进行封装,对外提供统一的接口。
其提供的通用接口有以下几种(不是全部):
poll(timeout)
: 开始监听,在有 socket 就绪时返回一个列表
register(fd,mode)
: 注册需要监听的 socket 及事件
unregister(fd)
: 注销对应的 socket 及其全部事件
整个处理流程是在对象初始化时选择实现(select、epoll 或 kqueue),在add_to_loop 时由 TCP/UDP/DNS 中继器注册监听事件, -
select、kqueue和epoll
这里只对三者的区别进行简单的解释,更详细的说明可以参考这里。在程序中,这三种实现的优先级是 epoll > kqueue > select。select 的实现就如上图所示,通过轮询来找到就绪的 socket,但无疑这种方式会占用CPU,消耗系统资源。因此就有 epoll 和 kqueue 的改进,这两者将轮询改成了回调函数的形式,当有 socket 就绪时,会自动调用设置的回调函数来告诉 epoll 和 kqueue 有事件发生,所以大多数时间这两者的效率要高于 select。 -
总结
EventLoop 起到的作用是代理监听多个 socket,在有数据到来时候调用相应的函数(handler)进行处理,有 epoll、kqueue、select 三个模块可以选择。
- aysncdns.py
如果说 Eventloop 用于分发任务,那么 aysncdns 、tcprelay 和 udprelay 就是用于处理具体的任务。aysncdns.py 对于 DNS 的处理完全基于二进制,而且对于协议的解析也很规范,很值得学习。
- DNS 报文格式
首先看一下 DNS 报文的格式。DNS 报文的请求和响应报文遵守相同的格式,由报头 (Header) 和报文主体组成。其中报头由以下几部分组成:- Transaction ID: 标识一个 DNS 会话,对应的请求和报文由相同的 ID
- Flags: 包括多个与报文有关的标志位,其中 QR 位用来区分当前报文是请求还是响应
- Question、Answer RRs、Authority RRs、Additional RRs 这四个域格式一致,都是指定报文主体中四个区域的节数
- DNS 请求/应答处理
aysncdns.py 文件对外提供的主要有两个接口,一个是 handle_event() 函数,其是 eventloop 监听到有数据接收时的处理函数;另一个是 resolve() 函数,负责从缓存中查找 DNS 响应以及发送未命中的 DNS 请求。
handle_event() 函数对于 DNS 报文的解析精确到位,流程也非常简洁明了,有时间可以仔细阅读,这里就不详细介绍了。
resolve() 函数则是先查询缓存(比如已经被读取的 hosts 文件),没有则校验域名的格式,最后封装成 DNS 报文。
- tcprelay.py
tcprelay.py 中有两个类,分别是 TCPRelay 和 TCPRelayHandler。TCPRelay的工作很简单,它接收到 eventloop 的消息时会判断这个有消息输入的连接是否已经在连接列表里,如果没有,则新建一个 TCPRelayHandler 对象去处理,如果有,则调用已有的 TCPRelayHandler 对象。
TCPRelayHandler 的逻辑则比较复杂,首先它在初始化时会根据 TCPRelay 传过来的参数新建一个和客户端的连接 (为什么是客户端?因为 eventloop 中此时只注册了TCP的监听接口,所以一定是客户端主动进行连接 );之后 TCPRelay 会将所有与该 socket 有关的事件都交给这个 TCPRelayHandler 对象的 handle_event() 函数来处理。
handle_event() 函数的处理过程比较复杂,这里用一张图来辅助讲解。
我们以图中的每一行为基准来讲解整个处理流程。
- 首先,TCPRelayHandler 收到消息以后会判断是哪个 socket 接收的,这时有两种可能,一种是 remote socket,即从服务器端接收的 (对于 sslocal 而言服务器端是 ssserver,对于 ssserver而言服务器端是 dest server),另一种是local socket (对于 sslocal 是 client,对于 ssserver 是 sslocal)。这里必须清楚的理解 remote 和 local 指代的是什么,不然之后的步骤会感到疑惑,若不记得了可去第一节复习以下。
- 判断完是服务器发送的还是客户端发送来的消息后,可以分为六个函数进行处理,其中无论是 local 还是 remote 的 write 和 err 函数逻辑都很简单,因为当接收到服务端消息时连接已经建立,只要做转发就可以了。复杂的部分在于read函数。
- 可以看到,on_local_read 和 on_remote_read 都先做了一个判断 is_local,这里一定要理解,这个 is_local 指的是当前程序运行的位置是 sslocal 还是 ssserver,这与第一步的 remote 和 local 的含义是不同的。之前说过,sslocal 与 ssserver之间的通信是加密的,因此在 sslocal 端收到信息需要解密,而发出信息需要加密,ssserver反之。
- 最后一步也是最复杂的一步,就是 sslocal 和 ssserver 结合客户端/远程服务器输入信息和当前状态进行处理的过程。先说接收客户端传来的消息,因为 sslocal 需要和 client 建立 socks 连接后再与 ssserver 建立连接,而 ssserver 只需要和 sslocal 建立 TCP 连接后就可以与 dest server 连接,所以 sslocal 会比 ssserver 多一个步骤。因此,整个建立连接的过程可以简化为如下代码:
首先,初始化的状态为 STAGE_INIT,当 sslocal 接收到 client 发来的 socks 握手请求后,回复 “0x 05 00” (表示socks5协议、无认证),并进入 STAGE_ADDR 状态,这一步 ssserver 是没有的。if is_local and self._stage == STAGE_INIT: self._write_to_sock(b'\x05\00', self._local_sock) self._stage = STAGE_ADDR return elif (is_local and self._stage == STAGE_ADDR) or \ (not is_local and self._stage == STAGE_INIT): self._handle_stage_addr(data) elif self._stage == STAGE_CONNECTING: self._handle_stage_connecting(data) elif self._stage == STAGE_STREAM: if self._is_local: data = self._encryptor.encrypt(data) self._write_to_sock(data, self._remote_sock) return
下一步,当 sslocal 进入 STAGE_ADDR,ssserver 还是 STAGE_INIT 时,sslocal 需要和 client 再进行一次 socks 建立连接过程,然后保存远程服务器的地址等待发送,ssserver 则是直接保存地址和数据并等待。这一步中其实还有一个隐含的过程,因为 sslocal 和 ssserver 得到的远程服务器地址可能是域名,而不是 IP 地址,所以还需要解析后才能进行连接,因此还有一个阻塞的 DNS 域名解析过程,解析成功后 sslocal 和 ssserver 都会进入 STAGE_CONNECTING阶段。
STAGE_CONNECTING 连接指的就不是和客户端的连接了,而是和服务器的连接。ssserver 在域名解析成功后就会直接与 dest server 发起连接,而 sslocal 则还需要处理 fastopen 。在建立完连接后两者就都进入 STAGE_STREAM状态,只负责发送数据了。
- udprelay.py
udprelay.py 的结构与 tcprelay.py 相似,而且要简单一些。udprelay.py 的 handle_event() 函数判断是由客户端还是服务器传来的数据,handle_client() 处理所有从服务器传来的数据,handle_server() 则处理所有从客户端传来的数据。
handle_server() 的处理与 tcprelay.py 中的 on_local_read() 很相似,都是接收客户端数据,ssserver 多一步解密,然后解析服务器地址,发送数据。(这里有一点注意,UDP 是无连接的,不需要维护 socket 列表,因此确认服务器返回的究竟是给哪一个客户端的数据,需要一个 (remote_addr, port,af) 的表来管理)。
handle_client() 接收服务器发来的数据,ssserver 需要在原始数据上加上一个头部再加密返回,sslocal 则解密并返回即可。
0x04 一次完整的通信过程
这一小节是因为之前看过的文章有从源码结构,也有从具体实现上对ss进行解释的,但没有文章从头到尾模拟过从 client 建立socks连接,到 sslocal 和 ssserver 转发请求、返回响应的整个流程,以及其中调用的函数。(参照下图看后文体验更佳)
首先,在设置完监听端口后,第一步肯定由 client 主动向 sslocal 发起连接,发送的是 socks 握手协议的选择认证方式的数据包,如下图所示。VER 表示 socks 协议版本,这里是 socks5 ,即5,NMETHODS 指支持几种认证方式,METHODS 表示分别是哪几种。这里 “0x05 01 00” 表示支持1种方式,00 即无认证的方式。
sslocal 则返回响应 " 0x 05 00",表示版本5,选择无认证方式。这一步在 on_local_read() 函数中实现。
下一步由 client 发送建立连接的数据包,CMD 字段 0x01 表示 TCP 连接,0x03 表示建立 UDP 连接,RSV 字段恒为0, ATYP 则决定后面的地址形式,01 为 IPv4,03为域名,04为 IPv6。DST.ADDR 和 PORT 则是目标地址和端口。
sslocal 接下来会返回如下格式的响应,REP 字段为0表示成功建立连接,其他与请求相同,BND.ADDR 和 PORT 只有当 UDP 连接时才有意义。这一步由 on_local_read() 调用_handle_stage_addr() 函数实现,至此,socks 连接建立完毕。
建立完 socks 连接后,sslocal 开始向 ssserver 建立连接,首先调用 asyncdns.py 中的 resolve 函数解析 ssserver 对应的 IP 地址(如果需要的话),然后在_handle_stage_connecting() 中建立与 ssserver 的连接。
这时处于 STAGE_INIT 状态的 ssserver 会调用 _handle_stage_addr() 函数解析 sslocal 传来的 dest-server 的地址和端口,如果是域名的话就调用 DNS resolve 进行解析。然后在_handle_dns_resolved() 函数中建立与 dest-server 的 TCP 连接。
之后,由 dest-server 返回的响应由 sslocal 的 on_remote_read() 函数接收,加密后传给 sslocal,sslocal 解密后返回给 client,这样一整个传输过程就完毕了。
0x05 收获
看完ss的源码可以说是收获颇丰的,一方面是通过ss构建网络通信模型的这一套方案学到了很多关于网络流量处理架构的知识,另一方面复习了一遍协议,包括socks、TCP、UDP、DNS这些,而且还对一些 python 网络编程的知识点进行了深入理解。
当然还有几个问题:
- 是ss的线程安全,比如cache,多线程对同一变量的修改问题,ss应该是没有做的的
- 如何用ss去做透明代理,不是通过网络端口转发的方式在前面再加一层,而是通过修改ss本身做到透明代理。(现在是不行的,因为ss不能拦截流量,只能主动连接,所以只支持本身就支持socks协议的程序)
- 还有几个由于篇幅限制没有提到的知识点,比如FastOpen技术,LRU缓存,服务器的多进程处理等等,之后有时间可以再深入研究。
在通读源码以及书写本文时参考了如下有关文章:
https://loggerhead.me/posts/shadowsocks-yuan-ma-fen-xi-udp-dai-li.html
https://github.com/cccfeng/shadowsocks_analysis