这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天
从零实现一个socks5代理服务器详解,主要分为以下四个部分:socks5协议简要介绍、它的工作原理、sock5代理服务器的实现流程、以及windows测试方式。
socks5协议的工作原理为最重要的部分,剩下的则是一些实现细节。首先对比一下,使用socks5代理服务器后,与客户端和远程服务器端直接连接的区别:
1. socks协议介绍
socks5是一种网络传输协议, 它在使用TCP/IP协议通讯的客户端和服务器之间扮演一个中介角色,根据OSI七层模型来划分,属于会话层协议,位于表示层与传输层之间。
socks5协议虽然是代理协议,但是不能用来翻墙,它的协议都是明文传输的,历史久远。 企业内网确保安全性,配置了严格的防火墙策略,但带来的副作用就是,访问某些资源哪怕是管理员也会很麻烦,socks5协议相当于在防护墙开了个口子,让用户可以通过单个端口(1080)访问内部所有资源。
很多翻墙软件最终暴露的也会是一个socks5协议的端口给浏览器什么的使用,爬虫IP访问频率超过限制然后报错,代理IP池里面的很多的代理协议就是socks5协议,流量也是通过这个走的。
2. socks5协议工作原理
关于sock5协议原理更详细的说明见下图:
- 认证阶段
浏览器(客户端)发送auth request报文给代理服务器, 代理服务器接收后返回auth reply报文给浏览器, 协商认证方式并完成认证。
- (socks5代理服务器端)接收报文:
浏览器(客户端)给socks5代理服务器发送一个报文,协商认证方式,报文内容包括代理版本和认证的方式。接收到的报文结构如下:// +----+----------+----------+ // |VER | NMETHODS | METHODS | // +-----+----------+----------+ // |1byte| 1 | 1 to 255 | // +----+----------+----------+ // VER: 协议版本,socks5为0x05,固定为5 // NMETHODS: 支持认证的方法数量 // METHODS: 每个方法(method)的编码 // 对应NMETHODS,NMETHODS的值为多少,METHODS就有多少个字节。 // RFC预定义了一些值的含义,内容如下: // X’00’ NO AUTHENTICATION REQUIRED 0不需要认证 // X’02’ USERNAME/PASSWORD 2用户名密码认证
- (socks5代理服务器端)发送报文:
代理服务器接收到报文后选择一种认证方式并发送给浏览器。发送报文结构如下:// +----+--------+ // |VER | METHOD | // +----+--------+ // | 1 | 1 | // +----+--------+
- 请求阶段
认证方法对应的协商完成后,客户端就可以开始请求发送细节了。
- (socks5代理服务器端)接收报文:
读取浏览器发送的报文,里面携带了用户(客户端)需要访问的URL或是IP地址+端口。详细的报文架构如下// +----+-----+-------+------+----------+----------+ // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | // +----+-----+-------+------+----------+----------+ // | 1 | 1 | X'00' | 1 | Variable | 2 | // +----+-----+-------+------+----------+----------+ // VER 版本号,socks5的值为0x05 // CMD 0x01表示CONNECT请求 : 代表请求类型 // 只支持connection请求,也就是让代理服务器建立新TCP链接 // RSV 保留字段,值为0x00 // ATYP 目标地址类型,DST.ADDR的数据对应这个字段的类型。 // 0x01表示IPv4地址,DST.ADDR为4个字节 // 0x03表示域名,DST.ADDR是一个可变长度的域名 // DST.ADDR 一个可变长度的值,与ATYP类型有关 // DST.PORT 目标端口,固定2个字节
- (socks5代理服务器端)发送报文:
socks服务器端会根据请求类型和源、目标地址,执行对应操作并返回一个或多个报文// +----+-----+-------+------+----------+----------+ // |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | // +----+-----+-------+------+----------+----------+ // | 1 | 1 | X'00' | 1 | Variable | 2 | // +----+-----+-------+------+----------+----------+ // VER socks版本,这里为0x05 // socket 5 // REP Relay field,内容取值如下 X’00’ succeeded 返回类型 0成功 // RSV 保留字段 (填0) // ATYPE 地址类型 填1(IPV4) 1字节 // BND.ADDR 服务绑定的地址 (暂时用不到填0)(IPV4--> 4字节) // BND.PORT 服务绑定的端口DST.PORT (用不到填0) // 一共1+1+1+1+4+2 = 10字节 字节切片填10个元素就OK
- 通信阶段
当链接建立后,客户端就可以和正常一样访问远程服务端通信了,此时通信的数据除了目的地址是代理服务器外,其余所有内容和普通链接一模一样。
对于代理程序而言,后端收到的所有来自客户端的数据都会原样转发给远程服务器端。
3. 实现流程
在代理服务器端,代码需要完成功能如下所示:
main() ---------------------------------process()
graph TD
监听端口-->接收请求-->处理链接_process函数-->END
协商认证_auth函数--OK-->代理请求_connect函数--OK-->建立双向数据转发_connect函数--任意一方转发失败-->END1
- main()具体实现细节如下:
- 启动子协程处理链接,在goland里面开销会小很多,可以轻松处理上万并发
func main() { // 侦听端口,返回server server, err := net.Listen("tcp", "127.0.0.1:1080") if err != nil { panic(err) } // 死循环接收请求,成功返回链接,启动子协程pocess处理 for { client, err := server.Accept() if err != nil { log.Printf("Accept failed %v", err) continue } go process(client) // go+函数:子协程下进行函数 } } - process()具体实现细节如下:
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 } } - auth()和connect()实现细节如下:
-
协商认证和代理请求:接收报文并回复报文 (代码需要接收读取来自浏览器的报文,并生成回复报文,具体代码见完整代码。)
-
建立双向数据转发
- 和远程服务器进行TCP连接:
收到代理请求报文后,通过addr地址和端口port信息用net.dial建立一个TCP链接 - 怎么实现双向数据转发:
通过单向实现双向,启动两个相反方向数据转发的子协程goroutine - 怎么实现单向数据转发:
io.copy(dst Writer, src Reader),会把src这个只读流里面的数据用一个死循环逐步拷贝到dst这个可写流里面 - 存在问题:
子协程不耗时间,函数会直接跑完,链接关闭。而我们想让链接一直存在,除非任意一个方向copy出错再结束链接 - 解决问题:
标准库下的context机制:context.withCancel
此时函数不会立即返回,会在最后等待ctx.Done()
cancel被调用-->ctx.Done()立刻返回
// 1. 和远程服务器进行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) // 2. 通过启动两个单向数据转发子协程实现双向转发转发 ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _, _ = io.Copy(dest, reader) // 单向数据转发(谁发给谁) cancel() }() go func() { _, _ = io.Copy(conn, dest) cancel() }() <-ctx.Done() return nil - 和远程服务器进行TCP连接:
-
4. windows下测试
- 使用curl
- 运行程序:
go run ../v4/main.go - cmd输入:
curl --socks5 127.0.0.1:1080 -v 网址,请求正常说明代理工作正常
- 运行程序:
- 使用浏览器(edge,chrome)插件switchyOmega
- 扩展里面安装switchyOmega
- 运行程序
- 打开-->新建情景模式-->
设置代理协议:socks5, 代理服务器:127.0.0.1, 代理端口:1080
-->保存(应用选项)退出
-->点击扩展中switchyOmege选择刚刚设置的情景模式 -->点开任意网页测试
- 代理设置
- 运行程序
- 开始-->设置-->网络和Internet-->手动设置代理打开-输入对应服务器和端口
- 打开任意网页测试
参考文献
[1] Socks5工作原理与搭建_東魔的博客-CSDN博客_socks5
[2] socks5代理工作流程和原理 - 马谦的博客 (dyxmq.cn)
[3] Go 语言的实战案例 - 掘金 (juejin.cn)