Go语言入门-实战-SOCKS5代理 | 青训营笔记

192 阅读7分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天

本堂课重点内容

  • SOCKS5协议介绍
  • SOCKS5代理服务器Go实战

1. SOCKS5协议

1.1 定义

百度百科定义

SOCKS5 是一个代理协议,它在使用TCP/IP协议通讯的前端机器和服务器机器之间扮演一个中介角色,使得内部网中的前端机器变得能够访问Internet网中的服务器,或者使通讯更加安全。SOCKS5 服务器通过将前端发来的请求转发给真正的目标服务器, 模拟了一个前端的行为。在这里,前端和SOCKS5之间也是通过TCP/IP协议进行通讯,前端将原本要发送给真正服务器的请求发送给SOCKS5服务器,然后SOCKS5服务器将请求转发给真正的服务器。

SOCKS5代理服务器位于客户端与下游服务器之间,充当一个跳板的角色。

1.2 原理

image.png 图示来源Socks5工作原理与搭建_東魔的博客socks5

正常情况下,我们使用浏览器访问网站,浏览器会与下游服务器先通过三次握手与服务器建立TCP连接,然后再通过HTTP协议进行数据传输。而经过SOCKS5代理后,此时则需要经过3个步骤:认证、请求、通信。

1.2.1 认证

客户端向代理服务器发送代理请求,其中包含了代理的版本和认证方式
代理请求的报文格式如下:

VERNMETHODSMETHODS
111 to 255

字段说明:

  • VER: SOCKS的版本号,SOCKS5里版本号为0x05
  • NMETHODS:支持认证的方法数量
  • METHODS:NMETHODS的值为多少,该字段就有多少个字节,每个字节都表示一种认证方法
    • RFC预定义了一些值的含义,内容如下:
      • X'00'  无需认证
      • X'01' GSSAPI
      • X'02'  用户名/密码
      • X'03' 到 X'7F' IANA 指定
      • X'80' 到 X'FE'  为私有方法保留
      • X'FF'  无可接受方法

代理服务器收到认证报文后,会向客户端返回一个报文,通知所采用的方法标识,随后客户端和代理服务器之间继续按照指定认证方式进行认证

VERMETHOD
11
  • 注意点:当返回的METHOD值为0xFF时,表示所有认证方法都不可用,此次连接中断

代码:

// Auth auth为客户端向代理服务器发起认证,reader为本次数据报文的只读流,conn为本连接
func auth(reader *bufio.Reader, conn net.Conn) error {
   // version只有1个字节
   version, err := reader.ReadByte()
   if err != nil {
      return fmt.Errorf("read version fail: %w", err)
   }

   if version != socks5Ver {
      return fmt.Errorf("not supported sockets version: %v", version)
   }

   // 读取NMETODS字段
   methodNums, err := reader.ReadByte()
   if err != nil {
      return fmt.Errorf("read nmethods fail: %w", err)
   }

   // []byte{1} = 00000001
   // []byte{1, 2} = 00000001 00000010
   // 为method分配一个长度为methodNums的[]byte类型(uint8)的切片
   method := make([]byte, methodNums)
   // 把reader剩下的字节(METHODS字段)复制到method切片中,第一个返回值的复制的字节数。
   _, err = io.ReadFull(reader, method)
   if err != nil {
      return fmt.Errorf("read method failed :%w", err)
   }
   log.Println("version: ", version, "method: ", method)


   // 向客户端返回报文
   // 向conn写入[]byte{1, 0x00}  0x00是指无需认证
   _, err = conn.Write([]byte{socks5Ver, 0x00})
   if err != nil {
      return fmt.Errorf("write failed:%w", err)
   }
   return nil
}

1.2.2 请求

一旦认证方法对应的协商完成,客户端就可以发送请求细节了。如果认证方法为了完整性或者可信性的校验,需要对后续请求报文进行封装,则后续请求报文都要按照对应规定进行封装。

报文格式如下:

VERCMDRSVATYPDST.ADDRDST.PORT
11X'00'1Variable2

字段说明:

  • VER:X'05'
  • CMD:命令
    • CONNECT:连接, X'01'
    • BIND:监听X'02'
    • UDP ASSOCIATE:UDP关联 X'03'
  • RSV:保留字段
  • ATYP:地址类型
    • X'01':表明地址字段为一个 IPV4 地址,长度为 4 个字节
    • X'03':表明地址字段为一个(合法的)域名,且第一个字节为域名长度标识,(显然)其不以 NULL 作为结束标识
    • X'04':表明地址字段为一个 IPV6 地址,长度为 16 个字节
  • DST.ADDR:目标地址
  • DST.PORT目标端口 (网络字节序)

SOCKS 服务端收到请求报文后会根据请求类型和源、目标地址,执行对应操作,并且返回对应的一个或多个报文信息。 回复报文,客户端与服务端建立连接并完成认证之后就会发送请求信息,服务端执行对应请求并返回如下格式的报文:

VERREPRSVATYPBND.ADDRBND.PORT
11X’00’1Variable2

