写点什么

go 语言实战入门案例之实现 Socks5

作者:timerring
  • 2023-08-03
    甘肃
  • 本文字数:9631 字

    阅读完需:约 32 分钟

文章和代码已经归档至【Github 仓库:https://github.com/timerring/backend-tutorial 】或者公众号【AIShareLab】回复 go 也可获取。

实现 Socks 5 代理

介绍


socks5 协议它虽然是代理协议,但它并不能用来翻,它的协议都是明文传输。这个协议历史比较久远,诞生于互联网早期。它的用途是, 比如某些企业的内网为了确保安全性,有很严格的防火墙策略,但是带来的副作用就是访问某些资源会很麻烦。socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。实际上很多翻软件,最终暴露的也是一个 socks5 协议的端口。例如爬,在爬取过程中很容易会遇到 IP 访问频率超过限制。这个时候很多人就会去网上找一些代理 IP 池,这些代理 IP 池里面的很多代理的协议就是 socks5。



先看一下最终写完的代理服务器的效果。启动程序,然后在浏览器里面配置使用这个代理,此时打开网页。代理服务器的日志,会打印出你访问的网站的域名或者 IP ,这说明我们的网络流量是通过这个代理服务器的。也能在命令行去测试我们的代理服务器。我们可以用 curl -socks5 + 代理服务器地址,后面加一个可访问的 URL,如果代理服务器工作正常的话,那么 curl 命令就会正常返回。

原理


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


如果设置代理服务器之后,流程会变得复杂一些。首先是浏览器和 socks5 代理建立 TCP 连接,代理再和真正的服务器建立 TCP 连接。这里可以分成四个阶段,握手阶段、认证阶段、请求阶段、 relay 阶段


  • 第一个握手阶段,浏览器会向 socks 5 代理发送请求,包的内容包括一个协议的版本号,还有支持的认证的种类,socks 5 服务器会选中一个认证方式,返回给浏览器。如果返回的是 00 的话就代表不需要认证,返回其他类型的话会开始认证流程,这里我们就不对认证流程进行概述了。

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

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

TCP echo server

我们先在 go 里面写一个简单的 TCP echo server。为了方便测试, server 的工作逻辑很简单,你给他发送啥,他就回复啥,代码如下。


