Go实践案例-SOCKS5代理 | 青训营笔记

115 阅读10分钟

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

此代理服务器并不能用来FQ,协议都是明文传输的

用途是某些企业的内网为了确保安全性,配置了很严格的防火墙

但副作用是,某些管理员为了访问内部资源会很麻烦

socks5相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。

实际上很多翻墙软件,最终暴露的也是一个socks5协议的端口。 如果有同学开发过爬虫的话,就知道,在爬取过程中很容易会遇到P访问频率超过限制。这个时候很多人就会去网上找一些代理IP池,这些代理IP池里面的很多代理的协议就是socks5。

原理

image.png

接下来我们来了解一下 socks5协议的工作原理。

正常浏览器访问一个网站,如果不经过代理服务器的话,就是先和对方的网站建立TCP连接,然后三次握手,握手完之后发起HTTP请求,然后服务返回HTTP响应

如果设置代理服务器之后,流程会变得复杂一些。 首先是浏览器和socks5代理建立TCP连接,代理再和真正的服务器建立TCP连接

这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、relay 阶段。

  1. 第一个握手阶段,浏览器会向socks5代理发送请求报文,包的内容包括一个协议的版本号,还有支持的认证的种类(密码或不需要认证),socks5 服务器会选中一个认证方式,返回给浏览器。

    如果返回的是00的话就代表不需要认证,返回其他类型的话会开始认证流程,这里我们就不对认证流程进行概述了。

  2. 第二阶段(若有)即为认证阶段

  3. 第三个阶段是请求阶段,认证通过之后浏览器会socks5服务器发起请求报文。主要信息包括版本号,请求的类型,一般主要是 connection请求,就代表代理服务器要和某个域名或者某个IP地址某个端口建立TCP连接。代理服务器收到响应之后,会真正和后端服务器建立连接,然后返回一个响应。

  4. 第四个阶段是relay阶段。此时浏览器会发送正常发送请求,然后代理服务器接收到请求之后,会直接把请求转换到真正的服务器上。然后如果真正的服务器以后返回响应的话,那么也会把请求转发到浏览器这边。然后实际上代理服务器并不 关心流量的细节,可以是HTTP流量,也可以是其它TCP流量。

这个就是 socks5协议的工作原理,接下来我们就会试图去简单地实现它。

v1:TCP echo server

一个发送什么,回复什么的服务端

package main
​
import (
    "bufio"
    "fmt"
    "log"
    "net"
)
​
/*
*
TCP Echo Server
*/
func main() {
    fmt.Println("开始监听")
    //侦听端口,返回server
    server, err := net.Listen("tcp", "127.0.0.1:3456")
    if err != nil {
        panic(err)
    }
    for {
        //接受请求,成功返回连接
        client, err := server.Accept()
        if err != nil {
            log.Printf("Accept Failed %v", err)
            continue
        }
        //在process函数中处理连接
        //通过go关键字启动一个协程(Goroutine)
        go process(client)
    }
}
​
func process(conn net.Conn) {
    //  关闭连接
    defer conn.Close()
    //基于当前连接创建一个带缓冲的流
    reader := bufio.NewReader(conn)
    for {
        //循环从数据流中,读一个字节
        b, err := reader.ReadByte()
        if err != nil {
            break
        }
        //将读入的字节,写入Response返回客户端
        _, err = conn.Write([]byte{b})
        if err != nil {
            break
        }
​
    }
​
}
​

使用netcat测试一下

nc 127.0.0.1 3456

image.png

输入内容,回车,会输出一行输入的内容

v2:认证阶段-auth

首先第一步的话,浏览器会给代理服务器发送一个包,然后这个包有三个字段,

  • VER 字段表征 Socks 协议版本, 占 1 字节, 对于 Socks 5 其值固定为 0x05
  • NMETHODS 字段指示其后的 METHOD 字段所占的字节数, 其本身占 1 字节
  • METHODS 字段为可变长字段, 用来指示客户端和代理服务器之间的认证方法, 其长度区间为 [1, 255] 个字节, 即客户端在向代理服务器发起握手时同时声明其所支持的认证方法的列表, 代理服务器会从中选择一个方法作为接下来与客户端进行认证的方法, 所以对于 Socks 5 协议来说, 客户端发起的握手实际上本身也是启动了一个协商过程
  • 第一个字段 Version 也就是 协议版本号 ,固定是 5 (socks5协议固定)
  • 第二个字段 Methods, 认证的方法数目
  • 第三个字段 每个 method的编码, 0代表 不需要认证, 2 代表用户名密码认证

