GO语言实现socks5代理 | 青训营笔记

216 阅读11分钟

这是我参与[第五届青训营]伴学笔记创作活动的第1天

今天在青训营中学习到了使用go语言实现一个socks5代理服务器。这里记录一下学习心得。

一、什么是socks5

socks5是一种代理协议,它通过TCP协议与客户端和服务端分别建立连接,负责代理客户端的请求发送至服务端。我们为什么需要代理呢?直接从客户端将请求和数据发送到服务端不是更快捷吗?其实,代理通常用在客户端无法直接与服务端通信的场景,比如服务端所在的网络为特殊网络,无法直接访问,就需要一台能访问服务端的机器作为中间节点,这台机器就扮演着代理的角色。 下图是socks5代理协议的通信过程示意图。图像来自博客 SOCKS5 协议原理详解与应用场景分析 - chris599 - 博客园 (cnblogs.com) image.png

通过上图我们可以知道,作为socks5代理服务器,我们即需要与客户端通信,对客户端鉴权并拿到客户端请求的信息和数据,还需要和服务端进行通信,从而转发请求。因此,我们要实现socks5代理服务器,主要要完成以下功能:

  • 监听客户端的代理请求,获取相关信息,与客户端进行鉴权协商
  • 鉴权协商通过后,通过TCP获取客户端发送的请求和数据,转发至目标服务器
  • 通过TCP获取目标服务器发回来的响应,转发响应给客户端

二、socks5协议的数据包

我们知道,不同协议的数据包格式是不一样的,为了实现socks5代理服务器的相关功能,我们还需要去了解socks5中的数据包格式。 socks5协议的数据包根据不同的阶段分为三种不同的格式,下面分别介绍。

2.1 协商阶段

在使用socks5通信时,客户端首先会与socks5代理服务器建立TCP连接,成功连接后,客户端会向socks5服务器发送socks5协商请求。该请求数据包定义如下:

+----+----------+----------+
|VER | NMETHODS | METHODS  |
+----+----------+----------+
| 1  |    1     | 1 to 255 |
+----+----------+----------+
​
#上方的数字表示字节数,下面的表格同理,不再赘述
VER: 协议版本,socks5为0x05
NMETHODS: 支持认证的方法数量
METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下:
X’00’ NO AUTHENTICATION REQUIRED
X’01’ GSSAPI
X’02’ USERNAME/PASSWORD
X’03to X’7F’ IANA ASSIGNED
X’80to X’FE’ RESERVED FOR PRIVATE METHODS
X’FF’ NO ACCEPTABLE METHODS

socks5服务器接收到该数据包后,根据自身所提供的服务,选择合适的响应返回给客户端,与客户端商量好合适的版本号,使用什么样的鉴权方式。响应格式如下:

+----+--------+
|VER | METHOD |
+----+--------+
| 1  |   1    |
+----+--------+
当客户端收到0x00时,会跳过认证阶段直接进入请求阶段; 当收到0xFF时,直接断开连接。其他的值进入到对应的认证阶段。

2.2 鉴权阶段

如果在协商阶段中,socks5服务端返回的数据包要求必须使用鉴权,那么就会进入鉴权阶段。客户端发送一个认证请求到socks5服务端,格式如下

+----+------+----------+------+----------+
|VER | ULEN |  UNAME   | PLEN |  PASSWD  |
+----+------+----------+------+----------+
| 1  |  1   | 1 to 255 |  1   | 1 to 255 |
+----+------+----------+------+----------+
VER: 版本,通常为0x01
ULEN: 用户名长度
UNAME: 对应用户名的字节数据
PLEN: 密码长度
PASSWD: 密码对应的数据

socks5服务端收到该请求后,解析出相应的数据进行鉴权认证,根据认证结果返回响应给客户端。返回响应格式如下:

+----+--------+
|VER | STATUS |
+----+--------+
| 1  |   1    |
+----+--------+
STATUS字段如果为0x00表示认证成功,其他的值为认证失败。当客户端收到认证失败的响应后,它将会断开连接。

2.3 请求阶段

协商鉴权认证成功后,客户端与socks5服务端就可以正式通信了,这时候就进入了请求阶段,客户端向socks5发送请求数据包,该数据包会包含客户端想要请求的目标地址,目标端口以及请求方式等信息。格式如下:

+----+-----+-------+------+----------+----------+
|VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1  |  1  | X'00' |  1   | Variable |    2     |
+----+-----+-------+------+----------+----------+
VER 版本号,socks5的值为0x05
CMD
0x01表示CONNECT请求
0x02表示BIND请求
0x03表示UDP转发
RSV 保留字段,值为0x00
ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
0x01表示IPv4地址,DST.ADDR为4个字节
0x03表示域名,DST.ADDR是一个可变长度的域名
0x04表示IPv6地址,DST.ADDR为16个字节长度
DST.ADDR 一个可变长度的值
DST.PORT 目标端口,固定2个字节
上面的值中,DST.ADDR是一个变长的数据,它的数据长度根据ATYP的类型决定

