使用nodejs实现socks5协议

作为程序员大家都接触过ss、ssr、v2ray这些工具吧,这些工具与客户端通信基本都是用socks5协议,有必要深入了解一下

#socks5 介绍

socks5s是一种网络传输协议,主要用于客户端与外网服务器之间通讯的中间传递。当防火墙后的客户端要访问外部的服务器时,就跟SOCKS代理服务器连接。这个代理服务器控制客户端访问外网的资格,允许的话,就将客户端的请求发往外部的服务器。 参考图
根据OSI模型,SOCKS是会话层的协议,位于表示层与传输层之间,也就是说socks是在TCP 之上的协议。

和HTTP代理相比

HTTP代理只能代理http请求,像TCP、HTTPS这些协议显得很无力,有一定的局限性。
SOCKS工作在比HTTP代理更低的层次:SOCKS使用握手协议来通知代理软件其客户端试图进行的连接SOCKS,然后尽可能透明地进行操作,而常规代理可能会解释和>重写报头(例如,使用另一种底层协议,例如FTP;然而,HTTP代理只是将HTTP请求转发到所需的HTTP服务器)。虽然HTTP代理有不同的使用模式,CONNECT方法允
许转发TCP连接;然而,SOCKS代理还可以转发UDP流量和反向代理,而HTTP代理不能。HTTP代理通常更了解HTTP协议,执行更高层次的过滤(虽然通常只用于GET和
POST方法,而不用于CONNECT方法)。

#SOCKS协议内容

官方协议RFC

选择认证方法

大体说下socks连接过程,首先客户端发送一个数据包到socks代理

Var NMETHODS METHODS
1 1 0-255

表格里面的单位表示位数

  • Var 表示是SOCK版本,应该是5;
  • NMETHODS 表示 METHODS部分的长度
  • METHODS 表示支持客户端支持的认证方式列表,每个方法占1字节。当前的定义是
    • 0x00 不需要认证
    • 0x01 GSSAPI
    • 0x02 用户名、密码认证
    • 0x03 - 0x7F由IANA分配(保留)
    • 0x80 - 0xFE为私人方法保留
    • 0xFF 无可接受的方法

服务器会响应给客户端

VER METHOD
1 1
  • Var 表示是SOCK版本,应该是5;
  • METHOD是服务端选中方法,这个的值为上面METHODS 列表中一个。如果客户端支持0x00,0x01,0x02,这三个方法。服务器只会选中一个认证方法返回给客户端,如果返回0xFF表示没有一个认证方法被选中,客户端需要关闭连接。
    我们先用一个简单Nodejs在实现sock连接握手.查看客户端发送数据报
    1
    2
    3
    4
    5
    6
    7
    const net = require('net');
    let server = net.createServer(sock =>{
    sock.once('data', (data)=>{
    console.log(data);
    });
    });
    server.listen(8888,'localhost');

使用curl工具连接nodejs

1
curl -x socks5://localhost:8888 https://www.baidu.com

console输出

<Buffer 05 02 00 01>

使用账号密码认证

当服务器选择0x02 账号密码方式认证后,客户端开始发送账号 、密码,数据包格式如下: (以字节为单位)

VER ULEN UNAME PLEN PASSWD
1 1 1 to 255 1 1 to 255
  • VER是SOCKS版本
  • ULEN 用户名长度
  • UNAME 账号string
  • PLEN 密码长度
  • PASSWD 密码string

可以看出账号密码都是明文传输,非常地不安全。
服务器端校验完成后,会响应以下数据():

VER STATUS
1 1
- STATUS 0x00 表示成功,0x01 表示失败

封装请求

认证结束后客户端就可以发送请求信息。客户端开始封装请求信息
SOCKS5请求格式(以字节为单位):

