概念介绍
SOCKS5 代理是一种网络协议,用于在客户端和服务器之间进行安全的数据传输。它提供了在客户端和服务器之间进行代理连接的功能,以便客户端可以通过代理服务器访问其他网络资源。
与其他代理协议(如 HTTP 代理)不同,SOCKS5 代理可以透明地处理各种网络流量,包括传输文件、邮件、Web 内容等。它不限制特定的应用层协议,因此更加灵活。
SOCKS5代理的主要优点是:
SOCK5 代理不仅可以用于穿越防火墙,也可以间接解决爬虫的 IP 访问频率限制问题,用户可以通过寻找代理 IP 池(里面的代理多为 SOCK5),将爬虫的请求通过代理服务器进行中转,以改变出口 IP 地址或者轮换 IP 地址,从而规避某些网站的访问频率限制。
目标实现
在启动程序后,在浏览器里配置使用该代理,代理服务器的日志会打印所访问的网页的域名或者是 IP,这说明网络流量是通过这个代理服务器的;同时可以使用 curl --sock5 代理服务器地址 -v URL
正常返回一个 curl 命令。
工作原理
当浏览器访问一个网站时,以下是一般的原理流程:
解析 URL:浏览器首先解析输入的 URL,并提取出协议、主机名和路径等信息;
DNS 解析:浏览器向本地 DNS 服务器发送解析请求,将主机名转换为对应的 IP 地址。如果本地 DNS 服务器没有缓存相应的 IP 地址,则会进行递归查询直至找到对应的 IP 地址;
发起 TCP 连接:使用 HTTP 协议,默认端口号为 80,或者使用 HTTPS 协议,默认端口号为 443,浏览器与服务器之间建立起 TCP 连接。这是通过三次握手来确保连接的可靠性和完整性,三次握手的过程如下:
-
第一次握手(SYN):客户端向服务器发送一个 SYN(同步)请求报文段,其中包含一个随机生成的序列号和其他连接信息。
-
第二次握手(SYN + ACK):服务器接收到客户端的 SYN 请求后,会回应一个 SYN + ACK(同步和确认)报文段表示接收到请求,并为连接分配资源。该报文段中包含另一个随机生成的序列号和对客户端序列号的确认。
-
第三次握手(ACK):客户端接收到服务器的 SYN + ACK 报文段后,会向服务器发送一个 ACK(确认)报文段,确认服务器的响应。该报文段中包含客户端对服务器序列号的确认。
发送 HTTP 请求:浏览器通过 TCP 连接向服务器发送 HTTP 请求报文,报文中包含请求行、请求头部和请求正文等信息。请求行中包含请求方法(GET、POST等)、请求的 URI3 和 HTTP 协议版本。
服务器响应:服务器收到请求后,会解析请求报文并根据请求内容生成对应的响应报文。响应报文中包含状态码、响应头部和响应正文等信息。常见的状态码有 200 表示成功,404 表示页面未找到,500 表示服务器内部错误等。
接收响应:浏览器接收到服务器发送的响应报文后,会解析报文并处理其中的信息。例如,提取出响应头部中的 Cookie 信息、重定向 URL 或者下载文件等。
渲染页面:如果响应报文是 HTML 文档,浏览器会解析 HTML、CSS 和 JavaScript 等内容,构建 DOM 树和渲染树,并将其显示在浏览器窗口中。
关闭 TCP 连接:当所有内容都被加载和显示后,浏览器会关闭与服务器之间的 TCP 连接。如果网页中有其他资源(例如图片、样式表或脚本),则会重复上述步骤,发起额外的请求获取这些资源。
但是在使用 SOCK5 代理的情况下,流程会略微复杂一些,先由浏览器和 SOCK5 代理建立 TCP 连接,在由代理服务器和目标服务器建立连接。代理服务器在这里就扮演了如同中介一般的角色,在建立浏览器与代理之间的连接时,浏览器会发送一个特殊的请求给代理,包括协议的版本号和支持认证的种类,代理会选择其中一个认证方式返回给浏览器,从而进行认证流程,认证通过之后浏览器继续向 SOCK5 发起请求,以指定目标服务器的 IP 地址和端口号,代理响应之后会和目标服务器建立 TCP 连接,然后再把响应返回给浏览器。
之后浏览器正常发送 HTTP 请求,代理服务器接收到后会直接转换到目标服务器上,目标服务器响应后也会将响应经由代理转发给服务器。
另外,SOCK5 代理并不关心流量的细节,可以是 HTTP 流量或是其它 TCP 流量。
具体实现
TCP echo server
TCP echo server 也即 TCP 回显服务器,是一种基于 TCP 协议的网络应用程序,其主要功能是接收客户端发送的数据,并将接收到的数据原样回传给客户端。工作原理如下:
再回过头看样例代码(来自青训营,包括后面的修改代码):
package main
import (
"bufio"
"io"
"log"
"net"
)
func main() {
//监听本地的 1080 端口
server, err := net.Listen("tcp", "127.0.0.1:1080")
if err != nil {
panic(err)
}
defer server.Close() //新加的,关闭监听以释放资源
//接受并处理传入的连接请求
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)
for {
b, err := reader.ReadByte()
if err != nil {
//感觉这个写上会更好
if err == io.EOF {
// 已经读取到文件末尾
break
}
}
_, err = conn.Write([]byte{b})
if err != nil {
break
}
}
}
首先使用 net.Listen()
函数4以 TCP 协议监听本地主机的 1080 端口5,接着在一个死循环里使用 server.Accept()
函数6接受传入的连接请求并返回已建立的连接对象,然后将该连接对象交给 process()
函数处理,至于要在该函数调用的前面加一个 go
关键字的原因,可以认为是每当有一个客户端连接该端口,都会创建一个新的子线程处理该连接。另外,在 Go 语言中使用子线程的开销非常小,因此可以轻松处理上万的并发。
接下来具体讨论一下这个 process()
函数的具体实现吧,首先需要设置在处理完毕后关闭连接,以免出现资源泄露,接着将连接对象放入只读缓冲流,使用缓冲流的作用是减少底层系统调用的次数,这里是为了方便 reader.ReadByte()
函数7一个字节一个字节地读取。然后用读取到的字节创建只有一个元素的字节数组,最后使用 conn.Write()
函数8写入连接对象,也就实现了“把客户端传过来的数据全部发送回去”的功能(难怪案例代码看起来那么奇怪,从连接对象里读出来还要写进去。。。)。
然后就到了激动人心的测试环节了,测试需要用到 netcat 工具,点击下载。
安装完成后记得配上环境变量,变量值为 netcat 的安装路径,接着把程序跑起来,再使用 nc 命令模拟客户端连接指定端口并发送消息,然后就会接收到一模一样的返回信息,这就说明 TCP 服务器是正常运行的。
auth
这一步要开始实现 SOCK5 的认证阶段了,相比于上一段代码,这段代码多了一个 auth
函数:
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
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)
}
log.Println("ver", ver, "method", method)
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
return nil
}
该函数用于处理客户端的认证过程,首先从读取器中读取协议版本号(VER)并进行验证。接着读取支持的认证方法数量(NMETHODS),并读取相应数量的方法字节(METHODS),然后打印协议版本和认证方法(在代理端可以看到),最后将协议版本以及选中的认证方法发送到客户端。如果在过程中发生错误,会返回相应的错误信息。接下来是对其中一些陌生函数的解释:
-
fmt.Errorf()
函数:用于创建一个格式化的错误消息。它返回一个error
类型的值,该值表示一个错误。这个函数的参数是一个格式化字符串和一些可变参数,类似于fmt.Printf()
函数。 -
io.ReadFull()
函数:用于从输入源中读取指定长度的数据,该函数接受两个参数:r
和buf
。r
是实现了io.Reader
接口的输入源,可以是文件、网络连接、字符串等;buf
是用于存储读取数据的字节数组。函数会尝试从输入源中读取足够的数据填充到buf
中,直到buf
被完全填充或者遇到错误为止。如果成功读取了指定长度的数据,函数会返回实际读取的字节数以及nil
。如果在读取过程中遇到错误,则返回已读取的字节数和相应的错误。
究其本质就是在上一阶段(建立连接)的基础上进行认证。接下来再次进行测试,发送 HTTP 请求,curl 命令要比 nc 命令更加好用,所以这次使用 curl 命令进行测试。
程序运行起来后,在命令行输入命令:
curl --socks5 127.0.0.1:1080 -v http://www.qq.com
意思是说以本地主机为代理服务器,建立客户端与主机的连接,然后向代理发送指向 QQ 官网的 HTTP 请求,可以看到认证确实是成功了,但是 HTTP 请求肯定是发送失败的,因为请求阶段的功能还未实现。
请求阶段
这一步相比上一步又是多加了一个 connect()
的函数用于实现 HTTP 请求,代码如下:
func connect(reader *bufio.Reader, conn net.Conn) (err error) {
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])
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)
}
return nil
}
前面的函数基本上没有大的改动,因此这里就不全部贴出来了,process()
函数里在调用了 auth()
函数之后再调用 connect()
函数,这样其实是没有问题的,因为在我们使用 curl 命令测试时,确实会发送两次信息,一次是认证信息,另一次是请求信息,由于 auth()
和 connect()
函数都涉及到指针的修改,因此对读取缓冲流是有改动的。auth()
读取认证信息,connect()
读取请求信息。
接下来大体过一遍 connect()
的逻辑,首先读取客户端传给代理的 HTTP 请求信息,分为 6 个字段,分别是:版本号、连接命令、保留字、目标地址类型、目标地址和端口号,首先读取前 4 个字段,对版本号,连接命令及目标地址类型进行判定(保留字必须为 0,不用管)。接着根据目标地址类型的不同对目标地址做不同的处理,需要注意的是,如果目标地址为域名,则目标地址的第一个字节为域名长度。 然后读入端口号,最后返回信息即可(用不到的暂时填成 0)。
需要注意的函数有:
-
fmt.Sprintf()
:用于返回格式化的字符串(是返回,而非输出)。 -
binary.BigEndian.Uint16()
:用于将大端字节序9的字节切片转换为无符号 16 位整数(uint16)并返回。
好吧,即使做到这种程度,还是没法请求成功,因为现阶段实现的依旧是代理和客户端的交互,还没有真正和目标服务器产生连接,但至少能打印出访问的 IP 及端口,这就说明这一步的实现还是正确的。
relay 阶段
这一步需要实现的是和目标服务器建立连接,并进行双向的数据传输,直接上 relay 的代码实现:
// 与目标服务器建立 TCP 连接
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)
}
//创建上下文对象,借助取消机制和并发子线程实现客户端和目标服务器的数据传输
ctx, cancel := context.WithCancel(context.Background())
defer cancel() //延迟取消,非必需,但可以保证代码的健壮性
// 把客户端的数据流拷贝到目标服务器的连接上
go func() {
_, _ = io.Copy(dest, reader)
cancel()
}()
// 把目标服务器的响应数据流拷贝回客户端
go func() {
_, _ = io.Copy(conn, dest)
cancel()
}()
// 等待两个拷贝都取消,否则阻塞