在Cloudflare Workers上实现SOCKS5客户端

2023-06-01

Cloudflare Workers是著名的Serverless运行平台,其底层为Chrome V8引擎,可以在Cloudflare的全球分布式节点上运行JavaScript/WASM。近期,Cloudflare Workers新发布了一个用于创建TCP套接字的API - connect()。利用此API可以在Workers上实现基于TCP的会话层协议,例如SOCKS5。本文将会讲解SOCKS5协议细节,并在Workers上实现SOCKS5客户端。

SOCKS5应作为中间件使用,为此可以设计一个函数,对connect()进行封装:

/**
 * 
 * @param {Array} socks5
 * @param {number} addressType
 * @param {string} addressRemote
 * @param {number} portRemote
 */
export async function socks5Connect(socks5, addressType, addressRemote, portRemote) {
  const [port, hostname, password, username] = socks5.reverse();
  // Connect to the SOCKS server
  const socket = connect({
    hostname,
    port,
  });
  // Communicate with the SOCKS server
  // ...
  return socket;
}

SOCKS5代理过程分为三个阶段: 握手(包括协商和认证)、请求、Relay

握手

握手阶段包含协商和认证,其中协商阶段对协议版本与认证方法进行确认。而认证阶段进行身份验证,no authorization模式可以省去。

协商

首先客户端向SOCKS5服务器发送greeting请求,其请求的内容为:

+-----+---------+----------+
| VER |  NAUTH  |   AUTH   |
+-----+---------+----------+
|  1  |    1    | 1 to 255 |
+-----+---------+----------+

其中VER为版本号,NAUTH表示AUTH字段的字节数,即客户端允许的认证方法数。 AUTH可选值有:

  • 0x00 不需要认证
  • 0x01 GSSAPI
  • 0x02 用户名、密码认证
  • 0x03 - 0x7F由IANA分配(保留)
    • 0x03: 握手挑战认证协议
    • 0x04: 未分派
    • 0x05: 响应挑战认证方法
    • 0x06: 传输层安全
    • 0x07: NDS认证
    • 0x08: 多认证框架
    • 0x09: JSON参数块
    • 0x0A–0x7F: 未分派
  • 0x80 - 0xFE 为私人方法保留
  • 0xFF 无可接受的方法

本文只实现其中最常用的两种:0x000x02。以下代码构造greeting内容并发送到SOCKS5服务器:

const socksGreeting = new Uint8Array([5, 2, 0, 2]);
const writer = socket.writable.getWriter();
await writer.write(socksGreeting);

服务端收到greeting请求后会向客户端发送如下格式的信息:

+-----+---------+
| VER |  CAUTH  |
+-----+---------+
|  1  |    1    |
+-----+---------+

其中CAUTH表示被选中的认证方法,返回值为0xFF时表示没有可接受的认证方法。可以通过以下代码判断其返回值:

const reader = socket.readable.getReader();
let res = (await reader.read()).value;
if (res[0] !== 0x05) {
  console.log(`socks server version error: ${res[0]} expected: 5`)
  return;
}
if (res[1] === 0xff) {
  console.log("no acceptable methods")
  return;
}

当返回值为0x0500时表示不需要认证,对于返回值为0x0502的情况我们进入认证阶段。

认证

用户名/密码认证时,客户端先向服务端发送发送如下格式的数据:

+----+------+----------+------+----------+
|VER | ULEN |  UNAME   | PLEN |  PASSWD  |
+----+------+----------+------+----------+
| 1  |  1   | 1 to 255 |  1   | 1 to 255 |
+----+------+----------+------+----------+

其中VER为该认证方式的版本号,与上述协议版本不同,目前恒为0x01。服务端校验客户端认证信息后,返回如下格式响应结果:

+-----+--------+
| VER | STATUS |
+-----+--------+
|  1  |   1    |
+-----+--------+

VER值与客户端请求对应,恒为0x01。仅当响应结果为0x0100时表示认证成功。

