socks5代理服务器实现详解 | 青训营笔记

1,739 阅读6分钟

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

从零实现一个socks5代理服务器详解,主要分为以下四个部分:socks5协议简要介绍、它的工作原理、sock5代理服务器的实现流程、以及windows测试方式。

socks5协议的工作原理为最重要的部分,剩下的则是一些实现细节。首先对比一下,使用socks5代理服务器后,与客户端和远程服务器端直接连接的区别: sock5协议.png

1. socks协议介绍

socks5是一种网络传输协议, 它在使用TCP/IP协议通讯的客户端和服务器之间扮演一个中介角色,根据OSI七层模型来划分,属于会话层协议,位于表示层与传输层之间。

socks5协议虽然是代理协议,但是不能用来翻墙,它的协议都是明文传输的,历史久远。 企业内网确保安全性,配置了严格的防火墙策略,但带来的副作用就是,访问某些资源哪怕是管理员也会很麻烦,socks5协议相当于在防护墙开了个口子,让用户可以通过单个端口(1080)访问内部所有资源。

很多翻墙软件最终暴露的也会是一个socks5协议的端口给浏览器什么的使用,爬虫IP访问频率超过限制然后报错,代理IP池里面的很多的代理协议就是socks5协议,流量也是通过这个走的。

2. socks5协议工作原理

关于sock5协议原理更详细的说明见下图: 1674897273(1).png

  1. 认证阶段

浏览器(客户端)发送auth request报文给代理服务器, 代理服务器接收后返回auth reply报文给浏览器, 协商认证方式并完成认证。

  • (socks5代理服务器端)接收报文:
    浏览器(客户端)给socks5代理服务器发送一个报文,协商认证方式,报文内容包括代理版本和认证的方式。接收到的报文结构如下:
    // +----+----------+----------+
    // |VER  | NMETHODS | METHODS  |
    // +-----+----------+----------+
    // |1byte|    1     | 1 to 255 |
    // +----+----------+----------+
    // VER: 协议版本,socks5为0x05,固定为5
    // NMETHODS: 支持认证的方法数量
    // METHODS: 每个方法(method)的编码 
      // 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。
      // RFC预定义了一些值的含义,内容如下:
      // X’00’ NO AUTHENTICATION REQUIRED  0不需要认证
      // X’02’ USERNAME/PASSWORD           2用户名密码认证
  • (socks5代理服务器端)发送报文:
    代理服务器接收到报文后选择一种认证方式并发送给浏览器。发送报文结构如下:
   // +----+--------+
  // |VER | METHOD |
  // +----+--------+
  // | 1  |   1    |
  // +----+--------+
  1. 请求阶段

认证方法对应的协商完成后,客户端就可以开始请求发送细节了。

  • (socks5代理服务器端)接收报文:
    读取浏览器发送的报文,里面携带了用户(客户端)需要访问的URL或是IP地址+端口。详细的报文架构如下
      // +----+-----+-------+------+----------+----------+
      // |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
      // +----+-----+-------+------+----------+----------+
      // | 1  |  1  | X'00' |  1   | Variable |    2     |
      // +----+-----+-------+------+----------+----------+
      // VER 版本号,socks5的值为0x05
      // CMD 0x01表示CONNECT请求 : 代表请求类型
      //   只支持connection请求,也就是让代理服务器建立新TCP链接
      // RSV 保留字段,值为0x00
      // ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。
      //   0x01表示IPv4地址,DST.ADDR为4个字节
      //   0x03表示域名,DST.ADDR是一个可变长度的域名
      // DST.ADDR 一个可变长度的值,与ATYP类型有关
      // DST.PORT 目标端口,固定2个字节 
  • (socks5代理服务器端)发送报文:
    socks服务器端会根据请求类型和源、目标地址,执行对应操作并返回一个或多个报文
  // +----+-----+-------+------+----------+----------+
  // |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
  // +----+-----+-------+------+----------+----------+
  // | 1  |  1  | X'00' |  1   | Variable |    2     |
  // +----+-----+-------+------+----------+----------+
  // VER socks版本,这里为0x05 // socket 5
  // REP Relay field,内容取值如下 X’00’ succeeded 返回类型 0成功
  // RSV 保留字段 (填0)
  // ATYPE 地址类型 填1(IPV4) 1字节
  // BND.ADDR 服务绑定的地址 (暂时用不到填0)(IPV4--> 4字节)
  // BND.PORT 服务绑定的端口DST.PORT (用不到填0)
  // 一共1+1+1+1+4+2 = 10字节  字节切片填10个元素就OK
  1. 通信阶段

