Subsocks: 用 Go 实现一个 Socks5 安全代理

 

笔者最近读完了 The Go Programming Language, 想写点东西练练手. Go 比较适合写服务器软件, 之前又学习了下 Socks5 协议, 于是决定写一个 Socks5 代理服务器. 目前基本功能已经完成, 部分思路参考了 ginuerzh/gost. 我给它起名为 Subsocks, sub- 意为在 … 之下 (类似于 "subway"). 项目 Repository 在这里: luyuhuang/subsocks. 这里做一个介绍并简单总结下它的实现.

使用

一个 Subsocks 进程可以是客户端或服务端, 这取决于配置. 客户端会接收来自应用 (如浏览器) 的 Socks5 请求, 然后将其封装在指定的安全协议 (如 HTTPS) 中, 然后发送给服务端. 服务端则解包客户端发来的请求并访问网络.

forward

配置文件格式是 TOML. 创建客户端配置文件 cli.toml, 内容如下:

[client]
listen = "127.0.0.1:1030"

server.protocol = "https"
server.address = "SERVER_IP:1080" # replace SERVER_IP with the server IP

http.path = "/proxy" # same as http.path of the server
tls.skip_verify = true # skip verify the server's certificate since the certificate is self-signed

然后启动客户端:

subsocks -c cli.toml

同样创建服务端配置文件 ser.toml:

[server]
listen = "0.0.0.0:1080"
protocol = "https"

http.path = "/proxy"

然后启动服务端:

subsocks -c ser.toml

然后我们将浏览器 Socks5 代理地址设置为 127.0.0.1:1030 就可以使用了. 上面的例子会使用自动生成的自签名证书, 因此客户端要设置 tls.skip_verify = true 跳过证书认证. 这种做法可能会被中间人攻击. 更安全的做法是服务端配置 tls.certtls.key 设置自定义证书, 如果证书不是权威 CA 签名的, 客户端还需要配置 tls.ca 进行证书锁定. 详细的文档见项目主页.

协议栈

因为 Subsocks 是将 Socks5 套在其他协议中, 比起普通的 Socks5 代理, Subsocks 的协议栈多出了几层. 我们可能需要在 HTTP 或 Websocket 之上实现 Socks5.

protocols

Go 做这个再合适不过了. 比如说, 在一个普通的 TCP 连接上加上 TLS 层, 使之成为一个 TLS 连接, 一行代码就能搞定:

tlsConn := tls.Client(conn, tlsConfig)

tls.Client 传入一个 net.Conn 对象和 TLS 配置, 返回一个新的 net.Conn 对象. net.Conn 是一个接口, 我们只需关心其中的 Read, Write 等方法, 其它的一切都是透明的. 这样返回的 tlsConn 就可以当作普通的连接使用, 完全不用关心 TLS 的细节.

类似的, 我们可以实现一个 HTTP wrapper, 传入一个 net.Conn 对象, 在这个连接上加上 HTTP 协议层, 然后返回一个新的 net.Conn 对象. 在新的连接对象上 Write 的任何数据都会被包在一个 HTTP 请求中; Read 的任何数据都会被剥离掉 HTTP 首部. 我们只需写一个实现了 net.Conn 接口的类型即可.

type httpWrapper struct {
	net.Conn
	client *Client
	buf    *bytes.Buffer
	ioBuf  *bufio.Reader
}

func newHTTPWrapper(conn net.Conn, client *Client) *httpWrapper

func (h *httpWrapper) Read(b []byte) (n int, err error) {
	if len(b) == 0 {
		return 0, nil
	}

	if h.buf.Len() > 0 {
		return h.buf.Read(b)
	}

	res, err := http.ReadResponse(h.ioBuf, nil)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()

	if n, err = res.Body.Read(b); err != nil && err != io.EOF {
		return
	}
	if _, err = h.buf.ReadFrom(res.Body); err != nil && err != io.EOF {
		return
	}
	err = nil
	return
}

func (h *httpWrapper) Write(b []byte) (n int, err error) {
	req := http.Request{
		Method: "POST",
		Proto:  "HTTP/1.1",
		URL: &url.URL{
			Scheme: h.client.Config.ServerProtocol,
			Host:   h.client.Config.ServerAddr,
			Path:   h.client.Config.HTTPPath,
		},
		Host:          h.client.Config.ServerAddr,
		ContentLength: int64(len(b)),
		Body:          ioutil.NopCloser(bytes.NewBuffer(b)),
	}
	if err := req.Write(h.Conn); err != nil {
		return 0, err
	}
	return len(b), nil
}

httpWrapper 嵌入了 net.Conn, httpWrapper.Conn 会被赋值为原始连接, 因此 net.Conn 的其它方法, 如 LocalAddr, 都等同与原始连接的方法. 我们只实现 ReadWrite.

Read 的思路是将 HTTP 消息的 Body 读到一个缓冲区中, 每次读的时候都在缓冲区中读, 缓冲区空则等待. 实际实现不必这么麻烦: 如果缓冲区不为空则读缓冲区; 否则读 HTTP 消息取其 Body 的数据, 若未读尽, 则将剩余数据写入缓冲区. 这样做的效果是一样的.