package main
import ( "bufio" "log" "net")
func main() { // 首先在 main 函数里面先用 net.listen 去监听一个端口,会返回一个 server server, err := net.Listen("tcp", "127.0.0.1:10803") if err != nil { panic(err) } for { // 然后在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接 client, err := server.Accept() if err != nil { log.Printf("Accept failed %v", err) continue } // 接下来的话我们在一个 process 函数里面去处理这个连接。 // 这前面会有个 go 关键字,这个代表启动一个 goroutinue, 可以暂时类比为其他语言里面的启动一个子线程。只是这里的 goroutinue 的开销会比子线程要小很多,可以很轻松地处理上万的并发。 go process(client) }}// 接下来是这个 process 函数的实现。func process(conn net.Conn) { // 首先第一步的话会先加一个 defer connection.close(), defer 是 Golang 里面的一个语法,这一行的含义就是代表在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露。 defer conn.Close() // 用 bufio.NewReader 来创建一个带缓冲的只读流 reader := bufio.NewReader(conn) for { // 带缓冲的流的作用是,可以减少底层系统调用的次数,比如这里为了方便是一个字节一个字节的读取,但是底层可能合并成几次大的读取操作。并且带缓冲的流会有更多的一些工具函数用来读取数据。 // 可以简单地调用 readbyte 函数来读取单个字节。再把这一个字节写进去连接。 b, err := reader.ReadByte() if err != nil { break } _, err = conn.Write([]byte{b}) if err != nil { break } }}
复制代码


我们来简单测试一下我们的第一个 TCP 服务器,然后测试会需要用到 nc 命令。如果没有的话可以进行安装,这里用 nc 127.0.0.1 10803,输入 timerring 然后服务器就会给你返回 timerring。先运行代码



然后另开一个终端进行测试:


auth

package main
import ( "bufio" "fmt" "io" "log" "net")
const socks5Ver = 0x05const cmdBind = 0x01const atypeIPV4 = 0x01const atypeHOST = 0x03const atypeIPV6 = 0x04
func main() { server, err := net.Listen("tcp", "127.0.0.1:1080") if err != nil { panic(err) } for { client, err := server.Accept() if err != nil { log.Printf("Accept failed %v", err) continue } go process(client) }}
func process(conn net.Conn) { defer conn.Close() reader := bufio.NewReader(conn) // 我们实现一个空的 auth 函数,在 process 函数里面调用,再来编写 auth 函数的代码。 err := auth(reader, conn) if err != nil { log.Printf("client %v auth failed:%v", conn.RemoteAddr(), err) return } log.Println("auth success")}
func auth(reader *bufio.Reader, conn net.Conn) (err error) { // +----+----------+----------+ // |VER | NMETHODS | METHODS | // +----+----------+----------+ // | 1 | 1 | 1 to 255 | // +----+----------+----------+ // VER: 协议版本,socks5为0x05 // NMETHODS: 支持认证的方法数量 // METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下: // X’00’ NO AUTHENTICATION REQUIRED // X’02’ USERNAME/PASSWORD
// 先用 read bytes 来把版本号读出来 ver, err := reader.ReadByte() if err != nil { return fmt.Errorf("read ver failed:%w", err) } // 然后如果版本号不是 socket 5 的话直接返回报错 if ver != socks5Ver { return fmt.Errorf("not supported ver:%v", ver) } // 接下来我们再读取 method size ,也是一个字节。 methodSize, err := reader.ReadByte() if err != nil { return fmt.Errorf("read methodSize failed:%w", err) } // 然后需要我们去 make 一个相应长度的一个 slice ,用 io.ReadFull 把它去填充进去。 method := make([]byte, methodSize) _, err = io.ReadFull(reader, method) if err != nil { return fmt.Errorf("read method failed:%w", err) } // 写到这里,我们把获取到的版本号和认证方式打印一下。 log.Println("ver", ver, "method", method) // +----+--------+ // |VER | METHOD | // +----+--------+ // | 1 | 1 | // +----+--------+ // 此时,代理服务器还需要返回一个response, 返回包包括 两个字段,一个是 version 一个是 method,也就是我们选中的鉴传方式,我们当前只准备实现不需要鉴传的方式,也就是00。 _, err = conn.Write([]byte{socks5Ver, 0x00}) if err != nil { return fmt.Errorf("write failed:%w", err) } return nil}
复制代码


我们回忆一下认证阶段的逻辑,首先第一步的话,浏览器会给代理服务器发送一个包,然后这个包有三个字段,


  • 第一个字段, version 也就是协议版本号,固定是 5

  • 第二个字段 methods,认证的方法数目

  • 第三个字段每个 method 的编码, 0 代表不需要认证, 2 代表用户名密码认证我们用 curl 命令测试一下当前版本的效果。此时 curl 命令肯定是不成功的,因为我们的协议还没实现完成。



但是我们看日志会发现, version 和 method 可以正常打印,说明当前我们的实现是正确的。


请求阶段

接下来我们开始做第三步,实现请求阶段,我们试图读取到携带 URL 或者 IP 地址+端口的包,然后把它打印出来。


package main
import ( "bufio" "encoding/binary" "errors" "fmt" "io" "log" "net")
const socks5Ver = 0x05const cmdBind = 0x01const atypeIPV4 = 0x01const atypeHOST = 0x03const atypeIPV6 = 0x04
func main() { server, err := net.Listen("tcp", "127.0.0.1:1080") if err != nil { panic(err) } for { client, err := server.Accept() if err != nil { log.Printf("Accept failed %v", err) continue } 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 failed:%v", conn.RemoteAddr(), err) return } // 我们实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。再来实现 connect 函数的代码。 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) { // +----+----------+----------+ // |VER | NMETHODS | METHODS | // +----+----------+----------+ // | 1 | 1 | 1 to 255 | // +----+----------+----------+ // VER: 协议版本,socks5为0x05 // NMETHODS: 支持认证的方法数量 // METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下: // X’00’ NO AUTHENTICATION REQUIRED // X’02’ USERNAME/PASSWORD
ver, err := reader.ReadByte() if err != nil { return fmt.Errorf("read ver failed:%w", err) } if ver != socks5Ver { return fmt.Errorf("not supported ver:%v", ver) } methodSize, err := reader.ReadByte() if err != nil { return fmt.Errorf("read methodSize failed:%w", err) } method := make([]byte, methodSize) _, err = io.ReadFull(reader, method) if err != nil { return fmt.Errorf("read method failed:%w", err) }
// +----+--------+ // |VER | METHOD | // +----+--------+ // | 1 | 1 | // +----+--------+ _, err = conn.Write([]byte{socks5Ver, 0x00}) if err != nil { return fmt.Errorf("write failed:%w", err) } return nil}
func connect(reader *bufio.Reader, conn net.Conn) (err error) { // 我们来回忆一下请求阶段的逻辑。浏览器会发送一个包,包里面包含如下6个字段 // +----+-----+-------+------+----------+----------+ // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | // +----+-----+-------+------+----------+----------+ // | 1 | 1 | X'00' | 1 | Variable | 2 | // +----+-----+-------+------+----------+----------+ // VER 版本号,socks5的值为0x05。version 版本号, 还是 5 // CMD 0x01表示CONNECT请求。CMD 代表请求的类型,我们只支持 connection 请求,也就是让代理服务建立新的TCP连接。 // RSV 保留字段,值为0x00,不理会。 // ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。可能是 IPV4 IPV6 或者域名。 // 0x01表示IPv4地址,DST.ADDR为4个字节 // 0x03表示域名,DST.ADDR是一个可变长度的域名 // DST.ADDR 一个可变长度的值,这个地址的长度是根据 atype 的类型而不同的,port 端口号,两个字节, 我们需要逐个去读取这些字段。 // DST.PORT 目标端口,固定2个字节
// 后面这四个字段总共四个字节,我们可以一次性把它读出来。我们定义一个长度为 4 的 buffer 然后把它读满。 buf := make([]byte, 4) _, err = io.ReadFull(reader, buf) if err != nil { return fmt.Errorf("read header failed:%w", err) } // 读满之后,然后第0 个、第1个、第3个、分别是 version cmd 和 type ver, cmd, atyp := buf[0], buf[1], buf[3] // version 需要判断是 socket 5 if ver != socks5Ver { return fmt.Errorf("not supported ver:%v", ver) } // cmd 需要判断是 1,这里cmdBind是在前面定义好的 if cmd != cmdBind { return fmt.Errorf("not supported cmd:%v", cmd) } // 下面的 atype,可能是 ipv4 ,ipv6,或者是 host。 addr := "" switch atyp { // 如果 IPV4 的话,我们再次读满这个 buffer,因为这个 buffer 长度刚好也是4个字节 case atypeIPV4: _, err = io.ReadFull(reader, buf) if err != nil { return fmt.Errorf("read atyp failed:%w", err) } // 然后逐个字节打印成 IP 地址的格式保存到 addr 变量。 addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3]) // 如果是个 host 的话 case atypeHOST: // 需要先读它的长度 hostSize, err := reader.ReadByte() if err != nil { return fmt.Errorf("read hostSize failed:%w", err) } // 再 make 一个相应长度的buf 填充它。 host := make([]byte, hostSize) _, err = io.ReadFull(reader, host) if err != nil { return fmt.Errorf("read host failed:%w", err) } // 再转换成字符串保存到 addr 变量。 addr = string(host) // IPV6 用得比较少,我们就暂时先不支持。 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 ,我们读取它,然后按协议规定的大端字节序转换成数字。 // 由于上面的 buffer 已经不会被其他变量使用了,我们可以直接复用之前的内存,建立一个临时的 slice ,长度是2用于读取,这样的话最多会只读两个字节回来。 port := binary.BigEndian.Uint16(buf[:2]) // 接下来我们把这个地址和端口打印出来用于调试。 log.Println("dial", addr, port) // 收到浏览器的这个请求包之后,我们需要返回一个包,这个包有很多字段,但其实大部分都不会使用。
// +----+-----+-------+------+----------+----------+ // |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 地址类型,第四个 atype 地址类型填 1。 // BND.ADDR 服务绑定的地址,第五个,第六个暂时用不到,都填成 0。 // BND.PORT 服务绑定的端口DST.PORT
// 一共 4 + 4 + 2 个字节,后面6个字节都是 0 填充。 _, 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 一下。




此时请求还是会失败,我们现在已经能看到正常打印出来访问的 IP 地址和端口,这说明我们当前的实现正常,这样我们就可以做最后一步,我们真正和这个端口建立连接,双向转发数据。



我们直接用 net.dial 建立一个 TCP 连接,建立完连接之后,我们同样要加一个 defer 来关闭连接。接下来需要建立浏览器和下游服务器的双向数据转发。


标准库的 io.copy 可以实现一个单向数据转发,双向转发的话,需要启动两个 goroutinue。



现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向 copy 出错的时候,再返回 connect 函数。这里可以使用到标准库里面的一个 context 机制,用 context 连 with cancel 来创建一个 context。



在最后等待 ctx.Done() ,只要 cancel 被调用, ctx.Done 就会立刻返回。然后在上面的两个 goroutinue 里面调用一次 cancel 即可。

完整代码

package main
import ( "bufio" "context" "encoding/binary" "errors" "fmt" "io" "log" "net")
const socks5Ver = 0x05const cmdBind = 0x01const atypeIPV4 = 0x01const atypeHOST = 0x03const atypeIPV6 = 0x04
func main() { server, err := net.Listen("tcp", "127.0.0.1:1080") if err != nil { panic(err) } for { client, err := server.Accept() if err != nil { log.Printf("Accept failed %v", err) continue } 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 failed:%v", conn.RemoteAddr(), err) return } 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) { // +----+----------+----------+ // |VER | NMETHODS | METHODS | // +----+----------+----------+ // | 1 | 1 | 1 to 255 | // +----+----------+----------+ // VER: 协议版本,socks5为0x05 // NMETHODS: 支持认证的方法数量 // METHODS: 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。RFC预定义了一些值的含义,内容如下: // X’00’ NO AUTHENTICATION REQUIRED // X’02’ USERNAME/PASSWORD
ver, err := reader.ReadByte() if err != nil { return fmt.Errorf("read ver failed:%w", err) } if ver != socks5Ver { return fmt.Errorf("not supported ver:%v", ver) } methodSize, err := reader.ReadByte() if err != nil { return fmt.Errorf("read methodSize failed:%w", err) } method := make([]byte, methodSize) _, err = io.ReadFull(reader, method) if err != nil { return fmt.Errorf("read method failed:%w", err) }
// +----+--------+ // |VER | METHOD | // +----+--------+ // | 1 | 1 | // +----+--------+ _, err = conn.Write([]byte{socks5Ver, 0x00}) if err != nil { return fmt.Errorf("write failed:%w", err) } return nil}
func connect(reader *bufio.Reader, conn net.Conn) (err error) { // +----+-----+-------+------+----------+----------+ // |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个字节
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", cmd) } addr := "" switch atyp { case atypeIPV4: _, err = io.ReadFull(reader, buf) if err != nil { return fmt.Errorf("read atyp failed:%w", err) } addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3]) case atypeHOST: 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])
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)
// +----+-----+-------+------+----------+----------+ // |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 _, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) if err != nil { return fmt.Errorf("write failed: %w", err) } // 现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向copy出错的时候,再返回 connect 函数。 // 可以使用到标准库里面的一个 context 机制,用 context 连 with cancel 来创建一个context。 ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 然后在两个 goroutinue 里面 调用一次 cancel 即可。 go func() { _, _ = io.Copy(dest, reader) cancel() }() go func() { _, _ = io.Copy(conn, dest) cancel() }() // 在最后等待 ctx.Done() , 只要 cancel 被调用, ctx.Done就会立刻返回。 <-ctx.Done() return nil}
复制代码


我们可以试着在浏览器里面再测试一下,在浏览器里面测试代理需要安装这个 switchomega 插件,然后里面新建一个情景模式,代理服务器选 socks 5,端口 1080 ,保存并启用。此时你应该还能够正常地访问网站,代理服务器这边会显示出浏览器版本的域名和端口。



学习路线推荐


参考:字节内部课 Go 语言原理与实践

发布于: 刚刚阅读数: 4
用户头像

timerring

关注

公众号【AIShareLab】 2022-07-14 加入

他日若遂凌云志

评论

发布
暂无评论
go 语言实战入门案例之实现Socks5_Go_timerring_InfoQ写作社区