1. 前言
通过这篇实践文章,我们可以更深入地理解协议本身是如何投入到实际使用中的,并对 Go 的一些实际开发中常用的特性进行了解。
2. SOCKS5 代理服务器
代理服务器的概念其实很简单,本来的网络请求流程是 客户端-->服务端,而代理服务器的角色就是充当一个中间人,以自己的身份向服务端发送和接收由客户端发起的请求与回报数据。这样在服务端看来,客户端就是透明的,网络上只存在代理服务器。当然,代理服务器作为中间人,还可以执行一些特殊操作,如记录、访问控制等。客户端与代理服务器间的请求可以通过任意的方式(即协议)进行,这里我们以 SOCKS5 为例。SOCKS5 是基于 TCP 协议的,因此我们可以直接利用 Go 的 tcp 套接字函数来实现 SOCKS5 协议。
3. 代码实现
首先,我们来实现一个最简单的 echo 服务器。代码如下:
package main
import (
"bufio"
"log"
"net"
)
func main() {
log.Print("starting server")
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
log.Fatal("unable to listen:", err)
}
for {
client, err := server.Accept()
if err != nil {
log.Print("failed to accept:", err)
continue
}
log.Print("accepting a new client")
go process_connection(client)
}
}
func process_connection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
for {
b, err := reader.ReadByte()
if err != nil {
log.Print("read EOF")
break
}
if err := writer.WriteByte(b); err != nil {
log.Print("write failed:", err)
break
}
if err := writer.Flush(); err != nil {
log.Print("flush failed:", err)
break
}
}
}
在代码中,我们使用 log 提供的函数来进行日志化输出(如添加时间,等等),net.Listen 用于在本地的 1080 端口进行监听,然后使用死循环来接收新的客户端连接请求,并将每个客户端的连接新开一个 goroutine 进行处理。在 process_connection 的开头,我们通过 defer 来确保在函数退出时连接能被正确关闭,相关资源能够被释放,然后使用 bufio 提供的 Reader 和 Writer 来简化代码的写法并借助缓存提高性能。运行效果如下:
可以看见,我们输入什么,服务器就会返回什么,功能实现正确。接下来来实现协议的第一部分:协商阶段。在这一阶段,客户端会向代理发起协商请求,格式如下(第二行代表对应字段所占字节数):
| VER | NMETHODS | METHODS |
|---|---|---|
| 1 | 1 | NMETHODS |
VER 为协议版本,固定为 0x5;NMETHODS 表示支持的认证方法数,METHODS 表示所有支持的认证方法。我们这里不关心认证,因此只需要选择 0x00 (NO AUTHENTICATION REQUIRED) 即可。接下来将选择的方式返回给客户端,格式如下:
| VER | METHOD |
|---|---|
| 1 | 1 |
根据上面的前提,这里直接返回 0x05 0x00 两个字节即可。对代码进行修改:
func process_connection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
// Negotiation phase
b, err := reader.ReadByte()
if err != nil {
log.Printf("read ver failed: %v", err)
return
}
if b != 0x05 {
log.Printf("found unsupported version %v", b)
return
}
b, err = reader.ReadByte()
if err != nil {
log.Printf("read nmethods failed: %v", err)
return
}
_, err = reader.Discard(int(b))
if err != nil {
log.Printf("read methods failed: %v", err)
return
}
_, err = writer.Write([]byte{0x05, 0x00})
if err != nil {
log.Printf("write negotiation failed: %v", err)
return
}
err = writer.Flush()
if err != nil {
log.Printf("flush negotiation failed: %v", err)
return
}
log.Print("negotiation succeeded")
}
测试结果如下:
虽然 curl 返回了错误,但可以看到输出了 negotiation succeeded,可以认为这一阶段成功了。接下来是认证阶段(又称子协商阶段),由于我们选择了无认证,这一阶段可以直接跳过。接下来便进入了请求阶段,客户端发送的数据格式如下:
| VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|---|---|---|---|---|---|
| 1 | 1 | X'00' | 1 | Variable | 2 |
VER 固定为 0x5,CMD 为命令,此处我们只关心 TCP 流转发,值为 0x01,RSV 固定为 0,ATYP 为地址类型,DST.ADDR 与 DST.PORT 分别为目标地址和端口。添加代码如下:
const atypeIPV4 byte = 0x01
const atypeHOST byte = 0x03
const atypeIPV6 byte = 0x04
const cmdConnect byte = 0x01
func process_connection(conn net.Conn) {
// ...
// Request phase
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
log.Printf("read request failed: %v", err)
return
}
ver, cmd, atype := buf[0], buf[1], buf[3]
if ver != 0x05 {
log.Printf("found unsupported version %v", b)
return
}
if cmd != cmdConnect {
log.Printf("found unsupported cmd %v", cmd)
return
}
addr := ""
switch atype {
case atypeIPV4:
_, err = io.ReadFull(reader, buf)
if err != nil {
log.Printf("read request failed: %v", err)
return
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
hostSize, err := reader.ReadByte()
if err != nil {
log.Printf("read hostSize failed: %v", err)
return
}
host := make([]byte, hostSize)
_, err = io.ReadFull(reader, host)
if err != nil {
log.Printf("read host failed: %v", err)
return
}
addr = string(host)
case atypeIPV6:
log.Print("found unsupported atype IPV6")
return
default:
log.Printf("found invalid atype %v", atype)
return
}
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
log.Printf("read port failed: %v", err)
return
}
port := binary.BigEndian.Uint16(buf[:2])
_, err = writer.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
if err != nil {
log.Printf("write request failed: %v", err)
return
}
err = writer.Flush()
if err != nil {
log.Printf("flush request failed: %v", err)
return
}
log.Print("request succeeded")
// ...
}
从上面的代码中可以看出,IPV4 地址使用 4 个字节来传输,而 HOST 地址则是使用一个 1 字节长度 + 对应长度的字符序列来表示。注意 io.ReadFull 用于保证读取了足够的字节数,而不会由于网络原因导致只返回部分字节;binary.BigEndian.Uint16 用于将网络序的 2 字节整数转为本机序。最后,我们构造响应并返回给客户端。回报格式如下:
| VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
|---|---|---|---|---|---|
| 1 | 1 | X'00' | 1 | Variable | 2 |
这里 REP 设置为 0x00 表示连接成功建立,BND.ADDR 与 BND.PORT 返回为 0.0.0.0:0,表示直接使用当前连接进行转发。测试结果如图:
可见功能实现正确。最后是转发阶段,这里直接原封不动地拷贝数据即可。先与服务端建立连接:
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
log.Printf("dial dest failed: %v", err)
return
}
defer dest.Close()
log.Println("dial", addr, port)
这里用 net.Dial 与服务端建立 TCP 连接,然后使用 defer 释放资源。接下来需要转发双向的数据,这里简单地使用两个 goroutine 与 io.Copy 实现转发。当然,直接写上 go func(){ ... } 是不行的,这样会导致当前 goroutine 在创建完两个 goroutine 后立刻退出,导致连接被关闭,进而导致转发失败。因此,我们需要用一些手段让外层的 goroutine 能够等待拷贝完成后再退出:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
_, _ = io.Copy(writer, dest)
cancel()
}()
<-ctx.Done()
这里的魔法步骤其实就是 <-ctx.Done(),它通过 channel 的堵塞读取语法巧妙的实现了对转发完毕(通过 cancel() 触发)的等待,完美地实现了我们的目标。最终效果如图:
可以看见整个代理正常地工作了起来。
4. 总结
这是我第一次使用 Go 做网络相关的程序,总的来说,这次实践帮助了我更好地熟悉了 SOCKS5 协议与 Go 的基本网络编程,途中也查阅了一些资料,增强了自己的资料阅读能力,也算是十分有成就感了。