image.png

最后,代理服务器还需要返回一个response, 返回包包括 两个字段,一个是 version 一个是 method,也就是我们选中的鉴传方式,

image.png

新增auth函数

package main
​
import (
   "bufio"
   "fmt"
   "io"
   "log"
   "net"
)
​
//全局变量
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04func main() {
   fmt.Println("开始监听")
   //侦听端口,返回server
   server, err := net.Listen("tcp", "127.0.0.1:3456")
   if err != nil {
      panic(err)
   }
   for {
      //接受请求,成功返回连接
      client, err := server.Accept()
      if err != nil {
         log.Printf("Accept Failed %v", err)
         continue
      }
      //在process函数中处理连接
      //通过go关键字启动一个协程(Goroutine)
      go process(client)
   }
}
​
func process(conn net.Conn) {
   // 关闭连接
   defer conn.Close()
   //基于当前连接创建一个带缓冲的流
   reader := bufio.NewReader(conn)
   err := auth(reader, conn)
   if err != nil {
      log.Printf("Client %v auth faild:%v", conn.RemoteAddr(), err)
      return
   }
   log.Printf("auth access!")
}
​
/**
鉴权函数
*/
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
   //第一个字段是协议版本号Version
   //第二个字段是鉴权方式的数目Methods
   //第三个字段是每个鉴权方式的编码
​
   //读出版本号(1字节)
   ver, err := reader.ReadByte()
   if err != nil {
      return fmt.Errorf("read version failed:%v", err)
   }
   if ver != socks5Ver {
      return fmt.Errorf("Not surport this version:%v", err)
   }
​
   //读取MethodsSize(1字节)
   methodSize, err := reader.ReadByte()
   if err != nil {
      return fmt.Errorf("read MethodSize failed:%v", err)
   }
​
   //建立缓冲区Slice,存放指定methodSize的数据
   method := make([]byte, methodSize)
   //填充数据到Slice
   _, err = io.ReadFull(reader, method)
   if err != nil {
      return fmt.Errorf("read method failed:%v")
   }
   fmt.Println("ver:", ver, "method:", method)
   
   //返回数据包(告诉客户端选择哪种鉴权方式)
   _, err = conn.Write([]byte{socks5Ver, 0x00})
   if err != nil {
      return fmt.Errorf("write filed:%v", err)
   }
   return nil
}
.\curl.exe --socks5 127.0.0.1:3456 -v http://www.qq.com

image.png

通过控制台可以看到服务端输出了读取到的Version和method两个字段,通过了并且客户端的认证请求

v3:请求阶段

第三阶段实现请求阶段。

我们试图读取到报文。

携带需要访问的 URL 或者 IP 地址+端口的包,

+----+-----+-------+------+----------+----------+
|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个字节

请求阶段的逻辑:

浏览器会发送一个包,包里面包含如下6个字段,

  • version :版本号, 还是 5。

  • command :代表请求的类型,我们只支持 connection 请求,也就是让代理服务与请求的服务器建立新的TCP连接。

  • RSV :保留字段,一般是0,可有忽略掉。

  • atype :就是目标地址类型,可能是 IPV 4 IPV 6 或者域名 下面是域名, 这个地址的长度是根据 atype 的类型而不同的

    • 1代表IPv4,DST.ADDR即为固定长度为4个字节
    • 3代表域名,DST.ADDR为变长的字符串,第一个字节为长度,后面的n个字节为真正的域名
  • DST.PORT:端口号,两个字节

读入数据之后,还要返回一个数据包,这个包有很多字段,但其实大部分都不会使用。

+----+-----+-------+------+----------+----------+
|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

第一个是版本号还是 socket 5。

第二个,就是返回的类型,这里是成功就返回0

第三个是保留字段 填 0

第四个 atype 地址类型 填 1

第五个,第六个暂时用不到,都填成 0。

一共 4 + 4 + 2 个字节,后面6个字节都是 0 填充。