VER CMD RSV ATYP DST.ADDR DST.PORT
1 1 0x00 1 动态 2
  • VER是SOCKS版本,这里应该是0x05;

  • CMD是SOCK的命令码

    • 0x01表示CONNECT请求

      • CONNECT请求可以开启一个客户端与所请求资源之间的双向沟通的通道。它可以用来创建隧道(tunnel)。例如,*CONNECT *可以用来访问采用了 SSL (HTTPS) 协议的站点。客户端要求代理服务器将 TCP 连接作为通往目的主机隧道。之后该服务器会代替客户端与目的主机建立连接。连接建立好之后,代理服务器会面向客户端发送或接收 TCP 消息流。
    • 0x02表示BIND请求

      Bind方法使用于目标主机需要主动连接客户机的情况(ftp协议)

      当服务端接收到的数据包中CMD为X’02’时,服务器使用Bind方法进行代理。使用Bind方法代理时服务端需要回复客户端至多两次数据包。

      服务端使用TCP协议连接对应的(DST.ADDR, DST.PORT),如果失败则返回失败状态的数据包并且关闭此次会话。如果成功,则监听(BND.ADDR, BND.PORT)来接受请求的主机的请求,然后返回第一次数据包,该数据包用以让客户机发送指定目标主机连接客户机地址和端口的数据包。

      在目标主机连接服务端指定的地址和端口成功或失败之后,回复第二次数据包。此时的(BND.ADDR, BND.PORT)应该为目标主机与服务端建立的连接的地址和端口。

    • 0x03表示UDP转发

  • RSV 0x00,保留

  • ATYP 类型

    • 0x01 IPv4地址,DST.ADDR部分4字节长度
    • 0x03 域名,DST.ADDR部分第一个字节为域名长度,DST.ADDR剩余的内容为域名,没有\0结尾。
    • 0x04 IPv6地址,16个字节长度。
  • DST.ADDR 目的地址

  • DST.PORT 网络字节序表示的目的端口
    示例数据

    <Buffer 05 01 00 01 0e d7 b1 26 01 bb>

服务器根据客户端封装数据,请求远端服务器,将下面固定格式响应给客户端。

VER REP RSV ATYP BND.ADDR BND.PORT
1 1 0x00 1 动态 2
  • VER是SOCKS版本,这里应该是0x05;
  • REP应答字段
    • 0x00表示成功
    • 0x01普通SOCKS服务器连接失败
    • 0x02现有规则不允许连接
    • 0x03网络不可达
    • 0x04主机不可达
    • 0x05连接被拒
    • 0x06 TTL超时
    • 0x07不支持的命令
    • 0x08不支持的地址类型
    • 0x09 - 0xFF未定义
  • RSV 0x00,保留
  • ATYP
    • 0x01 IPv4地址,DST.ADDR部分4字节长度
    • 0x03域名,DST.ADDR部分第一个字节为域名长度,DST.ADDR剩余的内容为域名,没有\0结尾。
    • 0x04 IPv6地址,16个字节长度。
  • BND.ADDR 服务器绑定的地址
  • BND.PORT 网络字节序表示的服务器绑定的端口

使用nodejs 实现CONNECT请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
const net = require('net');
const dns = require('dns');
const AUTHMETHODS = { //只支持这两种方法认证
NOAUTH: 0,
USERPASS: 2
}

//创建socks5监听

let socket = net.createServer(sock => {

//监听错误
sock.on('error', (err) => {
console.error('error code %s',err.code);
console.error(err);
});

sock.on('close', () => {
sock.destroyed || sock.destroy();
});

sock.once('data', autherHandler.bind(sock)); //处理认证方式
});

let autherHandler = function (data) {
let sock = this;
console.log('autherHandler ', data);
const VERSION = parseInt(data[0], 10);
if (VERSION != 5) { //不支持其他版本socks协议
sock.destoryed || sock.destory();
return false;
}
const methodBuf = data.slice(2); //方法列表

let methods = [];
for (let i = 0; i < methodBuf.length; i++)
methods.push(methodBuf[i]);
//先判断账号密码方式
let kind = methods.find(method => method === AUTHMETHODS.USERPASS);
if (kind) {
let buf = Buffer.from([VERSION, AUTHMETHODS.USERPASS]);
sock.write(buf);
sock.once('data', passwdHandler.bind(sock));
} else {
kind = methods.find(method => method === AUTHMETHODS.NOAUTH);
if (kind === 0) {
let buf = Buffer.from([VERSION, AUTHMETHODS.NOAUTH]);
sock.write(buf);
sock.once('data', requestHandler.bind(sock));
} else {
let buf = Buffer.from([VERSION, 0xff]);
sock.write(buf);
return false;
}
}

}

