GO语言工程实践(2) | 青训营

132 阅读7分钟

本篇为第三篇笔记,书接上回,整理老师带我们上手Go语言的第三个示例:socks5 代理服务器,比之前实现的简单的命令词典工具更复杂。

SOCKS5代理

介绍

SOCKS5代理服务器用到的代理协议socks5协议诞生于互联网早期,是明文传输,不能用来翻墙。某些企业的内网为了确保安全性,有很严格的防火墙策略,与此同时带来了访问某些资源会很麻烦的副作用。而socks5 相当于在防火墙开了个口子,让授权的用户可以通过单个端口去访问内部的所有资源。

这个示例是具有如下效果的代理服务器。

启动程序,然后在浏览器里面配置使用这个代理,此时打开网页。代理服务器的日志,会打印出访问的网站的域名或者IP,说明网络流量是通过这个代理服务器的。也可以在命令行去测试此代理服务器。 image.png 用 curl -socks5 代理服务器地址,后面加一个可访问的 URL,该命令正常返回表示代理服务器工作正常。

原理

首先来了解一下 socks5 协议的工作原理。

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

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

  • 握手阶段:浏览器会向 socks5 代理发送请求,包的内容包括一个协议的版本号,还有支持的认证的种类;
  • 认证阶段:socks5 服务器会选中一个认证方式,返回给浏览器。返回 00 就代表不需要认证,返回其他类型的话会开始认证流程。
  • 请求阶段:认证通过之后浏览器给 socks5 服务器发起请求。主要信息包括版本号、请求的类型,一般主要是 connection 请求,即代表代理服务器要和某个域名或者某个 IP 地址某个端口建立 TCP 连接。代理服务器收到响应之后,会和后端服务器建立连接,然后返回一个响应。
  • relay 阶段。浏览器正常发送请求,代理服务器接收到请求之后,直接把请求转换到真正的服务器上。如果真正的服务器返回响应,也会把请求转发到浏览器这边。实际上代理服务器并不关心流量的细节,可以是 HTTP流量,也可以是其它 TCP 流量。

了解socks5协议的工作原理,就可以尝试去简单地实现了。

TCP echo server

第一步,在Go里面写一个简单的 TCP echo server。为了方便测试,server的工作逻辑很简单,发送什么就会回复什么,像这样: image.png 其代码如下: image.png 首先在 main 函数里面先用 net.listen 去监听一个端口,会返回一个 server, 然后在一个死循环里面,每次去 accept 一个请求,成功就会返回一个连接。接下来的话我们在一个 process 函数里面去处理这个连接。注意这前面会有个 go 关键字,这个代表启动一个 goroutinue, 可以暂时类比为其他语言里面的启动一个子线程。只是这里的 goroutinue 的开销会比子线程要小很多,可以很轻松地处理上万的并发。

接下来是这个 process 函数的实现。 image.png 第一步先加一个 defer connection.close(), defer 是 Golang 里面的一个语法,这一行的含义就是代表在这个函数退出的时候要把这个连接关掉,否则会有资源的泄露。接下来的话我们会用 bufio.NewReader 来创建一个带缓冲的只读流,这个在前面的猜谜游戏里面也有用到,带缓冲的流的作用是,可以减少底层系统调用的次数,比如这里为了方便是一个字节一个字节的读取,但是底层可能合并成几次大的读取操作。并且带缓冲的流会有更多的一些工具函数用来读取数据。我们可以简单地调用那个 readbyte 函数来读取单个字节。再把这一个字节写进去连接。

认证阶段

接下来实现协议的第一步,认证阶段。

这一部分我们实现一个空的 auth 函数,在 process 函数里面调用,再来编写 auth 函数的代码。 image.png 根据认证阶段的逻辑,首先浏览器会给代理服务器发送一个包,然后这个包有三个字段,第一个字段,协议版本号version,固定是5;第二个字段,认证的方法数目methods;第三个字段,每个method的编码,0代表不需要认证,2代表用户名密码认证。先用 read bytes 来把版本号读出来,然后如果版本号不是 socket 5 的话直接返回报错,接下来再读取 method size ,也是一个字节。然后我们需要我们去 make 一个相应长度的一个 slice ,用 io.ReadFull 把它去填充进去。代理服务器还需要返回一个response, 返回包包括两个字段,version和method,当前是00。

请求阶段

第三步,实现协议的请求阶段。

要实现读取携带 URL 或者 IP 地址端口的包,然后把它打印出来。这部分实现一个和 auth 函数类似的 connect 函数,同样在 process 里面去调用。

根据请求阶段的逻辑,浏览器会发送一个包,包里面包含如下6个字段,version 版本号5,command代表请求的类型,我们只支持 connection 请求,也就是让代理服务建立新的TCP连接。RSV是保留字段,atype是目标地址类型,可能是IPV 4、IPV 6或者域名,addr的长度是根据atype的类型而不同的,port端口号,占两个字节。 image.png 定义一个长度为 4 的 buffer 然后把它读满。读满之后,第0个、 第1个、第3个分别是 version cmd和type,version 需要判断是 socket 5, cmd 需要判断是 1。type可能是 ipv4 ,ipv6,或者是 host。

如果是IPV 4,需要再次读满这个buffer, 因为这个buffer长度刚好也是4个字节,然后逐个字节打印成 IP 地址的格式保存到 addr变量。如果是 host 的话,需要先读它的长度,再 make 一个相应长度的buf填充它。 再转换成字符串保存到 addr 变量。最后还有两个字节是 port,读取它并按协议规定的大端字节序转换成数字。 image.png 建立一个临时的 slice ,长度是2用于读取,这样的话最多会只读两个字节回来。接下来把这个地址和端口打印出来用于调试。收到浏览器的这个请求包之后,需要返回一个包,这个包有很多字段。第一个是版本号socket 5;第二个,是返回的类型,成功就返回0;第三个是保留字段填0;第四个type地址类型,填1;第五个、第六个暂时用不到,都填成 0。

relay阶段

最后一步,relay阶段,真正和这个端口建立连接,双向转发数据。 image.png 直接用 net.dial 建立一个 TCP 连接,建立完连接之后,同样要加一个 defer 来关闭连接。接下来需要建立浏览器和下游服务器的双向数据转发。标准库的 io.copy 可以实现一个单向数据转发,双向转发需要启动两个 goroutinue。 image.png 现在有一个问题,connect 函数会立刻返回,返回的时候连接就被关闭了。需要等待任意一个方向copy出错的时候,再返回 connect 函数。可以使用标准库里面的一个 context 机制,用 context 连 with cancel 来创建一个context。在最后等待 ctx.Done() , 只要 cancel 被调用, ctx.Done就会立刻返回。 然后在上面的两个 goroutinue 里面调用一次 cancel 即可。 image.png 可以看到curl命令返回成功,至此代理服务器完成。