字段说明

  • VER协议版本:X'05'
  • REP 回复字段(回复类型):
    • X'00' 成功
    • X'01' 常规 SOCKS 服务故障
    • X'02' 规则不允许的连接
    • X'03' 网络不可达
    • X'04' 主机无法访问
    • X'05' 拒绝连接
    • X'06' 连接超时
    • X'07' 不支持的命令
    • X'08' 不支持的地址类型
    • X'09' 到 X’FF’ 未定义
  • RSV 保留字段,必须设定为 X‘00’ 。
  • ATYP 地址类型
    • IPV4 X'01'
    • 域名 X'03'
    • IPV6 X'04'
  • BND.ADDR 服务端绑定地址
  • BND.PORT 服务端绑定端口 (网络字节序)

代码:

// Connect 客户端向代理服务器发起向下游服务器的连接请求,reader为本次数据报文的只读流,conn为本连接
func connect(reader *bufio.Reader, conn net.Conn) (net.Conn, error) {
    // 创建一个长度为4个字节的缓冲区
    buf := make([]byte, 4)
    // 直接读取reader对象的前4个字节
    _, err := io.ReadFull(reader, buf)
    if err != nil {
            return fmt.Errorf("read version, cmd, rsv, atype failed: %w", err)
    }
    version, cmd, atype := buf[0], buf[1], buf[3]
    if version != socks5Ver {
            return fmt.Errorf("not supported socket version: %v", version)
    }
    if cmd != cmdConnect {
            return fmt.Errorf("not supported cmd: %v", cmd)
    }

    // 初始化地址
    addr := ""
    switch atype {
    case atypIPV4:
        // ipv4继续读取4个字节
        _, err := io.ReadFull(reader, buf)
        if err != nil {
                return fmt.Errorf("read ipv4 addr failed: %w", err)
        }
        // 格式化字符串
        addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
    case atypeHOST:
        // 域名类型,先读第一个字节获取域名长度l,然后读取l个长度的字节
        hostLength, err := reader.ReadByte()
        if err != nil {
                return fmt.Errorf("read host length failed: %w", err)
        }
        host := make([]byte, hostLength)
        _, err = io.ReadFull(reader, host)
        if err != nil {
                return fmt.Errorf("read host addr failed: %w", err)
        }
        addr = string(host)
    case atypeIPV6:
        // ipv6类型,有16个字节长度
        ipv6Addr := make([]byte, 16)
        _, err = io.ReadFull(reader, ipv6Addr)
        if err != nil {
                return fmt.Errorf("read ipv6 addr failed: %w", err)
        }
        // addr = fmt.Sprintf("%c%c")
    default:
        return fmt.Errorf("not supported addr type: %v", atype)
    }

    // 读取端口 注意只需要传入大小为2的[]byte缓冲区,如果传入缓冲区过大,会因为没读满造成阻塞
    _, err = io.ReadFull(reader, buf[:2])
    if err != nil {
            return fmt.Errorf("read port failed: %w", err)
    }
    // 使用大端解析端口号,只用2个字节
    port := binary.BigEndian.Uint16(buf[:2])

    // 代理服务器与下游服务器(远端)建立tcp连接
    dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
    if err != nil {
            return fmt.Errorf("dial dst failed:%w", err)
    }
    defer dest.Close()
    log.Println("dial", addr, 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)
    }

1.2.3 通信

此时我们已经建立了两个TCP连接,分别是从客户端与代理服务器以及代理服务器与下游服务器之间。 实现通信需要建立两个数据通道,此时检查代码可以发现我们现在拥有三个io对象:

  • 从客户端到代理服务器数据的只读流reader
  • 客户端到代理服务器的tcp连接conn
  • 代理服务器到下游服务器的tcp连接dest

因此我们需要开启从reader到dest,以及从dest到conn的两个单向数据转发,从而实现双向数据转发。这里可以启动两个goroutine来实现,两个goroutine中分别使用标准库的Copy(dst Writer, src Reader) (written int64, err error)函数,该函数的作用是实现将字节流从从src拷贝到dst,直到遇到EOF。

代码:

// connect函数中
// 该代码作用是阻塞程序
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// 两个goroutine作用是双向传输数据
// io.copy(writer, reader)
go func() {
    // 把从客户端接收的数据发送到下游服务器
    _, _ = io.Copy(dest, reader)
    cancel()
}()
go func() {
    // 把下游服务器返回的数据发送到客户端
    _, _ = io.Copy(conn, dest)
    cancel()
}()

// 当上面两个io.Copy出错时会调用cancel,该代码继续执行
// 如果缺少该部分代码,由于goroutine创建的时间很短,该程序会直接结束,中止双方的tcp连接
<-ctx.Done()

1.3 结果展示

客户端终端输入.\curl.exe --socks5 127.0.0.1:1080 -v https://www.baidu.com

curl for Windows

代理终端显示

image.png

客户端终端显示

image.png

可以看到客户端与代理服务器之间建立了连接,代理服务器与下游服务器之间也建立了连接。 最终返回了下游服务器返回HTTP的报文

image.png