/**
* 认证账号密码
*/
let passwdHandler = function (data) {
let sock = this;
console.log('data ', data);
let ulen = parseInt(data[1], 10);
let username = data.slice(2, 2 + ulen).toString('utf8');
let password = data.slice(3 + ulen).toString('utf8');
if (username === 'admin' && password === '123456') {
sock.write(Buffer.from([5, 0]));
} else {
sock.write(Buffer.from([5, 1]));
return false;
}
sock.once('data', requestHandler.bind(sock));
}

/**
* 处理客户端请求
*/
let requestHandler = function (data) {
let sock = this;
const VERSION = data[0];
let cmd = data[1]; // 0x01 先支持 CONNECT连接
if(cmd !== 1)
console.error('不支持其他连接 %d',cmd);
let flag = VERSION === 5 && cmd < 4 && data[2] === 0;
if (! flag)
return false;
let atyp = data[3];
let host,
port = data.slice(data.length - 2).readInt16BE(0);
let copyBuf = Buffer.allocUnsafe(data.length);
data.copy(copyBuf);
if (atyp === 1) { //使用ip 连接
host = hostname(data.slice(4, 8));
//开始连接主机!
connect(host, port, copyBuf, sock);

} else if (atyp === 3) { //使用域名
let len = parseInt(data[4], 10);
host = data.slice(5, 5 + len).toString('utf8');
if (!domainVerify(host)){
console.log('domain is fialure %s ', host);
return false;
}
console.log('host %s', host);
dns.lookup(host, (err, ip, version) => {
if(err){
console.log(err)
return;
}
connect(ip, port, copyBuf, sock);
});

}
}

let connect = function (host, port, data, sock) {
if(port < 0 || host === '127.0.0.1')
return;
console.log('host %s port %d', host, port);
let socket = new net.Socket();
socket.connect(port, host, () => {
data[1] = 0x00;
if(sock.writable){
sock.write(data);
sock.pipe(socket);
socket.pipe(sock);
}
});

socket.on('close', () => {
socket.destroyed || socket.destroy();
});

socket.on('error', err => {
if (err) {
console.error('connect %s:%d err',host,port);
data[1] = 0x03;
if(sock.writable)
sock.end(data);
console.error(err);
socket.end();
}
})
}

let hostname = function (buf) {
let hostName = '';
if (buf.length === 4) {
for (let i = 0; i < buf.length; i++) {
hostName += parseInt(buf[i], 10);
if (i !== 3)
hostName += '.';
}
} else if (buf.length == 16) {
for (let i = 0; i < 16; i += 2) {
let part = buf.slice(i, i + 2).readUInt16BE(0).toString(16);
hostName += part;
if (i != 14)
hostName += ':';
}
}
return hostName;
}

/**
* 校验域名是否合法
*/
let domainVerify = function (host) {
let regex = new RegExp(/^([a-zA-Z0-9|\-|_]+\.)?[a-zA-Z0-9|\-|_]+\.[a-zA-Z0-9|\-|_]+(\.[a-zA-Z0-9|\-|_]+)*$/);
return regex.test(host);
}


socket.listen(8888,() => console.log('socks5 proxy running ...')).on('error', err => console.error(err));

end

和浏览器结合使用的,发现没办法加载斗鱼的视频,不知什么原理,优酷都没有什么问题的.
刚刚学习NodeJs一些知识点,写得一般般,有哪里写得不好的,请大家指出来,大家一起讨论。一开始在看协议的时候,以为客户端(浏览器)和服务器在认证请求完后,双方会保持一个TCP长连接,客户端直接发送封装请求数据包.实际上客户端每一个请求都是从认证开始的,每一个请求都是相互独立的,所以once这个方法特别适合这里