三百行代码搭建一个简单的 SOCKS5 代理服务器
SOCKS是一种网络传输协议,主要用于客户端与外网服务器之间通讯的中间传递。当防火墙后的客户端要访问外部的服务器时,就跟SOCKS代理服务器连接。这个代理服务器控制客户端访问外网的资格,允许的话,就将客户端的请求发往外部的服务器。
这个协议最初由David Koblas开发,而后由NEC的Ying-Da Lee将其扩展到SOCKS4。最新协议是SOCKS5,与前一版本相比,增加支持UDP、验证,以及IPv6。根据OSI模型,SOCKS是会话层的协议,位于表示层与传输层之间。
SOCKS工作在比HTTP代理更低的层次:SOCKS使用握手协议来通知代理软件其客户端试图进行的SOCKS连接,然后尽可能透明地进行操作,而常规代理可能会解释和重写报头(例如,使用另一种底层协议,例如FTP;然而,HTTP代理只是将HTTP请求转发到所需的HTTP服务器)。虽然HTTP代理有不同的使用模式,HTTP CONNECT方法允许转发TCP连接;然而,SOCKS代理还可以转发UDP流量(仅SOCKS5),而HTTP代理不能。HTTP代理通常更了解HTTP协议,执行更高层次的过滤(虽然通常只用于GET和POST方法,而不用于CONNECT方法)。
(以上摘自维基百科)
协议部分
SOCKS5 协议是 1996 年发布的,迄今为止已经 25 年了,很多软件内部都支持 SOCKS5 代理。比如浏览器一般都会支持,方便有些用户通过 SOCK5 代理浏览网页。我在开发过程中也使用 CURL 进行过测试,也是支持的。也可以在系统设置里面找到代理设置。在软件开发测试领域,也有很多工具软件支持。需要测试的设备连上代理服务器后,所有流量都会经过相关工具软件,可以方便测试、调试软件。
如通过 Charles 可以查看 HTTP Request/Response 报文信息(非 HTTPS 网站)。可以统计 URL 访问次数、返回延时、数据包大小等非常有用的信息。代理服务器本身不会查看和修改所转发的数据,只是进行简单的转发。甚至不理解转发的内容,现在大部分网站都是基于 HTTPS 加密的,代理服务器没办法了解具体通信内容。当然如果是 HTTP 网站,数据都是明文传输,所有数据都可以看到。
本文主要介绍 SOCKS Protocol Version 5协议,原文比较简单,规定的是应用程序如何与代理服务器进行握手,握手成功后就行流量的正向及反向转发。接下来会先介绍下 SOCKS5 协议,然后通过 Node.js 实现一个简单的代理服务器。
1.协议从协商认证方法开始
/**
* The client connects to the server, and sends a version identifier/method selection message:
* +----+----------+----------+
* |VER | NMETHODS | METHODS |
* +----+----------+----------+
* | 1 | 1 | 1 to 255 |
* +----+----------+----------+
*/
客户端应用程序连接上服务器,发送协议版本号、客户端支持的认证方法列表给服务器。上面注释中数字代表字节长度,比如 VER 为 1 字节长,后文中出现类似的数字都代表字节长度,不再赘述。
- VER 字段是 SOCKS 协议版本号,传 X'05',1 字节长
- NMETHODS 字段是客户端支持的认证方法数量,每种认证方法用 1 字节进行编码,所以也决定了 METHODS 字段的长度
- METHODS 依次写入支持的认证方法编码
2.服务器根据客户端上报的方法列表选择,回复方法编码
/**
* The server selects from one of the methods given in METHODS, and sends a METHOD selection message
* +----+--------+
* |VER | METHOD |
* +----+--------+
* | 1 | 1 |
* +----+--------+
*/
- VER 字段是 SOCKS 协议版本号,传 X'05',1 字节长
- METHOD 字段是服务器选择的认证方法编码, 1 字节长
下面定义了认证方法的编码 X'00'
代表 1 字节长的十六进制数字
- X'00' NO AUTHENTICATION REQUIRED // 不需要认证
- X'01' GSSAPI // GSSAPI 认证,在 RFC 1961 里规定
- X'02' USERNAME/PASSWORD // 用户名密码认证,在 RFC 1929 里规定
- X'03' to X'7F' IANA ASSIGNED // 未分配
- X'80' to X'FE' RESERVED FOR PRIVATE METHODS // 保留
- X'FF' NO ACCEPTABLE METHODS // 服务器发现客户端上报的方法列表都不合适时回复 X'FF'
一般服务器必须实现 GSSAPI 方法,建议实现 USERNAME/PASSWORD。比较安全的环境也可以不用认证。
3.客户端认证成功后发送请求信息
/**
* The SOCKS request is formed as follows:
* +----+-----+-------+------+----------+----------+
* |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
* +----+-----+-------+------+----------+----------+
* | 1 | 1 | X'00' | 1 | Variable | 2 |
* +----+-----+-------+------+----------+----------+
*/
- VER 字段是 SOCKS 协议版本号,传 X'05',1 字节长
- CMD 字段是请求命令,1字节长
- CONNECT X'01' 代表连接目标服务器命令,本文会实现 CONNECT 请求命令
- BIND X'02' 暂时不介绍
- UDP ASSOCIATE X'03' 暂时不介绍
- RSV 保留字节,传 X'00',1字节长
- ATYP 目标地址类型,1字节长
- IP V4 address: X'01' IPV4
- DOMAINNAME: X'03' 域名
- IP V6 address: X'04' IPV6
- DST.ADDR 客户端想要请求的目标地址
- DST.PORT 客户端想连接的目标端口号,2 字节长,网络字节序
DST.ADDR 字段根据地址类型不同,长度不同。
- 如果IPV4 是固定 4 字节长。
- 如果是域名,则首个字节代表域名的长度(字节数),接下来的可变长度是域名字符串
4.服务端评估该请求并回复
/**
* The server evaluates the request, and returns a reply formed as follows:
* +----+-----+-------+------+----------+----------+
* |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
* +----+-----+-------+------+----------+----------+
* | 1 | 1 | X'00' | 1 | Variable | 2 |
* +----+-----+-------+------+----------+----------+
*/
- VER 字段是 SOCKS 协议版本号,传 X'05',1 字节长
- REP 回复字段:
- X'00' 成功
- X'01' general SOCKS server failure
- X'02' connection not allowed by ruleset
- X'03' Network unreachable
- X'04' Host unreachable
- X'05' Connection refused
- X'06' TTL expired
- X'07' 请求命令不支持
- X'08' 地址类型不支持
- X'09' to X'FF' 未分配
- RSV 保留字段,传 X'00'
- ATYP 地址类型
- IP V4 address: X'01'
- DOMAINNAME: X'03'
- IP V6 address: X'04'
- BND.ADDR 代理服务器连接目标服务器的地址
- BND.PORT 代理服务器连接目标服务器的端口,2 字节长,网络字节序
服务器可以评估是否允许该请求,如果允许则代表客户端连接目标服务器( DST.ADDR:DST.PORT ),连接成功后回复客户端 REP 为成功,握手过程完成,后面就可以开始代理转发数据了。如果评估不允许该请求或者因为其他原因不能连接上目标服务器,则需返回对应回复信息。
(以上为协议部分)
代码部分
consts.js 常量定义文件
/** socks 5 */
const SOCKS_VERSION = 0x05,
STATE = {
METHOD_NEGOTIATION: 0x00,
AUTHENTICATION: 0x01,
REQUEST_CONNECT: 0x02,
PROXY_FORWARD: 0x03
},
/**
* o X'00' NO AUTHENTICATION REQUIRED
* o X'01' GSSAPI
* o X'02' USERNAME/PASSWORD
* o X'03' to X'7F' IANA ASSIGNED
* o X'80' to X'FE' RESERVED FOR PRIVATE METHODS
* o X'FF' NO ACCEPTABLE METHODS
*/
METHODS = {
NO_AUTH: [0x00, 'no_auth'],
GSSAPI: [0x01, 'gssapi'],
USERNAME_PASSWD: [0x02, 'username_password'],
NO_ACCEPTABLE: [0xFF, 'no_acceptable_methods'],
get(method) {
switch(method) {
case this.NO_AUTH[0]:
return this.NO_AUTH
case this.GSSAPI[0]:
return this.GSSAPI
case this.USERNAME_PASSWD[0]:
return this.USERNAME_PASSWD
}
console.error(`method [${method}] is not supported`)
return false
}
},
/**
* o CONNECT X'01'
* o BIND X'02'
* o UDP ASSOCIATE X'03'
*/
REQUEST_CMD = {
CONNECT: [0x01, 'connect'],
BIND: [0x02, 'bind'],
UDP_ASSOCIATE: [0x03, 'udp_associate'],
get(cmd) {
switch(cmd) {
case this.CONNECT[0]:
return this.CONNECT
case this.BIND[0]:
return this.BIND
case this.UDP_ASSOCIATE[0]:
return this.UDP_ASSOCIATE
}
console.error(`cmd [${cmd}] is not supported`)
return false
}
},
/** reserved byte value */
RSV = 0x00,
/**
* o IP V4 address: X'01'
* o DOMAINNAME: X'03'
* o IP V6 address: X'04'
*/
ATYP = {
IPV4: [0x01, 'ipv4'],
FQDN: [0x03, 'domain name'],
IPV6: [0x04, 'ipv6'],
get(atyp) {
switch(atyp) {
case this.IPV4[0]:
return this.IPV4
case this.FQDN[0]:
return this.FQDN
case this.IPV6[0]:
return this.IPV6
}
console.error(`atpy [${atyp}] is not supported`)
return false
}
},
/**
* o X'00' succeeded
* o X'01' general SOCKS server failure
* o X'02' connection not allowed by ruleset
* o X'03' Network unreachable
* o X'04' Host unreachable
* o X'05' Connection refused
* o X'06' TTL expired
* o X'07' Command not supported
* o X'08' Address type not supported
* o X'09' to X'FF' unassigned
*/
REP = {
SUCCEEDED: [0x00, 'succeeded'],
GENERAL_FAILURE: [0x01, 'general SOCKS server failure'],
NOT_ALLOWED: [0x02, 'connection not allowed by ruleset'],
NETWORK_UNREACHABLE: [0x03, 'Network unreachable'],
HOST_UNREACHABLE: [0x04, 'Host unreachable'],
CONNECTION_REFUSED: [0x05, 'Connection refused'],
TTL_EXPIRED: [0x06, 'TTL expired'],
COMMAND_NOT_SUPPORTED: [0x07, 'Command not supported'],
ADDRESS_TYPE_NOT_SUPPORTED: [0x08, 'Address type not supported']
},
/**
* The VER field contains the current version of the subnegotiation, which is X'01'
* username/password auth version
*/
USERNAME_PASSWD_AUTH_VERSION = 0x01,
/**
* auth status
*/
AUTH_STATUS = {
SUCCESS: 0x00,
FAILURE: 0X01
}
module.exports = () => {
return {
SOCKS_VERSION: SOCKS_VERSION,
STATE: STATE,
METHODS: METHODS,
REQUEST_CMD: REQUEST_CMD,
RSV: RSV,
ATYP: ATYP,
REP: REP,
USERNAME_PASSWD_AUTH_VERSION, USERNAME_PASSWD_AUTH_VERSION,
AUTH_STATUS: AUTH_STATUS
}
}
config.js 程序配置
const consts = require('../consts')(),
app = {
port: 3000,
host: '0.0.0.0',
auth_method: consts.METHODS.NO_AUTH
}
module.exports = () => {
return app
}
如上,代理服务器监听在 3000 端口,采用无认证方式
app.js 程序启动
const net = require('net'),
server = new net.createServer(),
config = require('./config')(server),
Proxy = require('./proxy')(server)
server.listen(config.port, config.host)
.on('listening', () => {
console.log(`simple-proxy server listening on ${config.port}`)
})
.on('close', () => {
console.log('simple-proxy server closed')
})
.on('error', err => {
console.error('simple-proxy server throw error', err)
})
.on('connection', socket => {
var proxy = Proxy(socket)
// data package come in
socket.on('data', buf => {
proxy.handle(buf)
})
.on('end', () => {
console.log(`socket ${proxy._session.id} end`)
})
.on('close', hadError => {
console.log(`socket ${proxy._session.id} closed with error ${hadError}`)
})
.on('error', err => {
console.error(`socket ${proxy._session.id} throw error`, err)
})
.on('timeout', () => {
console.log(`socket ${proxy._session.id} timeout`)
})
})
可以看到其中依赖了核心 Proxy 类完成代理功能
proxy.js 核心代理实现逻辑
下面代码是核心代理逻辑,也实现了简单的用户名密码认证方式。如果去掉用户名密码认证和注释,核心代码就 300 行左右。Proxy 类主要是实现了 SOCKS5 握手协议,与目标服务器建立连接进行数据转发。每个方法上面都有注释,应该比较容易看懂。
const net = require('net'),
dns = require('dns'),
{ assert } = require('console'),
uuid = require('uuid'),
utils = require('../utils'),
consts = require('../consts')(),
config = require('./config')()
function Proxy(socket) {
return {
/**
* proxy socket
*/
_socket: socket,
/**
* session
*/
_session: {
id: uuid.v1(),
buffer: Buffer.alloc(0),
offset: 0,
state: consts.STATE.METHOD_NEGOTIATION
},
/**
* The client connects to the server, and sends a version identifier/method selection message:
* +----+----------+----------+
* |VER | NMETHODS | METHODS |
* +----+----------+----------+
* | 1 | 1 | 1 to 255 |
* +----+----------+----------+
*/
parseMethods() {
let buf = this._session.buffer
let offset = this._session.offset
var checkNull = offset => {
return typeof buf[offset] === undefined
}
if(checkNull(offset)) {
return false
}
let socksVersion = buf[offset++]
assert(socksVersion == consts.SOCKS_VERSION, `socket ${this._session.id} only support socks version 5, got [${socksVersion}]`)
if(socksVersion != consts.SOCKS_VERSION) {
this._socket.end()
return false
}
if(checkNull(offset)) {
return false
}
let methodLen = buf[offset++]
assert(methodLen >= 1 && methodLen <= 255, `socket ${this._session.id} methodLen's value [${methodLen}] is invalid`)
if(checkNull(offset + methodLen - 1)) {
return false
}
let methods = []
for(let i = 0; i < methodLen; i++) {
let method = consts.METHODS.get(buf[offset++])
if (!!method) {
methods.push(method)
}
}
console.log(`socket ${this._session.id} SOCKS_VERSION: ${socksVersion}`)
console.log(`socket ${this._session.id} METHODS: `, methods)
this._session.offset = offset
return methods
},
/** socks server select auth method */
selectMethod(methods) {
let method = consts.METHODS.NO_ACCEPTABLE
for(let i = 0; i < methods.length; i++) {
if (methods[i] == config.auth_method) {
method = config.auth_method
}
}
console.log(`SELECT METHOD [${method}]`)
this._session.method = method
return method
},
/**
* The server selects from one of the methods given in METHODS, and sends a METHOD selection message
* +----+--------+
* |VER | METHOD |
* +----+--------+
* | 1 | 1 |
* +----+--------+
* @param {*} method auth method selected
*/
replyMethod(method) {
this._socket.write(Buffer.from([consts.SOCKS_VERSION, method[0]]))
},
/**
* This begins with the client producing a Username/Password request:
* +----+------+----------+------+----------+
* |VER | ULEN | UNAME | PLEN | PASSWD |
* +----+------+----------+------+----------+
* | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
* +----+------+----------+------+----------+
*/
parseUsernamePasswd() {
let buf = this._session.buffer
let offset = this._session.offset
var req = {}
var checkNull = offset => {
return typeof buf[offset] === undefined
}
if(checkNull(offset)) {
return false
}
let authVersion = buf[offset++]
assert(authVersion == consts.USERNAME_PASSWD_AUTH_VERSION,
`socket ${this._session.id} only support auth version ${consts.USERNAME_PASSWD_AUTH_VERSION}, got [${authVersion}]`)
if(authVersion != consts.USERNAME_PASSWD_AUTH_VERSION) {
this._socket.end()
return false
}
if(checkNull(offset)) {
return false
}
let uLen = buf[offset++]
assert(uLen >= 1 && uLen <= 255, `socket ${this._session.id} got wrong ULEN [${uLen}]`)
if(uLen >= 1 && uLen <= 255) {
if(checkNull(offset + uLen - 1)) {
return false
}
req.username = buf.slice(offset, offset + uLen).toString('utf8')
offset += uLen
} else {
this._socket.end()
return false
}
if(checkNull(offset)) {
return false
}
let pLen = buf[offset++]
assert(pLen >= 1 && pLen <= 255, `socket ${this._session.id} got wrong PLEN [${pLen}]`)
if(pLen >= 1 && pLen <= 255) {
if(checkNull(offset + pLen - 1)) {
return false
}
req.passwd = buf.slice(offset, offset + pLen).toString('utf8')
offset += pLen
} else {
this._socket.end()
return false
}
this._session.offset = offset
return req
},
/**
* The server verifies the supplied UNAME and PASSWD, and sends the following response:
* +----+--------+
* |VER | STATUS |
* +----+--------+
* | 1 | 1 |
* +----+--------+
*/
replyAuth(succeeded) {
let reply = [
consts.USERNAME_PASSWD_AUTH_VERSION,
succeeded ? consts.AUTH_STATUS.SUCCESS : consts.AUTH_STATUS.FAILURE
]
if (succeeded) {
this._socket.write(Buffer.from(reply))
} else {
this._socket.end(Buffer.from(reply))
}
},
/**
* The SOCKS request is formed as follows:
* +----+-----+-------+------+----------+----------+
* |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
* +----+-----+-------+------+----------+----------+
* | 1 | 1 | X'00' | 1 | Variable | 2 |
* +----+-----+-------+------+----------+----------+
*/
parseRequests() {
let buf = this._session.buffer
let offset = this._session.offset
let req = {}
var checkNull = offset => {
return typeof buf[offset] === undefined
}
if(checkNull(offset)) {
return false
}
let socksVersion = buf[offset++]
assert(socksVersion == consts.SOCKS_VERSION, `socket ${this._session.id} only support socks version 5, got [${socksVersion}]`)
if(socksVersion != consts.SOCKS_VERSION) {
this._socket.end()
return false
}
if(checkNull(offset)) {
return false
}
req.cmd = consts.REQUEST_CMD.get(buf[offset++])
if(!req.cmd || req.cmd != consts.REQUEST_CMD.CONNECT) {
// 不支持的 cmd || 暂时只支持 connect
this._socket.end()
return false
}
if(checkNull(offset)) {
return false
}
req.rsv = buf[offset++]
assert(req.rsv == consts.RSV, `socket ${this._session.id} rsv should be ${consts.RSV}`)
if(checkNull(offset)) {
return false
}
req.atyp = consts.ATYP.get(buf[offset++])
if(!req.atyp) {
// 不支持的 atyp
this._socket.end()
return false
} else if(req.atyp == consts.ATYP.IPV4) {
let ipLen = 4
if(checkNull(offset + ipLen - 1)) {
return false
}
req.ip = `${buf[offset++]}.${buf[offset++]}.${buf[offset++]}.${buf[offset++]}`
} else if(req.atyp == consts.ATYP.FQDN) {
if(checkNull(offset)) {
return false
}
let domainLen = buf[offset++]
if(checkNull(offset + domainLen - 1)) {
return false
}
req.domain = buf.slice(offset, offset + domainLen).toString('utf8')
offset += domainLen
} else {
// 其他暂时不支持
this._socket.end()
return false
}
let portLen = 2
if(checkNull(offset + portLen - 1)) {
return false
}
req.port = buf.readUInt16BE(offset)
offset += portLen
console.log(`socket ${this._session.id} parse requests succeeded`, req)
this._session.offset = offset
return req
},
/**
* The server evaluates the request, and returns a reply formed as follows:
* +----+-----+-------+------+----------+----------+
* |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
* +----+-----+-------+------+----------+----------+
* | 1 | 1 | X'00' | 1 | Variable | 2 |
* +----+-----+-------+------+----------+----------+
* @param {*} req client requests
*/
dstConnect(req) {
let dstHost = req.domain || req.ip
dns.lookup(dstHost, { family: 4 }, (err, ip) => {
if (err || !ip) {
// failure reply
let reply = [
consts.SOCKS_VERSION,
consts.REP.HOST_UNREACHABLE[0],
consts.RSV,
consts.ATYP.IPV4[0]
]
.concat(utils.ipbytes('127.0.0.1')) // ip: 127.0.0.1
.concat([0x00, 0x00]) // port: 0x0000
// close connection
this._socket.end(Buffer.from(reply))
} else {
// connect target host
const dstSocket = net.createConnection({
port: req.port, // port from client's requests
host: ip // ip from dns lookup of socks proxy server
})
dstSocket.on('connect', () => {
// success reply
let bytes = [
consts.SOCKS_VERSION,
consts.REP.SUCCEEDED[0],
consts.RSV,
consts.ATYP.IPV4[0]
]
// dstSocket.localAddress or default 127.0.0.1
.concat(utils.ipbytes(dstSocket.localAddress || '127.0.0.1'))
// default port 0x00
.concat([0x00, 0x00])
let reply = Buffer.from(bytes)
// use dstSocket.localPort override default port 0x0000
reply.writeUInt16BE(dstSocket.localPort, reply.length - 2)
this._socket.write(reply)
// pipe for proxy forward
this._socket.pipe(dstSocket).pipe(this._socket)
})
.on('error', err => {
console.error(`socket ${this._session.id} -> dstSocket`, err)
})
.on('end', () => {
console.log(`socket ${this._session.id} -> dstSocket end`)
})
.on('close', () => {
console.log(`socket ${this._session.id} -> dstSocket close`)
})
// save dstSocket to session
this._session.dstSocket = dstSocket
}
})
},
/**
* called by socket's 'data' event listener
* @param {Buffer} buf data buffer
*/
handle(buf) {
// before proxy forward phase, otherwise do nothing
if(this._session.state < consts.STATE.PROXY_FORWARD) {
// append data to session.buffer
this._session.buffer = Buffer.concat([this._session.buffer, buf])
// discard processed bytes and move on to the next phase
const discardProcessedBytes = (nextState) => {
this._session.buffer = this._session.buffer.slice(this._session.offset)
this._session.offset = 0
this._session.state = nextState
}
switch(this._session.state) {
case consts.STATE.METHOD_NEGOTIATION:
let methods = this.parseMethods()
if(!!methods) { // read complete data
let method = this.selectMethod(methods)
this.replyMethod(method)
switch(method) {
case consts.METHODS.USERNAME_PASSWD:
discardProcessedBytes(consts.STATE.AUTHENTICATION)
break
case consts.METHODS.NO_AUTH:
discardProcessedBytes(consts.STATE.REQUEST_CONNECT)
break
case consts.METHODS.NO_ACCEPTABLE:
this._socket.end()
break
default:
this._socket.end()
}
}
break
// curl www.baidu.com --socks5 127.0.0.1:3000 --socks5-basic --proxy-user oiuytre:yhntgbrfvedc
case consts.STATE.AUTHENTICATION:
// add gssapi support
// need check this._session.method for parse data
let userinfo = this.parseUsernamePasswd()
if(!!userinfo) { // read complete data
let succeeded = (
userinfo.username === config.username &&
userinfo.passwd === config.passwd
)
discardProcessedBytes(
succeeded ? consts.STATE.REQUEST_CONNECT : consts.STATE.AUTHENTICATION
)
this.replyAuth(succeeded)
}
break
case consts.STATE.REQUEST_CONNECT:
let req = this.parseRequests()
if(!!req) { // read complete data
this.dstConnect(req)
discardProcessedBytes(consts.STATE.PROXY_FORWARD)
}
break
case consts.STATE.PROXY_FORWARD:
default:
console.log(`handle state [${this._session.state}]`, this._session)
}
}
}
}
}
module.exports = server => {
return Proxy
}
启动服务
$ node app.js
此时打开 Chrome 浏览器,安装 SwitchyOmega
插件,配置代理
配置完成后,后面所有的请求都是通过 SOCKS5 服务器 127.0.0.1:3000 转发完成的。本文主要内容介绍完了,如果想要一份完整的项目代码,可以与我联系。