package main
​
import (
    "bufio"
    "encoding/binary"
    "errors"
    "fmt"
    "io"
    "log"
    "net"
)
​
// 全局变量
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04// 带鉴权的socks5 proxy server,获取报文并打印出来
func main() {
    fmt.Println("开始监听")
    //侦听端口,返回server
    server, err := net.Listen("tcp", "127.0.0.1:3456")
    if err != nil {
        panic(err)
    }
    for {
        //接受请求,成功返回连接
        client, err := server.Accept()
        if err != nil {
            log.Printf("Accept Failed %v", err)
            continue
        }
        //在process函数中处理连接
        //通过go关键字启动一个协程(Goroutine)
        go process2(client)
    }
}
​
func process2(conn net.Conn) {
    //  关闭连接
    defer conn.Close()
    //基于当前连接创建一个带缓冲的流
    reader := bufio.NewReader(conn)
    //进行鉴权
    err := auth(reader, conn)
    if err != nil {
        log.Printf("Client %v auth faild:%v", conn.RemoteAddr(), err)
        return
    }
    log.Printf("auth access!")
    err = connect(reader, conn)
    if err != nil {
        log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
        return
    }
}
​
/*
*
鉴权函数
*/
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
    //第一个字段是协议版本号Version
    //第二个字段是鉴权方式的数目Methods
    //第三个字段是每个鉴权方式的编码//读出版本号(1字节)
    ver, err := reader.ReadByte()
    if err != nil {
        return fmt.Errorf("read version failed:%v", err)
    }
    if ver != socks5Ver {
        return fmt.Errorf("Not surport this version:%v", err)
    }
​
    //读取MethodsSize(1字节)
    methodSize, err := reader.ReadByte()
    if err != nil {
        return fmt.Errorf("read MethodSize failed:%v", err)
    }
​
    //建立缓冲区Slice,存放指定methodSize的数据
    method := make([]byte, methodSize)
    //填充数据到Slice
    _, err = io.ReadFull(reader, method)
    if err != nil {
        return fmt.Errorf("read method failed:%v")
    }
    fmt.Println("ver:", ver, "method:", method)
​
    //返回数据包(告诉客户端选择哪种鉴权方式)
    //第一个字节是协议版本号,第二个参数是选择的鉴权方式
    _, err = conn.Write([]byte{socks5Ver, 0x00})
    if err != nil {
        return fmt.Errorf("write filed:%v", err)
    }
    return nil
}
​
// 获取报文并打印
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
    //读取报文
    //创建长度为4的缓冲区
    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:%v", ver)
    }
    if cmd != cmdBind {
        return fmt.Errorf("not supported cmd:%v", ver)
    }
    addr := ""
    //判断atyp类型
    switch atyp {
    case atypIPV4:
        //直接将IP地址,填充入缓冲区
        _, err = io.ReadFull(reader, buf)
        if err != nil {
            return fmt.Errorf("read atyp failed:%w", err)
        }
        //打印IP地址
        addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
    case atypeHOST:
        //读取1字节的长度
        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)
​
    _, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
    if err != nil {
        return fmt.Errorf("write failed: %w", err)
    }
    return nil
}
​

通过命令使用curl指定ipv4,通过-v显示连接过程

.\curl.exe --socks5 127.0.0.1:3456 -4 http://www.qq.com -v

image.png

控制台可以正常打印出需要访问的Ip和端口,说明实现成功

v4:realy阶段

通过此阶段,代理服务器就算成功了。

先与真正的服务器建立TCP连接

//建立TCP连接
    dest, err := net.Dial("tcp"fmt.Sprintf("%v:%v",addr,port))
    if err != nil{
        return fmt.Errorf("dial dst failed:%v",err)
    }
    //没有出错关闭连接
    defer dest.Close()

再建立浏览器与下游服务器的双向数据转发

在标准库中找到io.Copy函数,实现

func Copy(dst Writer, src Reader) (written int64, err error)

从src中读取数据并写入dst

要实现双向数据转发,需要启动两个go routine分别调用Copy

//实现数据双向转发
    //使用context机制
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() //防御式编程
    go func() {
        //从Client浏览器拷贝到服务器
        _, _ = io.Copy(dest, reader)
        cancel()
    }()
    go func() {
        //从服务器拷贝数据到Client浏览器
        _, _ = io.Copy(conn, dest)
        cancel()
    }()
    //等待context执行完成(即cancel被调用),实现任意一方Copy失败,关闭双方连接,关闭数据
    <-ctx.Done()

