基于Go语言的代理服务器学习感悟 | 青训营笔记

118 阅读9分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天。
本文的主要内容是总结了课上学习socks5代理服务器所用到的一些知识点。

socks5简介

什么是socks5

SOCKS5 是一个代理协议,它在使用TCP/IP协议通讯的前端机器和服务器机器之间扮演一个中介角色,使得内部网中的前端机器变得能够访问Internet网中的服务器,或者使通讯更加安全。SOCKS5 服务器通过将前端发来的请求转发给真正的目标服务器, 模拟了一个前端的行为。在这里,前端和SOCKS5之间也是通过TCP/IP协议进行通讯,前端将原本要发送给真正服务器的请求发送给SOCKS5服务器,然后SOCKS5服务器将请求转发给真正的服务器。

socks5虽然不能理解自己转发的数据的内部结构,但是它能够忠实地转发通讯包,完成协议本来要完成的功能。

基本实现原理

包含四个阶段:握手阶段、认证阶段、请求阶段、relay阶段。

在握手阶段,浏览器向socks5发送请求包,包括协议的版本号,支持的认证的种类,socks5服务器则会选择一个认证方式,返回给浏览器。如果返回00的话,则代表不需要认证,返回其他类型则开始认证流程。

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

在relay阶段,浏览器会正常发送请求,代理服务器收到后会把请求转换到真正的服务器上,真正的服务器如果响应,代理服务器会把相应转到浏览器。

调试工具

curl

curl是一种命令行工具,作用是发出网络请求,然后得到和提取数据,显示在"标准输出"(stdout)上面。

curl的功能很多,下面只介绍调试过程中用到的两个参数:

-v

-v数可以显示一次http通信的整个过程,包括端口连接和http request头信息,用于调试,如下所示:

$ curl -v www.sina.com

