Go 语言实践案例——SOCKS5 代理 | 青训营

2023年 8月 21日 47.6k 0

概念介绍

SOCKS5 代理是一种网络协议,用于在客户端和服务器之间进行安全的数据传输。它提供了在客户端和服务器之间进行代理连接的功能,以便客户端可以通过代理服务器访问其他网络资源。

与其他代理协议(如 HTTP 代理)不同,SOCKS5 代理可以透明地处理各种网络流量,包括传输文件、邮件、Web 内容等。它不限制特定的应用层协议,因此更加灵活。

SOCKS5代理的主要优点是:

  • 高度灵活:它支持几乎所有的网络应用,无论是通过 TCP1 还是 UDP2 协议。
  • 更好的性能:与 HTTP 代理相比,SOCKS5 具有更低的延迟和更高的传输速度。
  • 支持身份验证:SOCKS5 代理还支持客户端和服务器之间的身份验证,增加了安全性。
  • 可以绕过防火墙:由于 SOCKS5 代理使用的端口通常不受限制,因此它可以用于绕过防火墙和网络限制(相当于在防火墙上开了一个口),实现科学上网(但是一般不这样做,因为 SOCK5 是明文传输,但实际上很多魔法最后暴露出来的都是一个 SOCK5 协议的端口)。
  • 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 协议的网络应用程序,其主要功能是接收客户端发送的数据,并将接收到的数据原样回传给客户端。工作原理如下:

  • TCP Echo Server 在指定的端口上监听客户端的连接请求;
  • 当客户端连接到服务器时,服务器会接受连接并与客户端建立一个 TCP 连接;
  • 一旦连接建立,服务器开始从客户端接收数据;
  • 接收到的数据被服务器存储在缓冲区中;
  • 一旦接收到完整的数据,服务器会将该数据原封不动地发送回客户端;
  • 服务器继续等待客户端发送更多的数据,重复步骤 4 和 5;
  • 当客户端关闭连接时,服务器也会关闭与该客户端的连接。
  • 再回过头看样例代码(来自青训营,包括后面的修改代码):

    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 服务器是正常运行的。

    image.png

    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() 函数:用于从输入源中读取指定长度的数据,该函数接受两个参数:rbufr 是实现了 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 请求肯定是发送失败的,因为请求阶段的功能还未实现。

    image.png

    请求阶段

    这一步相比上一步又是多加了一个 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()
    }()

    // 等待两个拷贝都取消,否则阻塞

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论