socks5服务端接收该请求后,需要解析出请求的目标地址和端口,将请求转发到目的地址,同时返回一个响应给客户端,告知客户端此次代理请求的相关信息。格式如下

+----+-----+-------+------+----------+----------+
|VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1  |  1  | X'00' |  1   | Variable |    2     |
+----+-----+-------+------+----------+----------+
VER socks版本,这里为0x05
REP Relay field,内容取值如下
X’00’ succeeded
X’01’ general SOCKS server failure
X’02’ connection not allowed by ruleset
X’03’ Network unreachable
X’04’ Host unreachable
X’05’ Connection refused
X’06’ TTL expired
X’07’ Command not supported
X’08’ Address type not supported
X’09’ to X’FF’ unassigned
RSV 保留字段
ATYPE 同请求的ATYPE
BND.ADDR 服务绑定的地址
BND.PORT 服务绑定的端口DST.PORT

三、go语言实现socks5代理服务器

通过上面两节内容,我们了解了socks5的通信原理以及通信数据格式,那么接下来我们实现socks5服务器的步骤其实就简单明了。

  • 首先,建立一个TCP服务端监听客户端的连接请求
  • 其次,读取客户端的协商数据包,解析内容后给客户端返回响应
  • 如果需要鉴权,读取鉴权数据包,解析并验证相关信息,返回响应给客户端(在今天的课堂中,讲师实现的是无鉴权方式)
  • 接着,我们就可以读取客户端发送的请求,解析出请求的目的地址和端口,返回响应给客户端告知socks5代理已接收到相关请求
  • 最后,我们把请求发送到目标地址,获取到响应后,将响应转发给客户端即可。

3.1 项目结构

image.png
这是我搭建的项目结构,其中common用于定义socks5协议中规定的常量,service用于实现socks5代理服务器的所有功能,并且向外保留一个Serve函数供main函数调用。common中的常量定义如下:

package common

const Socks5Ver  = 0x05
const CmdBind = 0x01
const AtypeIPV4  = 0x01
const AtypeHost  = 0x03
const AtypeIPV6  = 0x04

3.2 tcp服务的实现

在go语言中,使用net包进行服务端的tcp构建十分简便,主要就以下几步:

  • 调用net包的Listen()函数,对指定的ip地址和端口进行监听,函数返回一个listener接口
  • 调用listener接口的Accept()方法,阻塞等待客户端连接,连接成功后返回conn对象
  • 通过conn对象读取客户端数据或者往客户端写数据
    具体代码逻辑如下:
func Serve(host string,port string){
    listener,err := net.Listen("tcp",host+":"+port)
    if err != nil{
        panic(err)
    }
    for{
        conn,err := listener.Accept()
        if err != nil{
            log.Println("accept failed")
            return
        }
        go process(conn) // 每来一个连接,就启动一个goroutine去处理
    }
}

3.3 代理逻辑实现

处理函数拿到连接后,负责将连接中客户端发过来的数据包解析,然后分别进行协商,鉴权和请求转发。主要步骤如下:

  • 通过bufio包,根据连接conn构建带缓冲区的数据流,可以想象成水管,客户端发送的数据源源不断地发送到水管中,而socks5服务器在需要的时候通过接口提供的方法读取数据即可
  • 解析第一个数据包,即协商阶段。返回响应给客户端,这里我们使用无鉴权方式
  • 由于无鉴权,因此缓冲区中接下来的数据,就是客户端发来的请求数据,因此,我们根据数据格式读取数据,解析出目的地址与端口,然后返回响应告知客户端代理请求已收到
  • 将请求转发给目的地址,然后将响应返回给客户端
    处理连接的函数process()如下:
func process(conn net.Conn){
    defer  conn.Close() //结束会话前关闭连接
    reader := bufio.NewReader(conn) //构建带缓冲区的只读流
    err := auth(reader,conn) //协商与鉴权
    if err != nil {
            log.Println("auth failed,err:",err)
            return
    }
    log.Println("auth success")
    err = connect(reader,conn) //进行代理通信
    if err != nil {
            log.Println("connect failed,err:",err)
            return
    }    
}

3.3.1 协商与鉴权

协商与鉴权处理函数,主要功能就是根据socks5数据包格式解析各个字段,根据代理服务器自身规则对指定字段进行验证,然后根据验证结果返回响应给客户端。由于本项目实现指定socks5代理不使用鉴权方式,因此只需要解析协商数据包,步骤如下:

  • 读取第一个字节,获取版本号,本项目规定只支持版本号为5的版本,根据获取到的版本进行验证
  • 读取第二个字节,获取methos字段的大小,然后根据大小建立缓冲区,该缓冲区用于读取methos字段
  • 读取methos字段,获取相应的认证方法
  • 返回相应给客户端
