feat: support socks4/5 proxies
This commit is contained in:
parent
67a22ef1f2
commit
256b219247
5 changed files with 527 additions and 2 deletions
|
@ -4,7 +4,7 @@
|
|||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
"./index.ts",
|
||||
],
|
||||
"typedocOptions": {
|
||||
"name": "@mtcute/http-proxy",
|
||||
|
@ -13,7 +13,7 @@
|
|||
"listInvalidSymbolLinks": true,
|
||||
"excludePrivate": true,
|
||||
"entryPoints": [
|
||||
"./src/index.ts"
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
483
packages/socks-proxy/index.ts
Normal file
483
packages/socks-proxy/index.ts
Normal file
|
@ -0,0 +1,483 @@
|
|||
import {
|
||||
IntermediatePacketCodec,
|
||||
TcpTransport,
|
||||
TransportState,
|
||||
} from '@mtcute/core'
|
||||
import { tl } from '@mtcute/tl'
|
||||
import { connect } from 'net'
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { normalize } from 'ip6'
|
||||
|
||||
|
||||
const debug = require('debug')('mtcute:socks-proxy')
|
||||
|
||||
/**
|
||||
* An error has occurred while connecting to an SOCKS proxy
|
||||
*/
|
||||
export class SocksProxyConnectionError extends Error {
|
||||
readonly proxy: SocksProxySettings
|
||||
|
||||
constructor(proxy: SocksProxySettings, message: string) {
|
||||
super(
|
||||
`Error while connecting to ${proxy.host}:${proxy.port}: ${message}`
|
||||
)
|
||||
this.proxy = proxy
|
||||
}
|
||||
}
|
||||
|
||||
export interface SocksProxySettings {
|
||||
/**
|
||||
* Host or IP of the proxy (e.g. `proxy.example.com`, `1.2.3.4`)
|
||||
*/
|
||||
host: string
|
||||
|
||||
/**
|
||||
* Port of the proxy (e.g. `8888`)
|
||||
*/
|
||||
port: number
|
||||
|
||||
/**
|
||||
* Proxy authorization username, if needed
|
||||
*/
|
||||
user?: string
|
||||
|
||||
/**
|
||||
* Proxy authorization password, if needed
|
||||
*/
|
||||
password?: string
|
||||
|
||||
/**
|
||||
* Proxy connection headers, if needed
|
||||
*/
|
||||
headers?: Record<string, string>
|
||||
|
||||
/**
|
||||
* Version of the SOCKS proxy (4 or 5)
|
||||
*
|
||||
* Defaults to `5`.
|
||||
*/
|
||||
version?: 4 | 5
|
||||
}
|
||||
|
||||
function writeIpv4(ip: string, buf: Buffer, offset: number): void {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Invalid IPv4 address')
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const n = parseInt(parts[i])
|
||||
if (isNaN(n) || n < 0 || n > 255) {
|
||||
throw new Error('Invalid IPv4 address')
|
||||
}
|
||||
|
||||
buf[offset + i] = n
|
||||
}
|
||||
}
|
||||
|
||||
function buildSocks4ConnectRequest(
|
||||
ip: string,
|
||||
port: number,
|
||||
username = ''
|
||||
): Buffer {
|
||||
const userId = Buffer.from(username)
|
||||
const buf = Buffer.alloc(9 + userId.length)
|
||||
|
||||
buf[0] = 0x04 // VER
|
||||
buf[1] = 0x01 // CMD = establish a TCP/IP stream connection
|
||||
buf.writeUInt16BE(port, 2) // DSTPORT
|
||||
writeIpv4(ip, buf, 4) // DSTIP
|
||||
userId.copy(buf, 8) // ID
|
||||
buf[8 + userId.length] = 0x00 // ID (null-termination)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
function buildSocks5Greeting(authAvailable: boolean): Buffer {
|
||||
const buf = Buffer.alloc(authAvailable ? 4 : 3)
|
||||
|
||||
buf[0] = 0x05 // VER
|
||||
if (authAvailable) {
|
||||
buf[1] = 0x02 // NAUTH
|
||||
buf[2] = 0x00 // AUTH[0] = No authentication
|
||||
buf[3] = 0x02 // AUTH[1] = Username/password
|
||||
} else {
|
||||
buf[1] = 0x01 // NAUTH
|
||||
buf[2] = 0x00 // AUTH[0] = No authentication
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
function buildSocks5Auth(username: string, password: string) {
|
||||
const usernameBuf = Buffer.from(username)
|
||||
const passwordBuf = Buffer.from(password)
|
||||
|
||||
if (usernameBuf.length > 255)
|
||||
throw new Error(`Too long username (${usernameBuf.length} > 255)`)
|
||||
if (passwordBuf.length > 255)
|
||||
throw new Error(`Too long password (${passwordBuf.length} > 255)`)
|
||||
|
||||
const buf = Buffer.alloc(3 + usernameBuf.length + passwordBuf.length)
|
||||
buf[0] = 0x01 // VER of auth
|
||||
buf[1] = usernameBuf.length
|
||||
usernameBuf.copy(buf, 2)
|
||||
buf[2 + usernameBuf.length] = passwordBuf.length
|
||||
passwordBuf.copy(buf, 3 + usernameBuf.length)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
function writeIpv6(ip: string, buf: Buffer, offset: number): void {
|
||||
ip = normalize(ip)
|
||||
const parts = ip.split(':')
|
||||
if (parts.length !== 8) {
|
||||
throw new Error('Invalid IPv6 address')
|
||||
}
|
||||
|
||||
for (let i = 0, j = offset; i < 8; i++, j += 2) {
|
||||
const n = parseInt(parts[i])
|
||||
if (isNaN(n) || n < 0 || n > 0xffff) {
|
||||
throw new Error('Invalid IPv6 address')
|
||||
}
|
||||
|
||||
buf.writeUInt16BE(n, j)
|
||||
}
|
||||
}
|
||||
|
||||
function buildSocks5Connect(ip: string, port: number, ipv6 = false): Buffer {
|
||||
const buf = Buffer.alloc(ipv6 ? 22 : 10)
|
||||
|
||||
buf[0] = 0x05 // VER
|
||||
buf[1] = 0x01 // CMD = establish a TCP/IP stream connection
|
||||
buf[2] = 0x00 // RSV
|
||||
if (ipv6) {
|
||||
buf[3] = 0x04 // TYPE = IPv6
|
||||
writeIpv6(ip, buf, 4) // ADDR
|
||||
buf.writeUInt16BE(port, 20) // DSTPORT
|
||||
} else {
|
||||
buf[3] = 0x01 // TYPE = IPv4
|
||||
writeIpv4(ip, buf, 4) // ADDR
|
||||
buf.writeUInt16BE(port, 8) // DSTPORT
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
const SOCKS4_ERRORS: Record<number, string> = {
|
||||
0x5b: 'Request rejected or failed',
|
||||
0x5c: 'Request failed because client is not running identd',
|
||||
0x5d: "Request failed because client's identd could not confirm the user ID in the request",
|
||||
}
|
||||
|
||||
const SOCKS5_ERRORS: Record<number, string> = {
|
||||
1: 'General failure',
|
||||
2: 'Connection not allowed by ruleset',
|
||||
3: 'Network unreachable',
|
||||
4: 'Host unreachable',
|
||||
5: 'Connection refused by destination host',
|
||||
6: 'TTL expired',
|
||||
7: 'Command not supported / protocol error',
|
||||
8: 'Address type not supported',
|
||||
}
|
||||
|
||||
/**
|
||||
* TCP transport that connects via a SOCKS4/5 proxy.
|
||||
*/
|
||||
export abstract class SocksProxiedTcpTransport extends TcpTransport {
|
||||
readonly _proxy: SocksProxySettings
|
||||
|
||||
constructor(proxy: SocksProxySettings) {
|
||||
super()
|
||||
|
||||
if (proxy.version != null && proxy.version !== 4 && proxy.version !== 5)
|
||||
throw new SocksProxyConnectionError(
|
||||
proxy,
|
||||
`Invalid SOCKS version: ${proxy.version}`
|
||||
)
|
||||
|
||||
this._proxy = proxy
|
||||
}
|
||||
|
||||
connect(dc: tl.RawDcOption): void {
|
||||
if (this._state !== TransportState.Idle)
|
||||
throw new Error('Transport is not IDLE')
|
||||
|
||||
if (!this.packetCodecInitialized) {
|
||||
this._packetCodec.on('error', (err) => this.emit('error', err))
|
||||
this._packetCodec.on('packet', (buf) => this.emit('message', buf))
|
||||
this.packetCodecInitialized = true
|
||||
}
|
||||
|
||||
this._state = TransportState.Connecting
|
||||
this._currentDc = dc
|
||||
|
||||
this._socket = connect(
|
||||
this._proxy.port,
|
||||
this._proxy.host,
|
||||
this._onProxyConnected.bind(this)
|
||||
)
|
||||
|
||||
this._socket.on('error', this.handleError.bind(this))
|
||||
this._socket.on('close', this.close.bind(this))
|
||||
}
|
||||
|
||||
private _onProxyConnected() {
|
||||
let packetHandler: (msg: Buffer) => void
|
||||
|
||||
if (this._proxy.version === 4) {
|
||||
packetHandler = (msg) => {
|
||||
if (msg[0] !== 0x04) {
|
||||
// VER, must be 4
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
`Server returned version ${msg[0]}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const code = msg[1]
|
||||
|
||||
debug(
|
||||
'[%s:%d] CONNECT returned code %d',
|
||||
this._proxy.host,
|
||||
this._proxy.port,
|
||||
code
|
||||
)
|
||||
|
||||
if (code === 0x5a) {
|
||||
this._socket!.off('data', packetHandler)
|
||||
this._socket!.on('data', (data) =>
|
||||
this._packetCodec.feed(data)
|
||||
)
|
||||
this.handleConnect()
|
||||
} else {
|
||||
const msg =
|
||||
code in SOCKS4_ERRORS
|
||||
? SOCKS4_ERRORS[code]
|
||||
: `Unknown error code: 0x${code.toString(16)}`
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(this._proxy, msg)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
debug(
|
||||
'[%s:%d] connected to proxy, sending CONNECT',
|
||||
this._proxy.host,
|
||||
this._proxy.port
|
||||
)
|
||||
|
||||
try {
|
||||
this._socket!.write(
|
||||
buildSocks4ConnectRequest(
|
||||
this._currentDc!.ipAddress,
|
||||
this._currentDc!.port,
|
||||
this._proxy.user
|
||||
)
|
||||
)
|
||||
} catch (e) {
|
||||
this._socket!.emit('error', e)
|
||||
}
|
||||
} else {
|
||||
let state: 'greeting' | 'auth' | 'connect' = 'greeting'
|
||||
|
||||
const sendConnect = () => {
|
||||
debug(
|
||||
'[%s:%d] sending CONNECT',
|
||||
this._proxy.host,
|
||||
this._proxy.port,
|
||||
)
|
||||
|
||||
try {
|
||||
this._socket!.write(
|
||||
buildSocks5Connect(
|
||||
this._currentDc!.ipAddress,
|
||||
this._currentDc!.port,
|
||||
this._currentDc!.ipv6
|
||||
)
|
||||
)
|
||||
state = 'connect'
|
||||
} catch (e) {
|
||||
this._socket!.emit('error', e)
|
||||
}
|
||||
}
|
||||
|
||||
packetHandler = (msg) => {
|
||||
switch (state) {
|
||||
case 'greeting': {
|
||||
if (msg[0] !== 0x05) {
|
||||
// VER, must be 5
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
`Server returned version ${msg[0]}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const chosen = msg[1]
|
||||
|
||||
debug(
|
||||
'[%s:%d] GREETING returned auth method %d',
|
||||
this._proxy.host,
|
||||
this._proxy.port,
|
||||
chosen
|
||||
)
|
||||
|
||||
switch (chosen) {
|
||||
case 0x00:
|
||||
// "No authentication"
|
||||
sendConnect()
|
||||
break
|
||||
case 0x02:
|
||||
// Username/password
|
||||
if (
|
||||
!this._proxy.user ||
|
||||
!this._proxy.password
|
||||
) {
|
||||
// should not happen
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
'Authentication is required, but not provided'
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
this._socket!.write(
|
||||
buildSocks5Auth(
|
||||
this._proxy.user,
|
||||
this._proxy.password
|
||||
)
|
||||
)
|
||||
state = 'auth'
|
||||
} catch (e) {
|
||||
this._socket!.emit('error', e)
|
||||
}
|
||||
break
|
||||
case 0xff:
|
||||
default:
|
||||
// "no acceptable methods were offered"
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
'Authentication is required, but not provided/supported'
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'auth':
|
||||
if (msg[0] !== 0x01) {
|
||||
// VER of auth, must be 1
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
`Server returned version ${msg[0]}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
debug(
|
||||
'[%s:%d] AUTH returned code %d',
|
||||
this._proxy.host,
|
||||
this._proxy.port,
|
||||
msg[1]
|
||||
)
|
||||
|
||||
if (msg[1] === 0x00) {
|
||||
// success
|
||||
sendConnect()
|
||||
} else {
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
`Authentication failure`
|
||||
)
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'connect': {
|
||||
if (msg[0] !== 0x05) {
|
||||
// VER, must be 5
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(
|
||||
this._proxy,
|
||||
`Server returned version ${msg[0]}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const code = msg[1]
|
||||
|
||||
debug(
|
||||
'[%s:%d] CONNECT returned code %d',
|
||||
this._proxy.host,
|
||||
this._proxy.port,
|
||||
code
|
||||
)
|
||||
|
||||
if (code === 0x00) {
|
||||
// Request granted
|
||||
this._socket!.off('data', packetHandler)
|
||||
this._socket!.on('data', (data) =>
|
||||
this._packetCodec.feed(data)
|
||||
)
|
||||
this.handleConnect()
|
||||
} else {
|
||||
const msg =
|
||||
code in SOCKS5_ERRORS
|
||||
? SOCKS5_ERRORS[code]
|
||||
: `Unknown error code: 0x${code.toString(
|
||||
16
|
||||
)}`
|
||||
this._socket!.emit(
|
||||
'error',
|
||||
new SocksProxyConnectionError(this._proxy, msg)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(
|
||||
'[%s:%d] connected to proxy, sending GREETING',
|
||||
this._proxy.host,
|
||||
this._proxy.port
|
||||
)
|
||||
|
||||
try {
|
||||
this._socket!.write(
|
||||
buildSocks5Greeting(
|
||||
!!(this._proxy.user && this._proxy.password)
|
||||
)
|
||||
)
|
||||
} catch (e) {
|
||||
this._socket!.emit('error', e)
|
||||
}
|
||||
}
|
||||
|
||||
this._socket!.on('data', packetHandler)
|
||||
}
|
||||
}
|
||||
|
||||
export class SocksProxiedIntermediateTcpTransport extends SocksProxiedTcpTransport {
|
||||
_packetCodec = new IntermediatePacketCodec()
|
||||
}
|
18
packages/socks-proxy/package.json
Normal file
18
packages/socks-proxy/package.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@mtcute/socks-proxy",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "SOCKS4/5 proxy support for MTCute",
|
||||
"author": "Alisa Sireneva <me@tei.su>",
|
||||
"license": "MIT",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "mocha -r ts-node/register tests/**/*.spec.ts",
|
||||
"docs": "npx typedoc",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mtcute/core": "^0.0.0",
|
||||
"ip6": "^0.2.6"
|
||||
}
|
||||
}
|
19
packages/socks-proxy/tsconfig.json
Normal file
19
packages/socks-proxy/tsconfig.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./index.ts",
|
||||
],
|
||||
"typedocOptions": {
|
||||
"name": "@mtcute/socks-proxy",
|
||||
"includeVersion": true,
|
||||
"out": "../../docs/packages/socks-proxy",
|
||||
"listInvalidSymbolLinks": true,
|
||||
"excludePrivate": true,
|
||||
"entryPoints": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -8375,6 +8375,11 @@ ip-regex@^2.1.0:
|
|||
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
|
||||
integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
|
||||
|
||||
ip6@^0.2.6:
|
||||
version "0.2.6"
|
||||
resolved "https://registry.yarnpkg.com/ip6/-/ip6-0.2.6.tgz#01dab61e4182c01cb8911e7fe2963330cda285a0"
|
||||
integrity sha512-Im9kYH0L2PdGysroiMzuK4JoRnMr7U+I8NW1C7cvMfpPlPkz7OpJoHQPeGCarmYGH2cqoWRXI7o/pLKa6KXZ5Q==
|
||||
|
||||
ip@^1.1.0, ip@^1.1.5:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
|
||||
|
|
Loading…
Reference in a new issue