这是我参与「第三届青训营 -后端场」笔记创作活动的第 2 篇笔记
引言
SOCKS5 协议作为一款广泛使用的代理协议,它在使用TCP/IP协议通讯的客户端和服务器之间扮演一个中介角色,使得内部网中的客户端变得能够访问Internet网中的服务器,或者使C/S(Client和Server)之间的通讯更加安全。本文将会介绍 SOCKS5 的原理,并简单实现一个 SOCKS5 代理服务器。
1. SCOKS5 概述
SOCKS5代理协议是明文传输的。这个协议历史非常久远,诞生于互联网早期,我们来想象这样一个场景,如果一个企业的内网为了确保安全性,它可能配置了很严格的防火墙策略,但带来的副作用是即便你作为管理员访问某些资源也会很麻烦。那么这时候 SCOKS5 的作用就能体现出来了,SCOKS5 协议参相当于在防火墙内部开了个口子,让授权的用户可以通过单个端口访问内部的所有资源。实际上很多代理软件最终暴露的也会是一个 SCOKS5 协议的端口,给一些浏览器使用。如果有些同学开发过爬虫的话,就知道在爬虫的过程中很容易遇到由于 IP 访问频率超过限制,然后就会报错。这时候很多人可能会去找免费的或者收费的是代理 IP 池,里面很多的代理协议,也就是 SCOKS5 协议。
2. SCOKS5 代理特点
SOCKS是运行在OSI七层协议中的第五层会话层: 它可以处理包括HTTP、HTTPS、POP3、SMTP 和 FTP等多种请求类型,所以可以使用SOCKS协议来进行邮件发送、网页浏览、文件传输等。相对于SOCK4来说,SOCKS5加入了认证机制,所以可以通过身份验证建立完整的TCP连接,SOCKS5通常和SSH一起使用,通过使用SSH加密隧道方法来中继流量。
绕过互联网块: 由于代理服务器充当设备和互联网之间的中继,因此它们可以轻松绕过互联网块。例如,如果客户端IP被某个网站列入黑名单(或者使用VPN并且其服务器IP已被列入黑名单),则可以通过SOCKS5代理路由客户端的流量,从而绕过此块。但是,它无法规避国家防火墙,因为大多数防火墙都使用深度数据包检测(DPI)。这意味着客户端的ISP在到达网站之前就已经阻止了客户端的流量。
更快,更可靠的连接: 与仅使用TCP协议的前代产品不同,SOCKS5代理服务器可以使用UDP协议,确保可靠的连接和高效的性能。TCP互联网协议在客户端和服务器之间形成连接,确保所有数据包从一端到达另一端。它需要将内容拟合为固定格式,以便可以轻松传输。另一方面,UDP不关注来自客户端或服务器的所有数据包是否到达另一方以及它们是否以相同的顺序传输。 UDP不会浪费时间将数据包转换为固定包流。因此,有了这些UDP,SOCKS5可以提供更快的速度和可靠的连接。
错误减少,整体性能提升: 许多其他代理重写数据包头部。因此,错误路由或错误标记数据的可能性很高。但SOCKS5代理服务器不会重写数据包头部,因此错误的可能性较低。由于错误少得多,性能会自动提高。但是,这会以客户端的隐私和安全为代价,因为数据包头部包含客户端的个人信息,并且可以轻松识别。
在P2P平台上表现更好: SOCKS5比其他代理更快,因为它传输较小的数据包。因此,它提供更快的下载速度,这就是许多用户使用它连接到P2P共享网站和平台的原因。
3. SCOKS5 协议的工作原理
正常浏览器访问一个网站,如果不经过代理的话,要先和对应的网站经过三次握手建立 TCP 连接,在连接之后正常发起 HTTP 请求,然后服务器返回 HTTP 响应。
如果设置代理服务器的话,流程就会变得稍微复杂。首先浏览器要和 SOCKS5 代理服务器建立 TCP 连接,代理服务器再和真正的服务器去建立 TCP 连接。这里总共可以分为四个阶段:
第一个阶段,协商阶段。此时用户的浏览器会向 SOCKS5 代理服务器去发送一个报文。这个报文里面包括一个协议版本号,一般就是 v5,还有支持的认证的方法数量以及种类。对于SOCKS5支持用密码鉴权或者不需要认证,那么代理服务器会从里面选一个他自己支持的认证方式,并返回一个认证方式给浏览器,告诉浏览器说我建议你用什么鉴权方式,如果返回 00 的话,就代表不需要认证,返回其他认证的话,就进入第二个阶段,认证阶段,会走认证流程。下面的例子我们会跳过对认证流程的描述,因为我们要实现的是一个不加密的一个代理。
第三个阶段就是请求阶段。认证通过之后,浏览器会向 SOCKS5 代理服务器发送下一个报文,包括协议的版本号、请求的类型。然后一般是 CONNECT 请求,就代表我命令代理服务器,你要和某个域名、某个 IP 某个端口建立 TCP 连接。代理服务器收到响应之后,就会真正的和后端服务器去建立 TCP 连接,然后返回一个报文告诉用户浏览器,我成功建立连接了。
第四个阶段是 relay 阶段,此时浏览器就正常发送请求。代理服务器收到请求之后,它会直接简单的把请求转发到真正的服务器上,如果真正的服务器有返回响应的话,也会把响应转发到浏览器这边。实际上代理服务器它并不关心流量的细节,这里可以是 HTTP 流量,也可以是其他 TCP 流量,这就是 SOCKS5 协议的工作原理。接下来我们会试图简单的去实现它。
4. SOCKS5代理的实现
4.1 v1版本--TCP echo server:
我们会先实现一个TCP echo server, 这个 server 的逻辑很简单,你给他发送啥,他就给你回复啥,这样我们用来测试我们的 server 写的对不对?首先在 main 函数里面,我们会用 net.Listen 去监听一个端口,监听完端口之后会返回一个 server, 接下来到一个死循环里面,我们去用 server.Accept 去接受一个请求,如果成功的话就会返回一个连接client。
接下来我们会用会在一个 process 函数里面去处理这个连接。注意在前面会有一个go关键字,这里的话就代表我们会启动一个 goroutine ,你可以暂时类比为其他语言里面的启动一个子线程去处理这个连接client。只不过在go里面的 goroutine 的开销会比子线程小很多,可以轻松的处理上万的并发。
func main() {
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
for {
client, err := server.Accept()
if err != nil {
log.Printf("Accept failed %v1", err)
continue
}
go process(client)
}
}
下面重点就是这个 process(client) 函数的实现。第一步的话就是先加一个 defer conn.close ,这个代表在这个函数退出的时候,一定要把这个连接关闭,因为这个连接的生命周期,就是这个函数的生命周期。
接下来的话会用到 bufio.NewReader(conn) 去基于这个连接创建一个只读的缓冲流。然后在一个死循环里面,我们用 reader.ReadByte() 去每次读一个字节,再用 conn.Write([]byte{b}) 把这一个字节写入,这里正常是写一个。我们这里写一个字节的话,我们就可以用一个 byte 切片去包装一下。如果出错了的话,我们就直接 break 然后关闭连接。然后这个地方 bufio.NewReader(conn) 它实际上它是一个叫做带缓冲的流。就意味着其实这个reader.ReadByte()我们看起来是一个字节的读,看起来是非常低效的。因为正常服务端发送速度的话,肯定都是可能几百个字节几 KB 这样的发送。你要读很多次,会有很多次系统调用。但实际上底层实现会把它做一个合并,你读第一个字节的时候,它会提前把下一 KB的 都读完。
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
b, err := reader.ReadByte()
if err != nil {
break
}
_, err = conn.Write([]byte{b})
if err != nil {
break
}
}
}
我们写完这个 server 之后,下面简单测试一下。这里测试的话就不能用curl命令了,我们用 nc 命令,这个命令可以直接和某个 IP 加端口去建立一个 tcp 连接。我们run起来代码之后,在另一个终端用 nc 127.0.0.1:1080,然后我们输入任意字符串,比如 helo ,那么服务器就会给你返回 hello 。
4.2 v2版本-Auth:
刚刚我们已经完成了一个能够返回你输入信息的一个 TCP server,接下来我们试图去实现协议的第一步,协商阶段。从这一步开始,代码会变得比较复杂了。我们先改一下那个 process 函数,把里面的那个死循环删掉,改为调用这个 auth 函数。如果失败的话,我们就打印日志 return ,否则就打印鉴权成功。
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
err := auth(reader, conn)
if err != nil {
log.Printf("client %v1 auth failed:%v1", conn.RemoteAddr(), err)
return
}
log.Println("auth success")
}
接着我们来实现一个 auth 函数,就是用来鉴权,然后他的参数的话是一个只读流,后面再加那个原始的 TCP 连接。
我们再来回忆一下那个协商阶段的逻辑。第一步,浏览器会给代理服务器发送一个报文。这个报文会有三个字段,第一个字段是 VER 协议版本号,固定数:socks5为0x05。第二个字段NMETHODS:支持认证的方法数量,第三个字段:METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。然后一些常用的鉴权方式 0 代表不需要鉴权, 1 代表用户名密码鉴权。
我们先要把这个报文给完整读出来。报文前面两个字段都是单字节的,我们可以用 reader.ReadByte() 去读一个字节。ver, err := reader.ReadByte()这里先读到了版本号,那么我们接下来就判断版本号是否正确。如果出错的话,我们都直接返回一个错误信息,之后,上面的 process 函数就会关闭连接。然后下面methodSize, err := reader.ReadByte()同样也是单个字节。读完这个 methodSize 之后,我们会用这个 methodSize 去创建一个 method 的一个byte切片,然后用 io.ReadFull(reader, method) 去把它填充。此时我们就成功读到了所有的三个字段,我们加行日志把它打印出来。
// +----+----------+----------+
// |VER | NMETHODS | METHODS |
// +----+----------+----------+
// | 1 | 1 | 1 to 255 |
// +----+----------+----------+
// VER: 协议版本,socks5为0x05
// NMETHODS: 支持认证的方法数量
// METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
// X’00’ NO AUTHENTICATION REQUIRED
// X’02’ USERNAME/PASSWORD
ver, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read ver failed:%w", err)
}
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v1", ver)
}
methodSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read methodSize failed:%w", err)
}
method := make([]byte, methodSize)
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("read method failed:%w", err)
}
log.Println("ver", ver, "method", method)
按照协议的话,我们需要,我们返回一个数据包,告诉浏览器我们选择了哪种鉴权方式。我们这边直接把这个包构造出来。第一个字节是协议版本号VER,socks5Ver。后面是选择的鉴权方式METHODS,00表示我不需要认证。
// +----+--------+
// |VER | METHOD |
// +----+--------+
// | 1 | 1 |
// +----+--------+
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
写完这个代码之后,我们用curl命令测一下,此时可以预期curl命令肯定是不成功的,因为我们的协议还只实现了第一步,但是我们看日志的话,我们会发现我们应该是能成功打印出来 VER 和 METHODS 的两个字段的。这里看到 curl 它支持 0 这种鉴权方式,这说明我们当前的实现是正确的。
PS:老师上课curl之后是支持0和1两种鉴权方式的:
4.3 v3版本--请求阶段:
第三个,请求阶段。我们代码会试图读取浏览器端的一个报文。这个里面携带了用户需要访问的 URL 或者 IP 和端口,然后我们先把它打印出来,我们还会实现一个和 auth 函数类似的一个 connect 函数,签名是一致的。然后同样的在那个 process 函数里面去调用,上面是auth,下面是 connect。
func process(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
err := auth(reader, conn)
if err != nil {
log.Printf("client %v1 auth failed:%v1", conn.RemoteAddr(), err)
return
}
err = connect(reader, conn)
if err != nil {
log.Printf("client %v1 auth failed:%v1", conn.RemoteAddr(), err)
return
}
}
接下来我们再来实现 connect 函数的代码:
我们先回忆一下请求阶段的逻辑,浏览器会发送一个报文,报文里面包括这六个字段,第一个字段 VER 版本号同样是5。第二个字段 CMD 0x01表示 CONNECT 请求,就是让代理服务器和下游服务器创建连接。第三个字段,RSV,保留字段,一般就是0。第四个字段 ATYPE 就是我们重点关注的目标地址类型,它可能是多种类型,比如 IPV4、 IPTV6,或者是一个域名。这里 1 代表 IPV4,然后 3 代表域名,如果是 IPV4 的话,那后面的这个地址那就是固定长度 4 个字节。如果是域名的话,那么后面是个变长的一个字符串。DST.ADDR 是一个可变长度的值,最后DST.PORT 是目标端口,固定2个字节。
// +----+-----+-------+------+----------+----------+
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
// +----+-----+-------+------+----------+----------+
// | 1 | 1 | X'00' | 1 | Variable | 2 |
// +----+-----+-------+------+----------+----------+
// VER 版本号,socks5的值为0x05
// CMD 0x01表示CONNECT请求
// RSV 保留字段,值为0x00
// ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
// 0x01表示IPv4地址,DST.ADDR为4个字节
// 0x03表示域名,DST.ADDR是一个可变长度的域名
// DST.ADDR 一个可变长度的值
// DST.PORT 目标端口,固定2个字节
我们接下来要挨个的把这六个字段都读出来。虽然前面的这四个字段我们可以一个一个的 reader.ReadByte(),但这里我们换种方式,我们创建一个长度为 4 的byte切片,然后用 io.ReadFull(reader, buf)把它直接填充,那这样子我们就能一次性读取到前面这四个字段,因为他们都是定长的一个字节。那么接下来对于每个字段的话,我们都验证它的合法性。
对于 ATYP 它有不同的类型。
- 如果是
atypeIPV4的话,我们同样需要去读四个字节。我们上面这个缓冲区恰好是四个字节,我们就直接还是把它填充,然后把它打印成一个 IP 地址就好了。a.b.c.d这样打印。 - 如果是
atypeHOST类型的话,那么我们照例先读一个字节 host 的长度,然后再 make 一个对应长度的一个字符串,然后再用io.ReadFull(reader, host)把它填充满,填充满之后把它转换成一个字符串即可。 - 如果是
atypeIPV6的话,其实也是读一个固定长度的,我们这里就暂时不实现了,因为用的比较少,其他方式的话也不予支持。
我们前五个字段已经读完了,最后还上一个端口号两个字节。我们可以弄一个新的两个字节,然后去 read , 这里的话我们用另一种方式实现,我们复用之前的那个定义的长度为 4 的buf切片。io.ReadFull(reader, buf[:2])我们用一个切片语法,把它裁剪成一个两个字节的缓冲区,把它填充满。然后这个新的切片和原始的切片是共用底层数组的。所以下面这个缓存池里面是能直接读到那个端口号数据的。
然后因为DST.PORT是网络字节序(TCP/IP各层协议将字节序定义为Big Endian),我们需要用 binary.BigEndian.Uint16() 函数,按照大端字节序去解析出来,我们再打印一下日志,我们将会与这个地址端口号去建立连接。
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read header failed:%w", err)
}
ver, cmd, atyp := buf[0], buf[1], buf[3]
if ver != socks5Ver {
return fmt.Errorf("not supported ver:%v1", ver)
}
if cmd != cmdBind {
return fmt.Errorf("not supported cmd:%v1", ver)
}
addr := ""
switch atyp {
case atypIPV4:
_, err = io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read atyp failed:%w", err)
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
hostSize, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read hostSize failed:%w", err)
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host failed:%w", err)
}
addr = string(host)
case atypeIPV6:
return errors.New("IPv6: no supported yet")
default:
return errors.New("invalid atyp")
}
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed:%w", err)
}
port := binary.BigEndian.Uint16(buf[:2])
log.Println("dial", addr, port)
然后按照协议,我们接收到了浏览器的这个行为之后,我们还要给予一个响应报文,这个响应报文字段还是挺多,但是很多都用不上。这里的话包括 BND.ADDR、BND.PORT 都不是我们这种 connections 所必须的,我们都会直接把它填成零值。所以按照协议的话,我们就第一个字段填成 5 后面 IP(BND.ADDR)我们填成 0 ,RSV 是0。 ATYPE的话我们填一个最简单的一就是 IPV 4 后面 address 用 4 个字节,4个 0 ,最后端口两个0,拼成一个 byte数组直接写进去。
// +----+-----+-------+------+----------+----------+
// |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
// +----+-----+-------+------+----------+----------+
// | 1 | 1 | X'00' | 1 | Variable | 2 |
// +----+-----+-------+------+----------+----------+
// VER socks版本,这里为0x05
// REP Relay field,内容取值如下 X’00’ succeeded
// RSV 保留字段
// ATYPE 地址类型
// BND.ADDR 服务绑定的地址
// BND.PORT 服务绑定的端口DST.PORT
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
return fmt.Errorf("write failed: %w", err)
}
那么我们的 connect 阶段就算完成,我们简单测试一下,到这一步的话还是会失败。但是我们应该能看到我们能够正常打印出我们需要访问的 IP 和端口,这说明我们到当前我们的代码都是正确的,这样子我们接下来就可以做最后一步,我们需要真正和这个 IP 端口去建立连接,双向转换数值,我们代理就算完成了。
4.4 v4版本--relay阶段:
我们再来做最后一步,我们需要和真正的服务去建立 tcp 连接。我们会用net.Dial函数,这个就是简单的去用 TCP 协议对应的 IP 或者域名加端口去建立 TCP 连接。建立连接之后如果没有出错,我们第一时间还是加一个defer dest.Close(),在函数结束的时候去关闭连接。
dest, err := net.Dial("tcp", fmt.Sprintf("%v1:%v1", addr, port))
if err != nil {
return fmt.Errorf("dial dst failed:%w", err)
}
defer dest.Close()
log.Println("dial", addr, port)
接下来的话我们需要去建立浏览器和下游服务器的双向数据转换。我们找一下标准库,在 IO 包里面有一个 copy 函数,它可以实现一个单向数据转发,或者把 source 这个只读流里面的数据就是用一个死循环去逐步的拷贝到 dist 这个可写流里面,逻辑的话反正就是死循环,然后从 source 里面尝试读数据,读出之后就尝试往 dest 里面写。
和最开始第一版那个显示你输入的数据那个代码是类似,但我们现在我们需要实现一个双向数值转发,用一个 io.copy 是不够的。我们需要启动两个goroutine, 然后在里面去分别调用 io.copy 。注意这两个goroutine 的拷贝的方向是不一样的。一个是从用户的浏览器拷贝数据到底层的服务器,另一个是从底层服务器拷贝数据到用户的浏览器,两个方向恰好相反。但是现在的一个版本实现有一个问题,就是启动过渡是几乎不耗时间的。那么正常情况下函数就直接返回了,那么连接也就被关闭了,而我们需要等待任何一个方向的 copy 失败,就代表可能某一方关闭连接了,我们此时才终止整个连接。
这里我们会用到标准库里面的一个 context 机制,这个是 Golang 标准库里面一个很重要的内容,我们会用 ctx, cancel := context.WithCancel(context.Background()) 来创建一个 context 。然后在最后<-ctx.Done()我们会等待这个 context 的执行完成。这个执行完成的时机也是被 cancel 的函数被调用的时机。那么我们我们会在任何一边出错的时候被调用一下 cancel 函数。然后第 171 行这边也会调用一次 cancel 虽然没有什么意义,cancel可以多次调用,是幂等的。这样我们就实现了,任何一个方向的 copy 失败,我们就返回此函数,然后并且把双方的连接都关闭掉,清理数值。
到这里我们的代理服务器就完工了,我们可以用 curl 命令去测一下,应该是会成功的。会详细的输出和这个 socks5 代理服务器取协商,然后去连接,然后再去发起 http 请求,接收到响应,这边的代理服务器也会收到那个也会打印出来,说我和某个 IP 的某个端口建立连接了。
我们也能够在浏览器里面去测试这个代理。在 Chrome 浏览器里面的话,我们需要安装一个 Chrome 插件 switch Omega,在这个里面,我们需要点击这个新建情景模式,按照下图创建完成之后,点击下面的应用更改应用选项,然后再点右上角浏览器插件的小圈圈,然后把切换成你刚刚配置的代理。你此时你再去打开新的网页的话,那么你新的网页的所有流量都会通过这个代理服务器。服务器这边则会输出来你所访问的域名加端口。