当链接建立后,客户端就可以和正常一样访问远程服务端通信了,此时通信的数据除了目的地址是代理服务器外,其余所有内容和普通链接一模一样。
对于代理程序而言,后端收到的所有来自客户端的数据都会原样转发给远程服务器端。

3. 实现流程

在代理服务器端,代码需要完成功能如下所示:

main() ---------------------------------process()

graph TD
监听端口-->接收请求-->处理链接_process函数-->END

协商认证_auth函数--OK-->代理请求_connect函数--OK-->建立双向数据转发_connect函数--任意一方转发失败-->END1
  • main()具体实现细节如下:
    1. 启动子协程处理链接,在goland里面开销会小很多,可以轻松处理上万并发
     func main() {
         // 侦听端口,返回server
         server, err := net.Listen("tcp", "127.0.0.1:1080") 
         if err != nil {
                panic(err)
          }
         // 死循环接收请求,成功返回链接,启动子协程pocess处理
         for { 
              client, err := server.Accept()
              if err != nil {
                   log.Printf("Accept failed %v", err)
                   continue
              }
              go process(client) // go+函数:子协程下进行函数
         }
      }
    
  • process()具体实现细节如下:
     func process(conn net.Conn) {
          defer conn.Close()  // 手动关掉链接(函数结束时关闭),避免资源泄漏
          // 基于该链接创建一个只读的待缓存流,作用是减少底层系统调用次数
          reader := bufio.NewReader(conn) 
          // 认证阶段
          err := auth(reader, conn)
          if err != nil {
              log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
              return
          }
          // 请求阶段 + 建立双向稳定链接
          err = connect(reader, conn)
          if err != nil {
              log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
              return
          }
     }
    
  • auth()和connect()实现细节如下:
    1. 协商认证和代理请求:接收报文并回复报文 (代码需要接收读取来自浏览器的报文,并生成回复报文,具体代码见完整代码。)

    2. 建立双向数据转发

      • 和远程服务器进行TCP连接:
        收到代理请求报文后,通过addr地址和端口port信息用net.dial建立一个TCP链接
      • 怎么实现双向数据转发:
        通过单向实现双向,启动两个相反方向数据转发的子协程goroutine
      • 怎么实现单向数据转发:
        io.copy(dst Writer, src Reader),会把src这个只读流里面的数据用一个死循环逐步拷贝到dst这个可写流里面
      • 存在问题:
        子协程不耗时间,函数会直接跑完,链接关闭。而我们想让链接一直存在,除非任意一个方向copy出错再结束链接
      • 解决问题:
        标准库下的context机制:context.withCancel
        此时函数不会立即返回,会在最后等待ctx.Done()
        cancel被调用-->ctx.Done()立刻返回
      // 1. 和远程服务器进行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)
      // 2. 通过启动两个单向数据转发子协程实现双向转发转发 
       ctx, cancel := context.WithCancel(context.Background())
       defer cancel()
       go func() {
      	_, _ = io.Copy(dest, reader) // 单向数据转发(谁发给谁)
        	cancel()
       }()
       go func() {
      	_, _ = io.Copy(conn, dest)
      	cancel()
       }()
       <-ctx.Done()
       return nil
      

4. windows下测试

  • 使用curl
    1. 运行程序: go run ../v4/main.go
    2. cmd输入:curl --socks5 127.0.0.1:1080 -v 网址 ,请求正常说明代理工作正常
  • 使用浏览器(edge,chrome)插件switchyOmega
    1. 扩展里面安装switchyOmega
    2. 运行程序
    3. 打开-->新建情景模式-->
      设置代理协议:socks5, 代理服务器:127.0.0.1, 代理端口:1080
      -->保存(应用选项)退出
      -->点击扩展中switchyOmege选择刚刚设置的情景模式 -->点开任意网页测试
  • 代理设置
    1. 运行程序
    2. 开始-->设置-->网络和Internet-->手动设置代理打开-输入对应服务器和端口
    3. 打开任意网页测试

参考文献

[1] Socks5工作原理与搭建_東魔的博客-CSDN博客_socks5
[2] socks5代理工作流程和原理 - 马谦的博客 (dyxmq.cn)
[3] Go 语言的实战案例 - 掘金 (juejin.cn)