socks5代理服务器项目实践 | 青训营笔记

139 阅读5分钟

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

写好一个代理服务器,先要了解它的运行过程和逻辑,简单来说,就是在我们本身和浏览器端(客户端)进行连接的过程中插入了一个中间商。

proxy演示图.png

用到的常量设置

const (
   socks5ver = 0x05
   cmdBind   = 0x01
   atypeIPV4 = 0x01
   atypeHOST = 0x03
   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)
   }
}

goroutine函数

func process(conn net.Conn) (err error) {
   defer conn.Close()
   reader := bufio.NewReader(conn)
   err = auth(reader, conn)
   if err != nil {
      println("auth_err", err)
      return err
   }
   err = connect(reader, conn)
   if err != nil {
      println("connect错误", err)
      return err
   }
   return nil
}

我们主要需要两个函数来实现代理服务器的功能,这两个函数分别对应了代理服务器的两个过程,即协商过程和请求过程。

  • auth函数-协商过程
  • connect函数-连接过程

协商过程

协商过程简述:协商过程浏览器端会发送三个字段,我们响应两个字段回去,就完成了一个协商过程,然后进入连接请求过程。

协商具体过程和实现: 浏览器端发来的三个字段具体是VER,NMETHODS,METHODS

`              +----+----------+----------+
               |VER | NMETHODS | METHODS  |
               +----+----------+----------+
               | 1  |    1     | 1 to 255 |
               +----+----------+----------+

` ver字段一个字节,nmethods一个字节,而methods是不定长度,由读出来的nmethods的值决定。

iver, err := reader.ReadByte()
if err != nil {
   println("协议版本读取err:", err)
   return
}
if iver != socks5ver {
   println("协议版本错误", iver, "不是socks5")
   return
}
nmethods, err := reader.ReadByte()
if err != nil {
   println("方法数量读取err", err)
   return
}
buf := make([]byte, nmethods)
_, err = io.ReadFull(reader, buf)
if err != nil {
   println("读取方法出错", err)
   return
}

我们只需要在函数里根据长度读取字节,确定好协议版本和认证连接方式(用户密码认证,无需认证等)这个项目中,则是使用的socks5协议(不然怎么叫socks5代理服务器项目嘞)和无需认证的方式。

tcp连接方式要有来有回,所以我们应该写回我们选择的协议版本和认证方式

_, err = conn.Write([]byte{socks5ver, 0x00})
if err != nil {
   println("协商返回出错", err)
   return
}

连接请求

这是个稍微复杂点的过程。我们需要接收6个字段,然后返回6个字段。整个连接请求过程可以分为两个阶段

  • 处理请求
  • 返回确认信息
处理请求

处理请求的方式也协商阶段类似,我们先了解字段的长度种类,然后读取。

        |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
        +----+-----+-------+------+----------+----------+
        | 1  |  1  | X'00' |  1   | Variable |    2     |
        +----+-----+-------+------+----------+----------+

注意,这里的RSV是一个保留字段,不需要理会和处理。 因此我们提前index为0,1,3的字段。

字段解释:ver是协议版本(和协商阶段一致),cmd是连接请求字段,ATYP是地址类型字段(通过它确定发来的地址是ipv4地址还是HOST地址还是其他),DST.ADDR是地址字段,存放地址,DST.PORT是端口号字段。

具体代码处理实现如下

//前面4个字节分别是iver,cmd(连接请求),RSV(一个保留的量),ATYP(地址类型)
buf := make([]byte, 4)
_, err = io.ReadFull(reader, buf)
if err != nil {
   println("前4个字节读取错误", err)
   return err
}
iver, cmd, atye := buf[0], buf[1], buf[3]
if iver != socks5ver {
   println("协议版本不是socks5")
   return err
}
if cmd != cmdBind {
   println("请求连接错误")
   return err
}
var addr string
switch atye {
case atypeIPV4:
   _, err = io.ReadFull(reader, buf)
   if err != nil {
      println("ipv4地址读取错误", err)
      return err
   }
   addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
   println("ipv4地址为", addr)
case atypeHOST:
   address_size, err := reader.ReadByte()
   if err != nil {
      println("host地址读取错误", err)
      return err
   }
   buf2 := make([]byte, address_size)
   io.ReadFull(reader, buf2)
   addr = string(buf2)
   println("host地址为", addr)

case atypeIPV6:
   println("暂时不支持ipv6")
default:
   println("地址类型不支持")
}

在上述代码中,处理atyp字段的过程中也紧接着获取到了第五个字段,也就是对应的地址,并存到了addr变量中。

最后,再把固定长度为2bytes的端口号字段读取出来

_, err = io.ReadFull(reader, buf[:2])
if err != nil {
   println("读取端口号出错", err)
   return err
}
port := binary.BigEndian.Uint16(buf[:2])
println("读取到的端口号为", port)

注意这里要将port作二进制的大端序列处理

返回确认信息

字段处理完了,不妨回顾一下,在刚刚的过程中,我们确认了前三个字段信息没有错误,然后获取了地址和端口号,即我们已经获得了一个完整的可以用于拨号的地址,那么为了建立双向的连接,我们要写回我们的确认连接,同时Dial回去,在建立起双向的数据流传输,整个代理服务器的功能就实现了。

dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
if err != nil {
   println("dial拨号出错", err)
   return err
}
defer dest.Close()
//这个conn的write很重要,
//如果不写就导致我们没有返回确认,则无法联网
_, err = conn.Write([]byte{socks5ver, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})

最后,利用context库,使得函数不会立即退出,让io.copy(这个函数可以使得字节流单向复制,所以用两个io.copy来实现双向数据传输)可以一直执行,直到有一方出错,才停止,然后退出函数。

ctx, cancel := context.WithCancel(context.Background())
go func() {
   io.Copy(dest, reader)
   cancel()
}()
go func() {
   io.Copy(conn, dest)
   cancel()
}()
<-ctx.Done()

最后的最后,我们需要调试写好的代理服务器。 我们需要用到浏览器插件Proxy SwitchyOmega,启用这个插件后,设置好地址端口号,在我们的程序中去监听对应的地址端口号,然后就可以看到我们的代理服务器的作用了。 调试的结果如图

代理服务器结果.png 我们可以看出,所有的网络流量都通过了这个代理服务器,再来到我们的机器,同理,我们返回的数据也会经过代理服务器再发送到浏览器端。