Keep learning, keep living...

0%

SOCKS5和Dante介绍

SOCKS是一个比较简单的通用代理协议,用于在客户端与服务器之间代理网络数据包。最新的版本是5, 所以一般叫做SOCKS5,协议规范是RFC1928。但SOCKS5并不兼容之前的SOCKS4SOCKS4ASOCKS5SOCKS4的基础上添加了UDP转发功能和校验机制。

SOCKS5的工作过程简单可以归纳为协商、请求、和转发三个阶段。以TCP代理场景来看, 一般流程是:

  1. 客户端建立TCP连接
  2. 客户端发送客户端侧支持的校验方法
  3. 服务端回应选择的校验方法
  4. 客户端和服务端之间按选择的校验方法完成校验
  5. 客户端发送所需的请求给服务端。SOCKS5支持不同的请求类型,包括CONNECT, BIND, UDPASSOCIATE等。
  6. 服务端接收到请求,从中解析出目的地址,建立到目的地址的连接。
  7. 发送成功信息给客户端。
  8. 客户端开始发送应用层信息。
  9. 服务端在客户端和目的地址之间转发应用层信息。

其中,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连接。
  • sockdmother进程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的限制。当代理请求较多时,子进程越来越多,motherfd数量就会超过1024而导致进程崩溃。

可以通过不再定义这个宏来修复问题,也可以通过另外的方法来绕过这个问题。

Dante支持创建多个mother进程,每个mother都会再创建自己的一系列negotiate,request, io等进程,但monitor进程只是最初的mother(叫做main mother)会创建。因为main mother是在开始listen之后再fork其他进程,因而多个mother进程都监听在同一端口上,它们会调用accept来抢夺客户端连接。抢夺到连接的mother会在自己的一系列子进程中进行处理。这样我们可以限制每个mother的文件描述符小于1024,而启动若干个mothermother的数量可以通过命令行参数-N来指定。这种情况的进程架构如图:

Dante里很多实现细节不太考虑性能, 大量使用数组和遍历。举例来说,negotiate进程每次循环会调用neg_gettimedout函数获取一个超时的协商请求:

1
2
3
4
5
6
7
8
9
10
while (1 /* CONSTCOND */) {
...


#if HAVE_NEGOTIATE_PHASE
gettimeofday_monotonic(&tnow);
while ((neg = neg_gettimedout(&tnow)) != NULL) {

}
}

neg_gettimedout的实现里依然是一个遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static sockd_negotiate_t *
neg_gettimedout(const struct timeval *tnow)
{
size_t i;

for (i = 0; i < negc; ++i) {
if (!negv[i].allocated
|| CRULE_OR_HRULE(&negv[i])->timeout.negotiate == 0)
continue;

if (socks_difftime(tnow->tv_sec, negv[i].state.time.negotiatestart.tv_sec)
>= CRULE_OR_HRULE(&negv[i])->timeout.negotiate)
return &negv[i];
}

return NULL;
}

当协商请求很多时,negv这个数组基本被分配完,如果有一个请求超时,neg_gettimedout返回超时请求,下一次依然从数组第一个元素开始遍历。

如果对于SOCKS服务器性能有比较强的需求,Dante还是有很多优化空间的。比如使用epoll替换select, 使用其他结构替换数组等等。

参考: