SOCKS5 代理服务器的实现(中) | 青训营笔记

90 阅读4分钟

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

本节重点

重新理清代理协议的请求阶段的各个过程,动手进行实现。

SOCKS5 代理 - 请求阶段

上次 auth 阶段完成之后,我们其实离最终成功差得还比较远,但是我根据运行结果,能够打印出 version 和 method 两个字段和建权方式,说明我们当前的实现是正确的。

请求阶段的话,我代码会试图读取客户端发送一个报文,里面携带了用户需要访问的url或者ip加端口,我先将它打印出来。

然后我们会实现一个和 auth 函数类似的一个 connect 函数,签名是一致的,然后同样在 process 函数调用,先 auth 然后 connect。

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

接下来是实现 connect 代码: 先回忆一下请求阶段的逻辑,服务端先发送一个报文,报文中包括六个字段,第一个字段 VER 版本号,第二个字段 CMD,我们只支持 connect 请求,是让代理服务器和下一个服务器创建连接,第三个字段 RSV 保留字段,一般是 0,第四个字段 ATYP,目标地址类型,需要重点关注的,它可能是多种类型,比如IPv4、IPv6或者是一个域名,这里 1 代表IPv4,3 代表域名,若是 IPv4 则是固定域名四个字节,如果是域名的话,后面是个变长的字符串,第一个字节是长度,后面的就是真正的域名,最后是端口号,固定2字节。下面就要将六个字段都读出来。

虽然前面几个字段可以用 ReadByte 一个字节一个字节读取,但是这里我们创建一个长度为 4 的缓冲区,然后用 io.ReadFull 直接填充满,这样我们就能一次性读取到前面四个字段因为他们是定长的,有VER、CMD、RSV、ATYP,然后就验证每个字段的合法性。

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有很多种类型,我们对其进行讨论,如果是 IPv4 的话,同样需要读四个字节,恰好上面用到的缓冲区也是四个字节,我就直接还是将其填充满,然后将其打印成一个 IP 地址;如果是 HOST 类型的话,那么我们先读一个字节,然后再 make 一个对应长度的字符串,然后再用 io.ReadFull 将其填充满,填充满之后将其转化成一个字符串即可;如果是 IPv6 的话其实也是读一个固定长度,但是用的比较少,就不在此实现了;其他方式的话,不予支持。

switch atyp {
case atypIPV4:
   _, 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")
}

最后的话,前五个端口都已经读完了,只剩下最后端口号两个字节,我 make 一个新的缓冲区,然后去ReadFull。但是,此处我们用另一种方法实现:复用之前定义长度为 4 的缓冲区,用一个切片语法将其裁剪成一个两字节的缓冲区,把它填充满,然后新的切片和旧的是复用底层数据的,所以在缓冲区中是能够直接读到端口号数据的,然后再用 binary 中函数按照大端方式去解析整型数字,即端口号。再打印日志,说明将会于某个端口号建立连接。

_, 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)

然后根据协议,还需要给与一个回报,回报报文字段比较多,但是很多都用不上,按照协议的话,直接把第一个字段填成 5,后面 IP 填成 0,代表成功,RSV 是 0 保留字段,ATYP 的话填最简单的 1,就是 IPv4,后面 address 补用四个字节四个 0 扩成两个 0,最后拼成一个 byte 数组直接写进去,此时我们 connect 就算完成啦!

阶段小结

到这一步的话,简单测试一下之后,发现 curl 还是会失败,但是可以看到正常打印出需要访问的 IP 和端口,这说明到当前,实现都是正确的!

其实请求阶段与 auth 阶段差不太多,也就是报文的解析之类,主要还是理解请求阶段的各个过程!