if (res[1] === 0x02) {
  console.log("socks server needs auth")
  if (!username || !password) {
    console.log("please provide username/password")
    return;
  }
  const authRequest = new Uint8Array([
    1,
    username.length,
    ...(new TextEncoder()).encode(username),
    password.length,
    ...(new TextEncoder()).encode(password)
  ]);
  await writer.write(authRequest);
  res = (await reader.read()).value;
  // expected 0x0100
  if (res[0] !== 0x01 || res[1] !== 0x00) {
    console.log("fail to auth:", res.join(","))
    return;
  }
}

Relay

当认证通过或无需认证时进入请求与转发阶段。在该阶段,客户端先向服务端请求需要访问的地址。

其请求数据中的地址部分的格式为:

+------+----------+
| TYPE |   ADDR   |
+------+----------+
|  1   | Variable |
+------+----------+

TYPE表示该地址的类型,可选值有:

  • 0x01: IPv4 地址
  • 0x03: 域名
  • 0x04: IPv6 地址

紧随其后的ADDR根据其类型也有三种情况:

  • IPv4:4字节数据
  • 域名:开头一字节表示域名长度,其后1-255字节为域名
  • IPv6:16字节数据

完整的请求数据的结构为:

+----+-----+-----+----------+---------+
|VER | CMD | RSV | DSTADDR  | DSTPORT |
+----+-----+-----+----------+---------+
| 1  |  1  |  1  | Variable |   2     |
+----+-----+-----+----------+---------+

VER为协议版本,CMD为命令码:

  • 0x01: 表示CONNECT请求,即建立 TCP/IP 流连接
  • 0x02: 表示BIND请求,即建立 TCP/IP 端口绑定
  • 0x03: 表示UDP转发,即分配 UDP 端口

RSV为保留字段,必须为0x00DSTPORT为端口号,字节序为网络字节序(大端)

// addressType
// 1--> ipv4
// 2--> domain name
// 3--> ipv6
let DSTADDR;
if (addressType === 1) {
  DSTADDR = new Uint8Array([1, ...addressRemote.split('.').map(Number)]);
} else if (addressType === 2) {
  DSTADDR = new Uint8Array(
    [3, addressRemote.length, ...(new TextEncoder()).encode(addressRemote)]
  );
} else if (addressType === 3) {
  DSTADDR = new Uint8Array(
    [4, ...addressRemote.split(':').flatMap(x => [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2), 16)])]
  )
}
const socksRequest = new Uint8Array([5, 1, 0, ...DSTADDR, portRemote >> 8, portRemote & 0xff]);
await writer.write(socksRequest);
console.log('Sent socks request');

服务端返回数据的格式为:

+-----+--------+-----+----------+---------+
| VER | STATUS | RSV | BNDADDR  | BNDPORT |
+-----+--------+-----+----------+---------+
|  1  |   1    |  1  | Variable |   2     |
+-----+--------+-----+----------+---------+

STATUS 为状态码:

  • 0x00: 请求被批准
  • 0x01: 一般失败
  • 0x02: 规则集不允许连接
  • 0x03: 网络不可达
  • 0x04: 主机不可达
  • 0x05: 连接被目标主机拒绝
  • 0x06: TTL 超时
  • 0x07: 命令不支持或协议错误
  • 0x08: 地址类型不支持

这里BNDADDRBNDPORT表示绑定地址与绑定端口,其中BNDADDR的格式与上述客户端请求数据中的地址格式相同。这两个数据表示relay服务器的地址与端口,当socks5服务器与relay服务器不同时,socks5服务器可以通过这两个字段指定relay服务器。客户端应根据该字段将后续请求发送到relay服务器,并从其获取响应数据。不过一般情况下relay服务器与socks5服务器是同一台,此时BNDADDRBNDPORT全部为0。为降低复杂性,本文仅考虑socks5服务器与relay服务器相同的情况。同时考虑到鲁棒性,下述代码仅对STATUS进行校验。

res = (await reader.read()).value;
if (res[1] === 0x00) {
  console.log("socks connection opened:", res.join(","))
} else {
  console.log("fail to open socks:", res.join(","))
  return;
}
writer.releaseLock();
reader.releaseLock();

最后释放读写锁。此后即进入转发阶段,正常发送/读取数据即可。

利用STUN协议获取公网IP

如何让你的Linux服务器有一个炫酷的motd