+----+----------+----------+
|VER | NMETHODS | METHODS  |
+----+----------+----------+
| 1  |    1     | 1 to 255 |
+----+----------+----------+
func auth(reader *bufio.Reader,conn net.Conn)error{
    version,err := reader.ReadByte() //读取版本号
    if err != nil {
            log.Println("read version failed")
            return err
    }
    if version != common.Socks5Ver{ //验证版本号
            return errors.New("unsupported version")
    }
    size,err := reader.ReadByte() //读取methos字段的大小
    if err != nil {
            log.Println("read size failed")
            return err
    }

    methods := make([]byte,size) //根据大小构建缓冲区
    _,err = io.ReadFull(reader,methods) //读取methos
    if err != nil {
            log.Println("read methods failed")
            return err
    }

    log.Printf("version:%v,method:%v\n",version,methods)

    // 返回相应,第二个字节为0表示服务端不要求密码鉴权
    _,err = conn.Write([]byte{version,0})
    if err != nil {
            log.Println("conn write auth failed")
            return err
    }
    return nil
}

3.3.2 连接与通信

协商鉴权过后,客户端与socks5成功建立连接,客户端首先会将请求相关信息发送给socks5服务端,socks5服务端负责解析该消息,获取目标地址与端口,然后返回响应告诉客户端已成功解析到目的地址与端口。随后,客户端发送请求到socks5服务端,socks5服务端转发该请求到目的地址,并将目标响应回应给客户端。主要步骤如下:

  • 解析数据包,获取目的地址与端口
  • 返回响应,通知客户端可以发送请求了
  • 转发客户端请求到目的地址,获取响应
  • 将响应返回给客户端
    具体实现如下:
+----+-----+-------+------+----------+----------+
|VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1  |  1  | X'00' |  1   | Variable |    2     |
+----+-----+-------+------+----------+----------+
func connect(reader *bufio.Reader,conn net.Conn)error{
    buf := make([]byte,4)
    _,err := io.ReadFull(reader,buf) //读取前面四个字节
    if err != nil {
            log.Println("read connect pkg failed,err:",err)
            return err
    }
    ver,cmd,atype := buf[0],buf[1],buf[3]
    if ver != common.Socks5Ver{ // 验证版本号
            return errors.New("unsupported version")
    }

    if cmd != common.CmdBind{ // 本项目只支持bind方式连接
            return errors.New("unsupported cmd")
    }

    addr := ""
    switch atype{ //本项目只支持目的地址为域名或ipv4地址
    case common.AtypeIPV4:
            _,err := io.ReadFull(reader,buf)
            if err != nil {
                    log.Println("read ipv4 addr failed")
                    return err
            }
            addr = fmt.Sprintf("%v.%v.%v.%v",buf[0],buf[1],buf[2],buf[3])
    case common.AtypeHost:
            hostSize,err := reader.ReadByte()
            if err != nil {
                    fmt.Println("read hostSize failed")
                    return err
            }
            hostBuf := make([]byte,hostSize)
            _,err = io.ReadFull(reader,hostBuf)
            if err != nil {
                    log.Println("read host failed")
                    return err
            }
            addr = string(hostBuf)
    case common.AtypeIPV6:
            return errors.New("unsupported IPV6")
    default:
            return errors.New("UNKNOWN TYPE")
    }
            _,err = io.ReadFull(reader,buf[:2]) //读取目的端口
    if err != nil {
            log.Println("read port failed")
            return err
    }
    port := binary.BigEndian.Uint16(buf[:2]) //解析目的端口
    
    //返回响应通知客户端
    _,err = conn.Write([]byte{ver,0x00,0x00,0x01,0,0,0,0,0,0}) 
    if err != nil {
            log.Println("connect write conn failed")
            return err
    }
    
    log.Println("dial:",addr,port)

    dest,err := net.Dial("tcp",fmt.Sprintf("%v:%v",addr,port)) //连接目的服务器
    if err != nil {
            log.Println("tcp dial failed")
            return err
    }
    defer dest.Close()

    // context是go语言中用于管理goroutine上下文的工具
    // 通过context,我们能优雅的结束相关goroutine,以防止出现内存泄漏
    ctx,cancel := context.WithCancel(context.Background())
    defer  cancel() //调用cancel函数,会关闭ctx.Done()返回的通道

    go func(){ // 启动一个goroutine,负责将客户端请求转发至目标服务器
            _,_ = io.Copy(dest,reader)
            cancel()
    }()

    go func() { // 启动一个goroutine,负责将目标服务器的响应返回给客户端
            _,_ = io.Copy(conn,dest)
            cancel()
    }()
    <-ctx.Done() //阻塞等待目标服务器或者客户端任意一端断开连接
    return nil
}

四、总结

在本次项目中,我们主要学习到了以下知识点:

  • socks5代理协议的原理
  • go语言中如何通过net库建立TCP连接
  • go语言如何处理IO,如何读写IO
  • go语言如何启动goroutine,如何通过context管理goroutine

引用参考