浅浅访问一下百度

.\curl.exe --socks5 127.0.0.1:3456 -4 http://www.baidu.com -v

image.png

完整代码

package main
​
import (
    "bufio"
    "context"
    "encoding/binary"
    "errors"
    "fmt"
    "io"
    "log"
    "net"
)
​
// 全局变量
const socks5Ver = 0x05
const cmdBind = 0x01
const atypIPV4 = 0x01
const atypeHOST = 0x03
const atypeIPV6 = 0x04// 带鉴权的socks5 proxy server
func main() {
    fmt.Println("开始监听")
    //侦听端口,返回server
    server, err := net.Listen("tcp", "127.0.0.1:3456")
    if err != nil {
        panic(err)
    }
    for {
        //接受请求,成功返回连接
        client, err := server.Accept()
        if err != nil {
            log.Printf("Accept Failed %v", err)
            continue
        }
        //在process函数中处理连接
        //通过go关键字启动一个协程(Goroutine)
        go process2(client)
    }
}
​
func process2(conn net.Conn) {
    //  关闭连接
    defer conn.Close()
    //基于当前连接创建一个带缓冲的流
    reader := bufio.NewReader(conn)
    //进行鉴权
    err := auth(reader, conn)
    if err != nil {
        log.Printf("Client %v auth faild:%v", conn.RemoteAddr(), err)
        return
    }
    log.Printf("auth access!")
    err = connect(reader, conn)
    if err != nil {
        log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err)
        return
    }
}
​
/*
*
鉴权函数
*/
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
    //第一个字段是协议版本号Version
    //第二个字段是鉴权方式的数目Methods
    //第三个字段是每个鉴权方式的编码//读出版本号(1字节)
    ver, err := reader.ReadByte()
    if err != nil {
        return fmt.Errorf("read version failed:%v", err)
    }
    if ver != socks5Ver {
        return fmt.Errorf("Not surport this version:%v", err)
    }
​
    //读取MethodsSize(1字节)
    methodSize, err := reader.ReadByte()
    if err != nil {
        return fmt.Errorf("read MethodSize failed:%v", err)
    }
​
    //建立缓冲区Slice,存放指定methodSize的数据
    method := make([]byte, methodSize)
    //填充数据到Slice
    _, err = io.ReadFull(reader, method)
    if err != nil {
        return fmt.Errorf("read method failed:%v")
    }
    fmt.Println("ver:", ver, "method:", method)
​
    //返回数据包(告诉客户端选择哪种鉴权方式)
    //第一个字节是协议版本号,第二个参数是选择的鉴权方式
    _, err = conn.Write([]byte{socks5Ver, 0x00})
    if err != nil {
        return fmt.Errorf("write filed:%v", err)
    }
    return nil
}
​
// 获取报文并打印
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
    //读取报文
    //创建长度为4的缓冲区
    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:%v", ver)
    }
    if cmd != cmdBind {
        return fmt.Errorf("not supported cmd:%v", ver)
    }
    addr := ""
    //判断atyp类型
    switch atyp {
    case atypIPV4:
        //直接将IP地址,填充入缓冲区
        _, err = io.ReadFull(reader, buf)
        if err != nil {
            return fmt.Errorf("read atyp failed:%w", err)
        }
        //打印IP地址
        addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
    case atypeHOST:
        //读取1字节的长度
        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])
​
    //建立TCP连接
    dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
    if err != nil {
        return fmt.Errorf("dial dst failed:%v", 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)
    }
    //实现数据双向转发
    //使用context机制
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() //防御式编程
    go func() {
        //从Client浏览器拷贝到服务器
        _, _ = io.Copy(dest, reader)
        cancel()
    }()
    go func() {
        //从服务器拷贝数据到Client浏览器
        _, _ = io.Copy(conn, dest)
        cancel()
    }()
    //等待context执行完成(即cancel被调用),实现任意一方Copy失败,关闭双方连接,关闭数据
    <-ctx.Done()
​
    return nil
}
​

同时也可以使用浏览器插件

SwitchyOmega

个人认为…那是欧米伽Ω,不是哦买噶(lll¬ω¬)

浏览器打开新的页面都会经过代理服务器

服务器控制台就会输出访问的域名+端口