这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
本堂课重点内容
- SOCKS5协议介绍
- SOCKS5代理服务器Go实战
1. SOCKS5协议
1.1 定义
百度百科定义
SOCKS5 是一个代理协议,它在使用TCP/IP协议通讯的前端机器和服务器机器之间扮演一个中介角色,使得内部网中的前端机器变得能够访问Internet网中的服务器,或者使通讯更加安全。SOCKS5 服务器通过将前端发来的请求转发给真正的目标服务器, 模拟了一个前端的行为。在这里,前端和SOCKS5之间也是通过TCP/IP协议进行通讯,前端将原本要发送给真正服务器的请求发送给SOCKS5服务器,然后SOCKS5服务器将请求转发给真正的服务器。
SOCKS5代理服务器位于客户端与下游服务器之间,充当一个跳板的角色。
1.2 原理
正常情况下,我们使用浏览器访问网站,浏览器会与下游服务器先通过三次握手与服务器建立TCP连接,然后再通过HTTP协议进行数据传输。而经过SOCKS5代理后,此时则需要经过3个步骤:认证、请求、通信。
1.2.1 认证
客户端向代理服务器发送代理请求,其中包含了代理的版本和认证方式
代理请求的报文格式如下:
| VER | NMETHODS | METHODS |
|---|---|---|
| 1 | 1 | 1 to 255 |
字段说明:
- VER: SOCKS的版本号,SOCKS5里版本号为0x05
- NMETHODS:支持认证的方法数量
- METHODS:NMETHODS的值为多少,该字段就有多少个字节,每个字节都表示一种认证方法
- RFC预定义了一些值的含义,内容如下:
- X'00' 无需认证
- X'01' GSSAPI
- X'02' 用户名/密码
- X'03' 到 X'7F' IANA 指定
- X'80' 到 X'FE' 为私有方法保留
- X'FF' 无可接受方法
- RFC预定义了一些值的含义,内容如下:
代理服务器收到认证报文后,会向客户端返回一个报文,通知所采用的方法标识,随后客户端和代理服务器之间继续按照指定认证方式进行认证
| VER | METHOD |
|---|---|
| 1 | 1 |
- 注意点:当返回的METHOD值为0xFF时,表示所有认证方法都不可用,此次连接中断
代码:
// Auth auth为客户端向代理服务器发起认证,reader为本次数据报文的只读流,conn为本连接
func auth(reader *bufio.Reader, conn net.Conn) error {
// version只有1个字节
version, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read version fail: %w", err)
}
if version != socks5Ver {
return fmt.Errorf("not supported sockets version: %v", version)
}
// 读取NMETODS字段
methodNums, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read nmethods fail: %w", err)
}
// []byte{1} = 00000001
// []byte{1, 2} = 00000001 00000010
// 为method分配一个长度为methodNums的[]byte类型(uint8)的切片
method := make([]byte, methodNums)
// 把reader剩下的字节(METHODS字段)复制到method切片中,第一个返回值的复制的字节数。
_, err = io.ReadFull(reader, method)
if err != nil {
return fmt.Errorf("read method failed :%w", err)
}
log.Println("version: ", version, "method: ", method)
// 向客户端返回报文
// 向conn写入[]byte{1, 0x00} 0x00是指无需认证
_, err = conn.Write([]byte{socks5Ver, 0x00})
if err != nil {
return fmt.Errorf("write failed:%w", err)
}
return nil
}
1.2.2 请求
一旦认证方法对应的协商完成,客户端就可以发送请求细节了。如果认证方法为了完整性或者可信性的校验,需要对后续请求报文进行封装,则后续请求报文都要按照对应规定进行封装。
报文格式如下:
| VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|---|---|---|---|---|---|
| 1 | 1 | X'00' | 1 | Variable | 2 |
字段说明:
- VER:X'05'
- CMD:命令
- CONNECT:连接, X'01'
- BIND:监听X'02'
- UDP ASSOCIATE:UDP关联 X'03'
- RSV:保留字段
- ATYP:地址类型
- X'01':表明地址字段为一个 IPV4 地址,长度为 4 个字节
- X'03':表明地址字段为一个(合法的)域名,且第一个字节为域名长度标识,(显然)其不以 NULL 作为结束标识
- X'04':表明地址字段为一个 IPV6 地址,长度为 16 个字节
- DST.ADDR:目标地址
- DST.PORT目标端口 (网络字节序)
SOCKS 服务端收到请求报文后会根据请求类型和源、目标地址,执行对应操作,并且返回对应的一个或多个报文信息。 回复报文,客户端与服务端建立连接并完成认证之后就会发送请求信息,服务端执行对应请求并返回如下格式的报文:
| VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
|---|---|---|---|---|---|
| 1 | 1 | X’00’ | 1 | Variable | 2 |
字段说明
- VER协议版本:X'05'
- REP 回复字段(回复类型):
- X'00' 成功
- X'01' 常规 SOCKS 服务故障
- X'02' 规则不允许的连接
- X'03' 网络不可达
- X'04' 主机无法访问
- X'05' 拒绝连接
- X'06' 连接超时
- X'07' 不支持的命令
- X'08' 不支持的地址类型
- X'09' 到 X’FF’ 未定义
- RSV 保留字段,必须设定为 X‘00’ 。
- ATYP 地址类型
- IPV4 X'01'
- 域名 X'03'
- IPV6 X'04'
- BND.ADDR 服务端绑定地址
- BND.PORT 服务端绑定端口 (网络字节序)
代码:
// Connect 客户端向代理服务器发起向下游服务器的连接请求,reader为本次数据报文的只读流,conn为本连接
func connect(reader *bufio.Reader, conn net.Conn) (net.Conn, error) {
// 创建一个长度为4个字节的缓冲区
buf := make([]byte, 4)
// 直接读取reader对象的前4个字节
_, err := io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read version, cmd, rsv, atype failed: %w", err)
}
version, cmd, atype := buf[0], buf[1], buf[3]
if version != socks5Ver {
return fmt.Errorf("not supported socket version: %v", version)
}
if cmd != cmdConnect {
return fmt.Errorf("not supported cmd: %v", cmd)
}
// 初始化地址
addr := ""
switch atype {
case atypIPV4:
// ipv4继续读取4个字节
_, err := io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("read ipv4 addr failed: %w", err)
}
// 格式化字符串
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
case atypeHOST:
// 域名类型,先读第一个字节获取域名长度l,然后读取l个长度的字节
hostLength, err := reader.ReadByte()
if err != nil {
return fmt.Errorf("read host length failed: %w", err)
}
host := make([]byte, hostLength)
_, err = io.ReadFull(reader, host)
if err != nil {
return fmt.Errorf("read host addr failed: %w", err)
}
addr = string(host)
case atypeIPV6:
// ipv6类型,有16个字节长度
ipv6Addr := make([]byte, 16)
_, err = io.ReadFull(reader, ipv6Addr)
if err != nil {
return fmt.Errorf("read ipv6 addr failed: %w", err)
}
// addr = fmt.Sprintf("%c%c")
default:
return fmt.Errorf("not supported addr type: %v", atype)
}
// 读取端口 注意只需要传入大小为2的[]byte缓冲区,如果传入缓冲区过大,会因为没读满造成阻塞
_, err = io.ReadFull(reader, buf[:2])
if err != nil {
return fmt.Errorf("read port failed: %w", err)
}
// 使用大端解析端口号,只用2个字节
port := binary.BigEndian.Uint16(buf[:2])
// 代理服务器与下游服务器(远端)建立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)
}
1.2.3 通信
此时我们已经建立了两个TCP连接,分别是从客户端与代理服务器以及代理服务器与下游服务器之间。 实现通信需要建立两个数据通道,此时检查代码可以发现我们现在拥有三个io对象:
- 从客户端到代理服务器数据的只读流reader
- 客户端到代理服务器的tcp连接conn
- 代理服务器到下游服务器的tcp连接dest
因此我们需要开启从reader到dest,以及从dest到conn的两个单向数据转发,从而实现双向数据转发。这里可以启动两个goroutine来实现,两个goroutine中分别使用标准库的Copy(dst Writer, src Reader) (written int64, err error)函数,该函数的作用是实现将字节流从从src拷贝到dst,直到遇到EOF。
代码:
// connect函数中
// 该代码作用是阻塞程序
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 两个goroutine作用是双向传输数据
// io.copy(writer, reader)
go func() {
// 把从客户端接收的数据发送到下游服务器
_, _ = io.Copy(dest, reader)
cancel()
}()
go func() {
// 把下游服务器返回的数据发送到客户端
_, _ = io.Copy(conn, dest)
cancel()
}()
// 当上面两个io.Copy出错时会调用cancel,该代码继续执行
// 如果缺少该部分代码,由于goroutine创建的时间很短,该程序会直接结束,中止双方的tcp连接
<-ctx.Done()
1.3 结果展示
客户端终端输入.\curl.exe --socks5 127.0.0.1:1080 -v https://www.baidu.com
代理终端显示
客户端终端显示
可以看到客户端与代理服务器之间建立了连接,代理服务器与下游服务器之间也建立了连接。 最终返回了下游服务器返回HTTP的报文