SOCKS是一个比较简单的通用代理协议,用于在客户端与服务器之间代理网络数据包。最新的版本是5, 所以一般叫做SOCKS5,协议规范是RFC1928。但SOCKS5并不兼容之前的SOCKS4和SOCKS4A。SOCKS5在SOCKS4的基础上添加了UDP转发功能和校验机制。
SOCKS5的工作过程简单可以归纳为协商、请求、和转发三个阶段。以TCP代理场景来看, 一般流程是:
- 客户端建立TCP连接
- 客户端发送客户端侧支持的校验方法
- 服务端回应选择的校验方法
- 客户端和服务端之间按选择的校验方法完成校验
- 客户端发送所需的请求给服务端。
SOCKS5支持不同的请求类型,包括CONNECT,BIND,UDPASSOCIATE等。 - 服务端接收到请求,从中解析出目的地址,建立到目的地址的连接。
- 发送成功信息给客户端。
- 客户端开始发送应用层信息。
- 服务端在客户端和目的地址之间转发应用层信息。
其中,2-4完成协商阶段,5-7完成请求阶段,之后进入转发阶段。
当前常用的校验方法是USERNAME/PASSWORD(RFC1929)和GSS-API(RFC1961),具体的校验过程是由不同校验方法来自定义的。
使用USERNAME/PASSWORD校验方法的TCP代理时序图如下:
Dante是个较为成熟的SOCKS服务器开源实现,一些高级模块的代码没有开源,需要购买商业支持。但Dante的整体代码逻辑比较清晰,还是比较容易扩展的。
Dante的进程结构比较有意思,以一种类似流水线的方式在多个进程间传递并处理请求, 而且会根据请求量对相应进程数量进行调节,相应进程的处理量会通过修改进程名称来体现。
请求传递具体实现上依赖通过UNIX Socket在进程间传递文件描述符,机制可以参考这篇文章:
Dante包括5种不同角色的进程:mother: 初始启动的主进程,其他的进程都由mother进程创建。但在fork其他进程前,mother会建立两对UNIX Socket通道,一个用于在父子进程之间发送数据和文件描述符,另一个用于监控进程的存活状态。创建完其他进程后,mother开始循环处理客户端的TCP连接的accept。monitor: 基于超时时间处理其他进程的共享内存数据。negotiate: 处理SOCKS协议的协商。request: 建立到远端服务器的请求。io: 在客户端和远端服器之间转发数据。
整体的进程架构图如下:
上述的基于TCP的代理流程在sockd中的处理流程是:
- 客户端向
sockd发起TCP连接。 sockd的mother进程accept连接,然后选择一个negotiate进程,将相关数据结构及客户端连接fd发送给它。negotiate进程接收到fd和数据结构,从客户端fd读取协商请求和方法。从中选定一种方法,发送给客户端。- 客户端根据
sockd返回的校验方法发送校验请求。 negotiate进程读取校验请求并校验通过后,发送成功状态给客户端。- 客户端发送代理请求。
negotiate进程读取代理请求并构建相应的数据结构,将这个数据结构和客户端fd再发送回mother进程。mother进程收到这些信息后再选择一个request进程,并将这些信息转发给该进程。request进程根据收到的请求信息建立到远端服务器的TCP连接,并根据客户端和远端服务器两端信息构建相关数据结构,和两个fd一起发送回mother进程。mother进程将收到的数据和两个fd转发给选定的一个io进程。io进程接收到这些信息后,在两个fd之间进行数据转发。
整体流程如图:
每个角色进程的逻辑主体都是基于select实现的事件驱动模型。众所周知,select有最大支持1024个文件描述符的限制。而在一些操作系统具体实现上,实际并不会真的去检查该限制。Dante的实现就利用这一点来突破1024的限制。这篇文章介绍了这种方法:
https://blog.csdn.net/dog250/article/details/105896693
但实际上这里还是存在一个BUG。Dante默认的编译情况下,会定义_FORTIFY_SOURCE, 这会导致FD_SET中会去检查1024的限制。当代理请求较多时,子进程越来越多,mother的fd数量就会超过1024而导致进程崩溃。
可以通过不再定义这个宏来修复问题,也可以通过另外的方法来绕过这个问题。
Dante支持创建多个mother进程,每个mother都会再创建自己的一系列negotiate,request, io等进程,但monitor进程只是最初的mother(叫做main mother)会创建。因为main mother是在开始listen之后再fork其他进程,因而多个mother进程都监听在同一端口上,它们会调用accept来抢夺客户端连接。抢夺到连接的mother会在自己的一系列子进程中进行处理。这样我们可以限制每个mother的文件描述符小于1024,而启动若干个mother。mother的数量可以通过命令行参数-N来指定。这种情况的进程架构如图:
Dante里很多实现细节不太考虑性能, 大量使用数组和遍历。举例来说,negotiate进程每次循环会调用neg_gettimedout函数获取一个超时的协商请求:
1 | while (1 /* CONSTCOND */) { |
而neg_gettimedout的实现里依然是一个遍历:
1 | static sockd_negotiate_t * |
当协商请求很多时,negv这个数组基本被分配完,如果有一个请求超时,neg_gettimedout返回超时请求,下一次依然从数组第一个元素开始遍历。
如果对于SOCKS服务器性能有比较强的需求,Dante还是有很多优化空间的。比如使用epoll替换select, 使用其他结构替换数组等等。
参考:
- https://p0lycarp.github.io/2019/fortify/
- https://en.wikipedia.org/wiki/SOCKS
- https://datatracker.ietf.org/doc/html/rfc1928
- https://datatracker.ietf.org/doc/html/rfc1929
- https://datatracker.ietf.org/doc/html/rfc1961
- https://blog.csdn.net/dog250/article/details/105896693
- https://www.inet.no/dante/index.html
- https://securityintelligence.com/posts/socks-proxy-primer-what-is-socks5-and-why-should-you-use-it/
- https://zhuanlan.zhihu.com/p/65094630
- https://blog.csdn.net/JassFuchang/article/details/7170321
- https://wiyi.org/socks5-protocol-in-deep.html
- https://www.rapidseedbox.com/blog/guide-to-socks5-proxy
- https://copyconstruct.medium.com/file-descriptor-transfer-over-unix-domain-sockets-dcbbf5b3b6ec
- https://cybarrior.com/blog/2019/03/07/dante-socks5-proxy-server-setup/