Write 则比较简单, 直接将数据封装在 HTTP 请求中再发送出去即可. 当然这里也可以做一些优化, 如果数据较小则写入缓冲区再定时封装成 HTTP 请求发送出去.

HTTPS 其实就是 HTTP over TLS, 因此只需要在 HTTP wrapper 的基础上加上 TLS 层就能得到 HTTPS wrapper 了:

httpsConn := newHTTPWrapper(tls.Client(conn, tlsConfig), client)

按照这个思路实现其它协议的 wrapper, 使用的时候根据配置调用不同的 wrapper, 将普通的 TCP 连接包装成特定协议的连接, 然后在其之上实现 Socks5 即可. 目前我实现了 HTTP 和 Websocket, 这种模式也方便以后扩展新的协议.

Socks5

作为一个代理协议, Socks5 的思路基本上是发送一个代理请求, 然后建立代理状态, 之后的数据盲转发. Connect 方法的流程如下图所示, 注意 Subsocks 客户端与服务端之间传递的所有数据都会被封装在指定的协议中. 整个流程与 HTTP 的 Connect 方法大致相同.

connect

Bind 的方法的流程与 Connect 方法大致相同, 不同的是服务端不会主动连接目标服务器而是开放端口等待目标服务器的连接. 代理状态建立之后, 同样对接下来的数据盲转发.

UDP associate 稍微麻烦些. 因为 Subsocks 客户端与服务端之间是 TCP 协议, UDP associate 方法不能直接用. 这里我们参考 gost 的做法, 稍稍修改 Socks5 协议, 增加一个 UDP over TCP 方法. 流程有以下几步:

  • 客户端连接建立到服务端的 TCP 连接;
  • 协商 (与其他方法相同);
  • 客户端发送 UDP over TCP 请求, DST.ADDR 和 DST.PORT 字段可为空;
  • 服务端创建 UDP 套接字, 返回其地址给客户端;
  • 接下来客户端发送给服务端的数据格式与 Socks5 UDP 请求的格式相同 (见 RFC 1928 第 7 节), 服务端也会将 UDP 套接字收到的所有数据封装在这样的格式中发送给客户端. 具体做法是:
    • RSV 字段表示该段数据包的长度, 因为 TCP 是面向流的协议, 不保留消息边界.
    • 服务端会根据客户端数据包 DST.ADDR 和 DST.PORT 字段指定的地址, 将数据以 UDP 协议发送给目标服务器;
    • 服务端发给客户端的数据包中的 DST.ADDR 和 DST.PORT 字段则表示目标服务器的 UDP 地址.

Again, UDP over TCP 方法传递的数据也会封装在指定的协议中. 至此, 我们就完全实现了 Socks5 协议. 得益于 Go 的很多方便的特性, 整体实现起来并不复杂. 具体实现可参考源代码.

构建与发布

这个项目我比较好地应用了 Github Actions. 我希望的是每打一个 tag, 就触发 action, 编译出各个平台的二进制文件, 然后上传到 release page.

得益于 Go 的交叉编译, 我们很容易编译出各个平台的二进制文件. 我简单写了一个脚本来做这件事:

#!/usr/bin/env bash

platforms=(
"linux amd64"
"linux 386"
"windows amd64 .exe"
"windows 386 .exe"
"darwin amd64"
)

rm -rf targets
mkdir -p targets

for p in "${platforms[@]}"; do
	eval $(echo $p | awk '{printf"os=%s;arch=%s;ext=%s",$1,$2,$3}')
	GOOS=$os GOARCH=$arch go build -o targets/subsocks-$os-$arch$ext
done

接下来只需要在 Github Actions 中执行这个脚本就可以了. 怎么上传到 release page 呢? Github Actions 中可以直接使用 hub 命令, Github 的很多操作都能用它完成. 如下的命令就能创建一个带附件的 release:

hub release create -a FILE -m MESSAGE TAG

我的 release.yml 是这样写的:

name: Release

on:
  push:
    tags:
    - 'v*'

jobs:
  binary:
    name: Release binary files
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/[email protected]
      with:
        go-version: ^1.13

    - name: Checkout
      uses: actions/[email protected]

    - name: Build
      run: |
        bash build.sh

    - name: Release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        set -x
        assets=()
        for asset in targets/*; do
          assets+=("-a" "$asset")
        done
        tag_name="${GITHUB_REF##*/}"
        hub release create "${assets[@]}" -m "$tag_name" "$tag_name"

后续工作

目前还没有实现任何用户认证机制, 只能通过配置一个秘密的 path 实现访问控制. 之后可以实现 HTTP 摘要认证以达到访问控制的目的. 我并不打算实现 Socks5 的协商认证机制, 既然 Subsocks 的 Socks5 是基于其他协议的, 在下层协议做认证显然更好.

另外一个想做的事是智能代理. 它可以根据一些规则决定一次连接或请求是否经由代理.

还有就是可以扩展支持更多的协议, 比如 SSH 之类. 不过目前感觉意义不大. 比起这个, 让客户端支持 HTTP 代理可能更有用些, 因为有些软件只支持 HTTP 代理.