* About to connect() to www.sina.com port 80 (#0)
  * Trying 61.172.201.195... connected
  * Connected to www.sina.com (61.172.201.195) port 80 (#0)
  > GET / HTTP/1.1
  > User-Agent: curl/7.21.3 (i686-pc-linux-gnu) libcurl/7.21.3 OpenSSL/0.9.8o zlib/1.2.3.4 libidn/1.18
  > Host: www.sina.com
  > Accept: */*
  >
  * HTTP 1.0, assume close after body
  < HTTP/1.0 301 Moved Permanently
  < Date: Sun, 04 Sep 2011 00:42:39 GMT
  < Server: Apache/2.0.54 (Unix)
  < Location: http://www.sina.com.cn/
  < Cache-Control: max-age=3600
  < Expires: Sun, 04 Sep 2011 01:42:39 GMT
  < Vary: Accept-Encoding
  < Content-Length: 231
  < Content-Type: text/html; charset=iso-8859-1
  < X-Cache: MISS from sh201-19.sina.com.cn
  < Connection: close
  <
  <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
  <html><head>
  <title>301 Moved Permanently</title>
  </head><body>
  <h1>Moved Permanently</h1>
  <p>The document has moved <a href="http://www.sina.com.cn/">here</a>.</p>
  </body></html>
  * Closing connection #0

-x

-x参数用于HTTP请求的代理,参见用法为:

$curl -x 代理域名及端口 要访问的网站域名

比如,要通过127.0.0.1:1080的socks5代理服务器访问www.qq.com可以写作:

curl -x socks5://127.0.0.1:1080 http:www.qq.com
或者
curl --socks5 127.0.0.1:1080 http:www.qq.com

相关链接:www.cnblogs.com/duhuo/p/569…

nc(netcat)

NetCat,在网络工具中有“瑞士军刀”美誉,其有Windows和Linux的版本。因为它短小精悍(1.84版本也不过25k,旧版本或缩减版甚至更小)、功能实用,被设计为一个简单、可靠的网络工具,可通过TCP或UDP协议传输读写数据。同时,它还是一个网络应用Debug分析器,因为它可以根据需要创建各种不同类型的网络连接。

nc 强大之处在于输出是标准输出(stdout), 输入来自标准输入(stdin), 以至于可以很容易通过管道和重定向直接使用或被其他程序和脚本调用。

其主要功用如下所示:

  • 端口扫描: 通过与目的 IP 建立连接, 从而扫描目的IP 的端口是否开放
  • 聊天工具: 一边使用 nc 监听一个端口, 另一边使用 nc 成功连接这个端口即可互相通信
  • 发送文件: 与目的 IP 建立连接, 配合重定向, 源地址读取文件, 目的地址接收文件
  • 目录传输: tar 命令和管道的结合
  • 远程克隆磁盘: dd 命令和管道的结合
  • 配合 ssh config 的 ProxyCommand 命令进行跳板登录…

下面主要介绍一下端口扫描的命令:

与 www.qq.com 的 80 端口建立一个 TCP 连接(本地端口随机):

$ nc www.qq.com 80

使用本地 1234 端口与 www.qq.com 的 80 端口建立一个 TCP 连接:

$ nc -p 1234 www.qq.com 80

其他相关功能请详见:

www.ifmicro.com/记录/2017/12/…

www.cnblogs.com/nmap/p/6148…

相关代码

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

在main函数中,使用net.listen监听一个端口 127.0.0.1表示本机IP地址,也可用localhost表示,127开头的地址可以说是A类的保留地址,用作本地软件环回测试(loopback test)本主机的进程之间的通信之用。若主机发送一个目的地址为环回地址的IP数据报,则本机中的协议软件就处理数据报中的数据,而不会把数据报发送到任何网络**)**, 其会返回一个server,再死循环中每次accept一个请求,成功的话则会返回一个连接,之后就可以创建一个gorutine(相当于一个轻量级的子线程),在process函数中处理该连接。

process函数

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
	}

第一行的defer确保了如果因为发生意外引发panic导致提前退出的话,能够关闭连接

bufio.NewReader用来创建一个带有缓冲区的只读流(带有缓冲区的流可以减少底层系统的调用次数,即便如果代码中用.ReadByte要求一个字节一个字节读取,在底层也可能合并成几次大的读取操作,并且它还有更多的工具函数用来读取数据)。

之后会进行协商阶段

auto函数

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

如前文所述,客户端会发送协议版本以及支持的认证方法数量,对于单个字节的部分,使用**reader.ReadByte()**函数进行读取,而对于两个字节或者更多字节的部分,使用io.ReadFull(reader, method)进行读取,metho

d是具有一定大小的byte类型切片,reader则是输入缓冲流,该方法会将method填满供我们使用。

最后代理服务器会向客户端返回协议版本以及选择的认证方法。函数中返回的是不需要认证,因此直接跳过认证阶段,进入连接阶段。

connect函数

协商、认证完成后,浏览器客户端则会发送TCP连接请求给代理服务器,代理服务器则会与真正的服务器进行通讯,通讯成功则可以进行数据传递:

相应的代码为:

//**part1**
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 := ""
	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")
	}
	_, err = io.ReadFull(reader, buf[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buf[:2])

//**part2**
	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)
	_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed: %w", err)
	}

	//**part3**
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go func() { //匿名函数
		_, _ = io.Copy(dest, reader) //copy是单向数据转换,因此启动两个gorutine实现双向数据转换
		cancel()                     //上边是把输入缓冲区的内容发送给服务器
	}()
	go func() {
		_, _ = io.Copy(conn, dest) //服务器的信息返回给客户端
		cancel()
	}()

	<-ctx.Done() //等待任何一个方向拷贝失败才能返回,只有调用了cacel函数才会结束
	return nil

上述代码中part1表示代理服务器解析客户端浏览器将要访问的真是的浏览器的信息,信息格式如下所示:

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

part2表示利用net.dial建立一个代理服务器与真实服务器之间的一个TCP连接,当TCP连接建立成功后,代理服务器返回对应的连接状态,返回信息格式如下所示:

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

part3表示代理服务器“忠实地”转发客户端和服务端之间的数据包。数据包的转发通过io.copy()进行,但是有一个问题,就是如果不进行阻塞的话,connect函数会立即返回,连接也会被关闭。因此,使用标准库里面的context机制,使用**context.WithCancel(context.Background())**来建立一个context,最后等待ctx.Done(),只要cancel被调用,ctx.Done()就会被返回。注意:为了防止因为意外导致函数直接返回,而ctx.Done 无法释放,在定义ctx后,直接defer了一个cancel函数。