@mutagen-d/node-proxy-server
Advanced tools
| const { createProxyServer } = require('../../src'); | ||
| const { connectionLogger } = require('../tool/connection.logger'); | ||
| const port = 8080; | ||
| const time = () => new Date().toISOString() | ||
| const server = createProxyServer({ auth: true }); | ||
| server.on('proxy-auth', (username, password, callback) => { | ||
| callback(true) | ||
| }) | ||
| server.on('error', (error) => { | ||
| console.log(time(), 'server error', error) | ||
| }) | ||
| server.on('connection', (socket) => { | ||
| socket.setTimeout(15 * 1000, () => socket.destroy()) | ||
| }) | ||
| connectionLogger(server) | ||
| server.listen(port, '0.0.0.0', () => console.log(time(), 'proxy-server listening port', port)) |
| { | ||
| "name": "example", | ||
| "version": "1.0.0", | ||
| "description": "", | ||
| "main": "index.js", | ||
| "scripts": { | ||
| "net-proxy": "node ./net-proxy", | ||
| "ssh-proxy": "node ./shh-proxy", | ||
| "test": "echo \"Error: no test specified\" && exit 1" | ||
| }, | ||
| "keywords": [], | ||
| "author": "", | ||
| "license": "ISC", | ||
| "dependencies": { | ||
| "ssh2": "^1.11.0" | ||
| } | ||
| } |
| const fs = require('fs') | ||
| const util = require('util') | ||
| const path = require('path') | ||
| const os = require('os') | ||
| const { Client } = require('ssh2') | ||
| const { createProxyServer } = require('../../src') | ||
| const { connectionLogger } = require('../tool/connection.logger') | ||
| const time = () => new Date().toISOString() | ||
| const sshClient = new Client() | ||
| const forwardOut = util.promisify(sshClient.forwardOut) | ||
| const server = createProxyServer({ | ||
| auth: true, | ||
| createProxyConnection: async (info) => { | ||
| const stream = await forwardOut.call(sshClient, info.srcHost, info.srcPort, info.dstHost, info.dstPort) | ||
| return stream; | ||
| }, | ||
| }) | ||
| server.on('proxy-auth', (username, password, callback) => { | ||
| callback(true) | ||
| }) | ||
| server.on('error', (e) => { | ||
| console.log(time(), 'server error', e) | ||
| }) | ||
| connectionLogger(server) | ||
| sshClient.connect({ | ||
| host: 'localhost', | ||
| username: 'username', | ||
| privateKey: fs.readFileSync(path.join(os.homedir(), '.ssh/id_rsa')), | ||
| keepaliveInterval: 0, | ||
| }) | ||
| sshClient.on('ready', () => { | ||
| const port = 8080; | ||
| console.log(time(), 'ssh-client ready') | ||
| server.listen(port, '0.0.0.0', () => console.log(time(), 'proxy-server listening port', port)) | ||
| }) | ||
| module.exports = { proxyServer: server, sshClient } |
| const time = () => new Date().toISOString() | ||
| /** @param {import('../../src').ProxyServer} server */ | ||
| const connectionLogger = (server) => { | ||
| let count = 0; | ||
| server.on('connection', (socket) => { | ||
| count += 1; | ||
| const { remoteAddress: srcHost, remotePort: srcPort } = socket; | ||
| console.log(time(), count, 'connect', { srcHost, srcPort }) | ||
| socket.on('close', () => { | ||
| count -= 1; | ||
| console.log(time(), count, 'disconnect', { srcHost, srcPort }) | ||
| }) | ||
| }) | ||
| let pcount = 0; | ||
| server.on('proxy-connection', (stream, info) => { | ||
| const { dstHost, dstPort } = info; | ||
| pcount += 1; | ||
| console.log(time(), pcount, 'connect-proxy', { dstHost, dstPort }) | ||
| stream.on('close', () => { | ||
| pcount -= 1; | ||
| console.log(time(), pcount, 'disconnect-proxy', { dstHost, dstPort }) | ||
| }) | ||
| }) | ||
| return server; | ||
| } | ||
| module.exports = { connectionLogger } |
+328
| const net = require('net') | ||
| const tls = require('tls') | ||
| const http = require('http') | ||
| const { | ||
| parseHTTP, | ||
| serializeHTTP, | ||
| Deffer, | ||
| isSocks4HandshakeData, | ||
| isSocks5HandshakeData, | ||
| socks4ResponseData, | ||
| socks5ResponseData, | ||
| toHex, | ||
| oneTime, | ||
| } = require('./tool'); | ||
| const { ReadBuffer } = require('./read-buffer'); | ||
| /** | ||
| * @typedef {import('./tool').HttpRequestOptions} HttpRequestOptions | ||
| * @typedef {(info: ConnectionInfo, options?: HttpRequestOptions) => Promise<import('stream').Duplex>} CreateProxyConnection | ||
| * @typedef {(userid: string, password: string, callback: (isAuth: boolean) => void, socket: net.Socket) => any} OnAuth | ||
| * @typedef {{ | ||
| * on(event: 'http-proxy', listener: (socket: net.Socket, data: Buffer, options: HttpRequestOptions) => any): ProxyServer; | ||
| * on(event: 'http-proxy-connection', listener: (socket: net.Socket, data: Buffer, options: HttpRequestOptions) => any): ProxyServer; | ||
| * on(event: 'socks4-proxy', listener: (socket: net.Socket, data: Buffer) => any): ProxyServer; | ||
| * on(event: 'socks5-proxy', listener: (socket: net.Socket, data: Buffer) => any): ProxyServer; | ||
| * on(event: 'socks5-proxy-connection', listener: (socket: net.Socket, data: Buffer) => any): ProxyServer; | ||
| * on(event: 'proxy-auth', listener: OnAuth): ProxyServer; | ||
| * on(event: 'proxy-connection', listener: (connection: import('stream').Duplex, info: ConnectionInfo)): ProxyServer; | ||
| * } & net.Server} ProxyServer | ||
| * @typedef {{ dstHost: string, dstPort: number; srcHost: string; srcPort: number }} ConnectionInfo | ||
| * @typedef {{ | ||
| * createProxyConnection?: CreateProxyConnection; | ||
| * auth?: boolean; | ||
| * }} ProxyServerOptions | ||
| */ | ||
| /** | ||
| * @param {ProxyServerOptions} [options] | ||
| */ | ||
| function createProxyServer(options) { | ||
| const auth = { enabled: options && options.auth } | ||
| const createProxyConnection = options && options.createProxyConnection || createTCPConnection | ||
| /** @type {ProxyServer} */ | ||
| const server = net.createServer(); | ||
| server.on('connection', (socket) => { | ||
| socket._server = server; | ||
| socket.on('error', onSocketError) | ||
| socket.once('data', onConnectionHandshake) | ||
| }) | ||
| server.on('http-proxy', (socket, data, options) => { | ||
| if (!auth.enabled) { | ||
| server.emit('http-proxy-connection', socket, data, options) | ||
| return; | ||
| } | ||
| const proxyAuthHead = options.headers['proxy-authorization']; | ||
| const [type, token] = (proxyAuthHead || '').split(/\s+/g); | ||
| if (!proxyAuthHead || !type || type.toLowerCase() !== 'basic' || !token) { | ||
| socket.end([ | ||
| 'HTTP/1.1 407 Proxy Authentication Required', | ||
| 'Proxy-Authenticate: Basic realm="Proxy Authentication Required"', | ||
| '\r\n' | ||
| ].join('\r\n'), 'utf-8') | ||
| return; | ||
| } | ||
| const [username, password] = Buffer.from(token, 'base64').toString('utf-8').split(':'); | ||
| if (!server.listenerCount('proxy-auth')) { | ||
| socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n', 'utf-8'); | ||
| server.emit('error', new Error('require "proxy-auth" event listener')) | ||
| return; | ||
| } | ||
| socket.pause() | ||
| server.emit('proxy-auth', username, password, oneTime((isAuth) => { | ||
| socket.resume() | ||
| if (isAuth) { | ||
| server.emit('http-proxy-connection', socket, data, options) | ||
| } else { | ||
| socket.end('HTTP/1.1 401 Unathorized\r\n\r\n', 'utf-8') | ||
| } | ||
| }), socket) | ||
| }) | ||
| server.on('http-proxy-connection', async (socket, data, options) => { | ||
| try { | ||
| const { remoteAddress: srcHost, remotePort: srcPort } = socket; | ||
| const { method, url } = options; | ||
| /** @type {import('stream').Duplex} */ | ||
| let conn; | ||
| socket.pause() | ||
| if (method.toLowerCase() === 'connect') { | ||
| const [dstHost, dstPort] = url.split(':') | ||
| conn = await createProxyConnection({ srcHost, srcPort, dstHost, dstPort: +dstPort }, options) | ||
| server.emit('proxy-connection', conn, { dstHost, dstPort: +dstPort, srcHost, srcPort }) | ||
| socket.write('HTTP/1.1 200 OK\r\n\r\n', 'utf-8') | ||
| } else { | ||
| const { host: dstHost, port: dstPort } = new URL(url); | ||
| conn = await createProxyConnection({ srcHost, srcPort, dstHost, dstPort: dstPort || 80 }, options) | ||
| server.emit('proxy-connection', conn, { dstHost, dstPort, srcHost, srcPort }) | ||
| if (options.headers['proxy-authorization']) { | ||
| delete options.headers['proxy-authorization'] | ||
| socket.write(serializeHTTP(options)) | ||
| } else { | ||
| socket.write(data) | ||
| } | ||
| } | ||
| socket.resume(); | ||
| conn.pipe(socket) | ||
| socket.pipe(conn) | ||
| socket.on('close', () => conn.destroy()) | ||
| conn.on('error', onProxyError) | ||
| } catch (e) { | ||
| server.emit('error', e) | ||
| socket.resume() | ||
| socket.end(`HTTP/1.1 500 Internal Server Error\r\n\r\n`, 'utf-8') | ||
| } | ||
| }) | ||
| server.on('socks4-proxy', async (socket, data) => { | ||
| try { | ||
| const { remoteAddress: srcHost, remotePort: srcPort } = socket; | ||
| const buf = new ReadBuffer(data) | ||
| buf.seek(1) // version byte | ||
| const command = buf.readUInt8(); | ||
| const dstPort = buf.readUInt16BE() | ||
| const ip = buf.readArrayBuffer(4).join('.'); | ||
| const userid = buf.readStringNT('ascii'); | ||
| if (!userid) { | ||
| socket.end(socks4ResponseData(0x5D)) // NOUSERID | ||
| return; | ||
| } | ||
| let dstHost = ip | ||
| if (/^0\.0\.0\./.test(ip)) { | ||
| dstHost = buf.readStringNT('ascii'); | ||
| } | ||
| if (!dstHost) { | ||
| socket.end(socks4ResponseData(0x5B)) // REJECTED | ||
| return; | ||
| } | ||
| /** @type {import('stream').Duplex} */ | ||
| let conn; | ||
| socket.pause() | ||
| if (command === 0x01) { | ||
| conn = await createProxyConnection({ dstHost, dstPort, srcHost, srcPort }) | ||
| server.emit('proxy-connection', conn, { dstHost, dstPort, srcHost, srcPort }) | ||
| } else { | ||
| socket.resume() | ||
| socket.end(socks4ResponseData(0x5B)) // REJECTED | ||
| return; | ||
| } | ||
| socket.resume() | ||
| socket.write(socks4ResponseData(0x5A)) // SUCCESS | ||
| conn.pipe(socket).pipe(conn) | ||
| socket.on('close', () => conn.destroy()) | ||
| conn.on('error', onProxyError) | ||
| } catch (e) { | ||
| server.emit('error', e) | ||
| socket.resume() | ||
| socket.end(socks4ResponseData(0x5C)) | ||
| } | ||
| }) | ||
| server.on('socks5-proxy', (socket, data) => { | ||
| const buf = new ReadBuffer(data) | ||
| buf.seek(1) // version byte | ||
| const size = buf.readUInt8() | ||
| const authTypes = buf.readArrayBuffer(size) | ||
| if (auth.enabled) { | ||
| if (authTypes.includes(0x02)) { | ||
| socket.write(socks5ResponseData(0x02)) // PWD AUTH | ||
| socket.once('data', onSocks5PasswordAuth) | ||
| return; | ||
| } | ||
| if (authTypes.includes(0x00)) { | ||
| socket.pause() | ||
| server.emit('proxy-auth', '', '', oneTime((isAuth) => { | ||
| socket.resume() | ||
| if (isAuth) { | ||
| socket.write(socks5ResponseData(0x00)) // NO AUTH | ||
| socket.once('data', onSocks5Connection) | ||
| } else { | ||
| socket.end(socks5ResponseData(0x01)) | ||
| } | ||
| }), socket) | ||
| return; | ||
| } | ||
| } else if (authTypes.includes(0x00)) { | ||
| socket.resume() | ||
| socket.write(socks5ResponseData(0x00)) // NO AUTH | ||
| socket.once('data', onSocks5Connection) | ||
| return; | ||
| } | ||
| socket.end(socks5ResponseData(0xFF)) // NOT SUPPORTED AUTHENTICATION | ||
| }) | ||
| server.on('socks5-proxy-connection', async (socket, data) => { | ||
| const { remoteAddress: srcHost, remotePort: srcPort } = socket; | ||
| const buf = new ReadBuffer(data) | ||
| buf.seek(1) // version byte | ||
| const command = buf.readUInt8(); | ||
| buf.seek(1) | ||
| const addressType = buf.readUInt8(); | ||
| let dstPort, dstHost, address, domSize; | ||
| switch (addressType) { | ||
| case 0x01: | ||
| address = buf.readArrayBuffer(buf.length, buf.position); | ||
| dstHost = buf.readArrayBuffer(4).join('.'); // IPv4 | ||
| dstPort = buf.readUInt16BE() | ||
| break; | ||
| case 0x03: | ||
| address = buf.readArrayBuffer(buf.length, buf.position); | ||
| domSize = buf.readUInt8() | ||
| dstHost = buf.readArrayBuffer(domSize).toString('ascii'); // Name | ||
| dstPort = buf.readUInt16BE() | ||
| break; | ||
| case 0x04: | ||
| address = buf.readArrayBuffer(buf.length, buf.position) | ||
| dstHost = buf.readArrayBuffer(16).map(toHex).join(':') // IPv6 | ||
| dstPort = buf.readUInt16BE() | ||
| break; | ||
| default: | ||
| socket.end(socks5ResponseData(0x08, 0)) // ADDRESS TYPE UNSUPPORTED | ||
| return; | ||
| } | ||
| try { | ||
| /** @type {net.Socket} */ | ||
| let conn; | ||
| if (command === 0x01) { | ||
| socket.pause() | ||
| conn = await createProxyConnection({ dstHost, dstPort, srcHost, srcPort }) | ||
| server.emit('proxy-connection', conn, { dstHost, dstPort, srcHost, srcPort }) | ||
| socket.resume() | ||
| socket.write(socks5ResponseData(0x00, 0, addressType, ...address)) // SUCCESS | ||
| } else { | ||
| socket.end(socks5ResponseData(0x07, 0)) // COMMAND UNSUPPORTED | ||
| return; | ||
| } | ||
| conn.on('error', onProxyError) | ||
| conn.pipe(socket) | ||
| socket.pipe(conn) | ||
| socket.on('close', () => conn.destroy()) | ||
| } catch (e) { | ||
| server.emit('error', e) | ||
| socket.end(socks5ResponseData(0x01, 0)) // FAILED | ||
| } | ||
| }) | ||
| return server; | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function onConnectionHandshake(data) { | ||
| const socket = this; | ||
| /** @type {ProxyServer} */ | ||
| const server = socket._server; | ||
| if (isSocks4HandshakeData(data)) { | ||
| server.emit('socks4-proxy', socket, data) | ||
| return; | ||
| } | ||
| if (isSocks5HandshakeData(data)) { | ||
| server.emit('socks5-proxy', socket, data) | ||
| return; | ||
| } | ||
| const options = parseHTTP(data) | ||
| if (http.METHODS.includes(options.method)) { | ||
| server.emit('http-proxy', socket, data, options) | ||
| return; | ||
| } | ||
| socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n', 'utf-8') | ||
| } | ||
| /** @type {CreateProxyConnection} */ | ||
| function createTCPConnection(info, options) { | ||
| const socket = net.createConnection({ host: info.dstHost, port: info.dstPort }) | ||
| const conn = options && options.url.indexOf('https:') === 0 ? new tls.TLSSocket(socket, { rejectUnauthorized: false }) : socket; | ||
| /** @type {Deffer<net.Socket>} */ | ||
| const deffer = new Deffer() | ||
| conn.on('connect', () => deffer.resolve(conn)) | ||
| conn.on('error', (err) => deffer.reject(err)) | ||
| return deffer.promise; | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function onSocks5PasswordAuth(data) { | ||
| const socket = this; | ||
| const buf = new ReadBuffer(data) | ||
| buf.seek(1) // version byte | ||
| const useridSize = buf.readUInt8() | ||
| const userid = buf.readArrayBuffer(useridSize).toString('ascii') | ||
| const passwordSize = buf.readUInt8() | ||
| const password = buf.readArrayBuffer(passwordSize).toString('ascii'); | ||
| /** @type {ProxyServer} */ | ||
| const server = socket._server; | ||
| socket.pause() | ||
| server.emit('proxy-auth', userid, password, oneTime((isAuth) => { | ||
| socket.resume() | ||
| if (isAuth) { | ||
| socket.write(socks5ResponseData(0x00)) // SUCCESS | ||
| socket.once('data', onSocks5Connection) | ||
| } else { | ||
| socket.end(socks5ResponseData(0x01)) // FAILED | ||
| } | ||
| }), socket) | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function onSocks5Connection(data) { | ||
| /** @type {ProxyServer} */ | ||
| const server = this._server; | ||
| server.emit('socks5-proxy-connection', this, data) | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Error} error | ||
| */ | ||
| function onSocketError(error) { } | ||
| /** | ||
| * @this {import('stream').Duplex} | ||
| * @param {Error} error | ||
| */ | ||
| function onProxyError(error) { } | ||
| module.exports = { createProxyServer } |
| module.exports = { ReadBuffer } | ||
| /** | ||
| * @param {Buffer} buffer | ||
| * @param {number} [offset] | ||
| */ | ||
| function ReadBuffer(buffer, offset) { | ||
| /** @protected */ | ||
| this._buf = typeof offset === 'number' | ||
| ? buffer.subarray(offset, buffer.length) | ||
| : buffer; | ||
| /** @protected */ | ||
| this._readOffset = 0; | ||
| } | ||
| Object.defineProperty(ReadBuffer.prototype, 'buffer', { | ||
| get: function () { | ||
| return this._buf; | ||
| }, | ||
| enumerable: true, | ||
| configurable: true, | ||
| }) | ||
| Object.defineProperty(ReadBuffer.prototype, 'position', { | ||
| get: function () { | ||
| return this._readOffset; | ||
| }, | ||
| enumerable: true, | ||
| configurable: true, | ||
| }) | ||
| Object.defineProperty(ReadBuffer.prototype, 'length', { | ||
| get: function () { | ||
| return this._buf.length; | ||
| }, | ||
| enumerable: true, | ||
| configurable: true, | ||
| }) | ||
| /** @param {number} byteOffset */ | ||
| ReadBuffer.prototype.seek = function seek(byteOffset) { | ||
| this._readOffset += byteOffset; | ||
| return this._readOffset; | ||
| } | ||
| /** @param {number} [offset] */ | ||
| ReadBuffer.prototype.readUInt16BE = function readUInt16BE(offset) { | ||
| return this._readNumberValue(this._buf.readUInt16BE, 2, offset) | ||
| } | ||
| /** @type {ReadBuffer['readUInt16BE']} */ | ||
| ReadBuffer.prototype.readUint16BE = ReadBuffer.prototype.readUInt16BE; | ||
| /** @param {number} [offset] */ | ||
| ReadBuffer.prototype.readUInt8 = function readUInt8(offset) { | ||
| return this._readNumberValue(this._buf.readUInt8, 1, offset) | ||
| } | ||
| /** | ||
| * @protected | ||
| * @template T | ||
| * @param {Extract<T, (offset: number) => any>} fn | ||
| * @param {number} byteSize | ||
| * @param {number} [offset] | ||
| * @return {ReturnType<T>} | ||
| */ | ||
| ReadBuffer.prototype._readNumberValue = function _readNumberValue(fn, byteSize, offset) { | ||
| if (typeof offset === 'number') { | ||
| return fn.call(this._buf, offset) | ||
| } | ||
| const value = fn.call(this._buf, this._readOffset) | ||
| this._readOffset += byteSize; | ||
| return value; | ||
| } | ||
| /** | ||
| * @param {BufferEncoding} encoding | ||
| */ | ||
| ReadBuffer.prototype.readStringNT = function readStringNT(encoding) { | ||
| let nullPos = this._buf.length; | ||
| for (let i = this._readOffset; i < this._buf.length; ++i) { | ||
| if (this._buf[i] === 0x00) { | ||
| nullPos = i; | ||
| break; | ||
| } | ||
| } | ||
| const value = this._buf.subarray(this._readOffset, this._readOffset + nullPos) | ||
| this._readOffset = nullPos + 1; | ||
| return value.toString(encoding) | ||
| } | ||
| /** | ||
| * @param {number} byteSize | ||
| * @param {number} [offset] | ||
| */ | ||
| ReadBuffer.prototype.readArrayBuffer = function readArrayBuffer(byteSize, offset) { | ||
| if (typeof offset === 'number') { | ||
| return this._buf.subarray(offset, offset + byteSize) | ||
| } | ||
| const value = this._buf.subarray(this._readOffset, this._readOffset + byteSize) | ||
| this._readOffset += byteSize; | ||
| return value; | ||
| } |
+123
| /** | ||
| * @typedef {{ | ||
| * method: string; | ||
| * url: string; | ||
| * version: string; | ||
| * headers: Record<string, string>; | ||
| * body: Buffer; | ||
| * }} HttpRequestOptions | ||
| */ | ||
| const LF = 0x0A | ||
| const CR = 0x0D | ||
| /** | ||
| * @param {Buffer} data | ||
| */ | ||
| function parseHTTP(data) { | ||
| let rawHeaders = '' | ||
| /** @type {Buffer} */ | ||
| let body | ||
| for (let i = 0; i < data.length; ++i) { | ||
| if (data[i] === LF && data[i + 1] === LF) { | ||
| rawHeaders = data.subarray(0, i).toString('utf-8') | ||
| body = data.subarray(i + 2) | ||
| } | ||
| if (data[i] === CR && data[i + 1] === LF && data[i + 2] === CR && data[i + 3] === LF) { | ||
| rawHeaders = data.subarray(0, i).toString('utf-8') | ||
| body = data.subarray(i + 4) | ||
| } | ||
| } | ||
| const lines = rawHeaders.split(/\r?\n/g).filter(Boolean); | ||
| /** @type {Record<string, string>} */ | ||
| const headers = lines.slice(1).reduce((acc, line) => { | ||
| const index = line.indexOf(':') | ||
| const key = line.slice(0, index) | ||
| const value = line.slice(index + 1) | ||
| acc[key.toLowerCase()] = value.trim(); | ||
| return acc; | ||
| }, {}) | ||
| const [method, url, version] = lines[0].split(/\s/g); | ||
| return { | ||
| method, | ||
| url, | ||
| version, | ||
| headers, | ||
| body, | ||
| } | ||
| } | ||
| /** @param {HttpRequestOptions} request */ | ||
| function serializeHTTP(request) { | ||
| const { method, url, version, headers, body } = request; | ||
| const rawHeaders = [ | ||
| `${method} ${url} ${version}`, | ||
| ...Object.keys(headers).map(key => `${key}: ${headers[key]}`), | ||
| '\r\n' | ||
| ].join('\r\n'); | ||
| const bufferHead = Buffer.from(rawHeaders, 'utf-8') | ||
| return body ? Buffer.concat([bufferHead, body]) : bufferHead | ||
| } | ||
| /** @template T */ | ||
| function Deffer() { | ||
| /** @type {Promise<T>} */ | ||
| this.promise = new Promise((resolve, reject) => { | ||
| this.resolve = resolve; | ||
| this.reject = reject; | ||
| }) | ||
| } | ||
| /** @param {Buffer} data */ | ||
| const isSocks4HandshakeData = (data) => { | ||
| return data[0] === 0x04 && (data[1] === 0x01 || data[1] === 0x02 || data[1] === 0x03) | ||
| } | ||
| /** @param {Buffer} data */ | ||
| const isSocks5HandshakeData = (data) => { | ||
| const size = data[1] | ||
| const auth = data.subarray(2, 2 + size) | ||
| return data[0] === 0x05 && (auth.includes(0x00) || auth.includes(0x02)) | ||
| } | ||
| /** @param {number} code */ | ||
| const socks4ResponseData = (code) => { | ||
| return Buffer.from([0, code, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]); | ||
| } | ||
| /** @param {number[]} code */ | ||
| const socks5ResponseData = (...code) => { | ||
| return Buffer.from([0x05, ...code]) | ||
| } | ||
| const toHex = (v) => { | ||
| if (typeof v === 'number') { | ||
| return v.toString(16) | ||
| } | ||
| return v; | ||
| } | ||
| /** | ||
| * @template T | ||
| * @param {Extract<T, (...args: any[]) => any>} callback | ||
| */ | ||
| const oneTime = (callback) => { | ||
| let count = 0; | ||
| /** @type {T} */ | ||
| const fn = (...args) => { | ||
| if (count++) return; | ||
| return callback(...args) | ||
| } | ||
| return fn; | ||
| } | ||
| module.exports = { | ||
| Deffer, | ||
| parseHTTP, | ||
| serializeHTTP, | ||
| isSocks4HandshakeData, | ||
| isSocks5HandshakeData, | ||
| socks4ResponseData, | ||
| socks5ResponseData, | ||
| toHex, | ||
| oneTime, | ||
| } |
| const { describe, expect, beforeAll, it } = require('@jest/globals') | ||
| const { serializeHTTP, parseHTTP } = require('./tool') | ||
| describe('tool', () => { | ||
| /** @type {Buffer} */ | ||
| let DATA | ||
| /** @type {Buffer} */ | ||
| let BODY | ||
| /** @type {string} */ | ||
| let Json | ||
| /** @type {import('./tool').HttpRequestOptions} */ | ||
| let OPTIONS | ||
| beforeAll(() => { | ||
| Json = '{ "hello": "world!" }' | ||
| BODY = Buffer.from(Json, 'utf-8') | ||
| OPTIONS = { | ||
| method: 'POST', | ||
| url: '/hello.json', | ||
| version: 'HTTP/1.0', | ||
| headers: { | ||
| 'keep-alive': 'timeout=5, max=1000', | ||
| 'content-type': 'application/json', | ||
| 'content-length': `${BODY.length}`, | ||
| }, | ||
| body: BODY, | ||
| } | ||
| DATA = serializeHTTP(OPTIONS) | ||
| }) | ||
| it('parseHTTP', () => { | ||
| const res = parseHTTP(DATA) | ||
| expect(res.method).toEqual(OPTIONS.method) | ||
| expect(res.url).toEqual(OPTIONS.url) | ||
| expect(res.version).toEqual(OPTIONS.version) | ||
| Object.keys(OPTIONS.headers).forEach((key) => { | ||
| expect(res.headers).toHaveProperty(key.toLowerCase()) | ||
| expect(res.headers[key.toLowerCase()]).toEqual(OPTIONS.headers[key]) | ||
| }) | ||
| expect(Buffer.compare(res.body, BODY)).toEqual(0) | ||
| }) | ||
| describe('serializeHTTP', () => { | ||
| it('with body', () => { | ||
| const options = { ...OPTIONS } | ||
| const data = serializeHTTP(options) | ||
| const res = parseHTTP(data) | ||
| expect(res.method).toEqual(options.method) | ||
| expect(res.url).toEqual(options.url) | ||
| expect(res.version).toEqual(options.version) | ||
| Object.keys(options.headers).forEach((key) => { | ||
| expect(res.headers).toHaveProperty(key.toLowerCase()) | ||
| expect(res.headers[key.toLowerCase()]).toEqual(options.headers[key]) | ||
| }) | ||
| expect(Buffer.compare(res.body, options.body)).toEqual(0) | ||
| }) | ||
| it('empty body', () => { | ||
| const options = { ...OPTIONS, body: undefined } | ||
| const data = serializeHTTP(options) | ||
| const res = parseHTTP(data) | ||
| expect(res.method).toEqual(options.method) | ||
| expect(res.url).toEqual(options.url) | ||
| expect(res.version).toEqual(options.version) | ||
| Object.keys(options.headers).forEach((key) => { | ||
| expect(res.headers).toHaveProperty(key.toLowerCase()) | ||
| expect(res.headers[key.toLowerCase()]).toEqual(options.headers[key]) | ||
| }) | ||
| expect(Buffer.compare(res.body, Buffer.from([]))).toEqual(0) | ||
| }) | ||
| }) | ||
| }) |
+11
-12
| { | ||
| "name": "@mutagen-d/node-proxy-server", | ||
| "version": "0.2.0", | ||
| "version": "1.0.0", | ||
| "description": "Http and socks proxy server", | ||
| "main": "index.js", | ||
| "main": "src/index.js", | ||
| "scripts": { | ||
@@ -14,18 +14,17 @@ "test": "jest" | ||
| "keywords": [ | ||
| "proxy", | ||
| "server", | ||
| "http", | ||
| "https", | ||
| "socks", | ||
| "proxy", | ||
| "server" | ||
| "socks4", | ||
| "socks4a", | ||
| "socks5", | ||
| "socks5h" | ||
| ], | ||
| "author": "mutagen-d", | ||
| "license": "ISC", | ||
| "dependencies": {}, | ||
| "devDependencies": { | ||
| "@jest/globals": "^28.1.3", | ||
| "axios": "^0.27.2", | ||
| "jest": "^28.1.3", | ||
| "proxy-agent": "^5.0.0", | ||
| "socks-proxy-agent": "^7.0.0" | ||
| "@jest/globals": "^29.1.2", | ||
| "jest": "^29.1.2" | ||
| } | ||
| } | ||
| } |
+64
-86
@@ -1,114 +0,92 @@ | ||
| # node-proxy-server | ||
| # Proxy Server | ||
| ## Http and socks proxy server | ||
| Http and Socks proxy server with zero dependencies | ||
| This module provides `http` and `socks` proxy server implementation that can run both protocols in one server instance. | ||
| ## Content | ||
| ## Installation | ||
| - [Usage](#usage) | ||
| - [Authorization](#authorization) | ||
| - [Keep Alive](#keepalive) | ||
| - [Custom proxy connection](#custom-proxy-connection) | ||
| - [Examples](#examples) | ||
| ```bash | ||
| npm install @mutagen-d/node-proxy-server | ||
| ## Usage | ||
| ```js | ||
| const { createProxyServer } = require('./src') | ||
| const port = 8080 | ||
| const server = createProxyServer() | ||
| server.on('error', (error) => { | ||
| console.log('server error', error) | ||
| }) | ||
| server.listen(port, '0.0.0.0', () => console.log('proxy-server listening port', port)) | ||
| ``` | ||
| ## Example | ||
| ## Authorization | ||
| ```js | ||
| const net = require('net') | ||
| const { createHttpProxy, createSocksProxy } = require('@mutagen-d/node-proxy-server') | ||
| const server = net.createServer() | ||
| const options = { | ||
| keepAlive: true, | ||
| keepAliveMsecs: 5000, | ||
| } | ||
| createHttpProxy(server, options) | ||
| createSocksProxy(server, options) | ||
| server.listen(8080) | ||
| const server = createProxyServer({ auth: true }) | ||
| server.on('proxy-auth', (username, password, callback) => { | ||
| callback(username === 'login' && password === '1234') | ||
| }) | ||
| ``` | ||
| ## API | ||
| Only first `"proxy-auth"` event listener will be envoked | ||
| ### `createHttpProxy([serverOrOptions [, options]])` | ||
| ## KeepAlive | ||
| - `serverOrOptions` - `net.Server` or `HttpProxyServerOptions` | ||
| - `options` - `HttpProxyServerOptions` | ||
| `HttpProxyServerOptions`: | ||
| | name | type | required | default | description | | ||
| | ---------------- | --------------------- | -------- | ------- | ---------------------------------------------------------------------------------------- | | ||
| | `keepAlive` | `boolean` | `no` | `true` | controls keep-alive behavior | | ||
| | `keepAliveMsecs` | `number` | `no` | `1000` | inactivity timeout before close connection | | ||
| | `authType` | `"Basic" \| "Bearer"` | `no` | - | if defined then proxy authentication required | | ||
| | `authRealm` | `string` | `no` | - | | | ||
| | `onAuth` | `OnAuth` | `no` | - | | | ||
| | `useHttpRequest` | `boolean` | `no` | - | if `ture` then use `http.request` for HTTP requests, otherwise directly use `net.Socket` | | ||
| | `rewriteOptions` | `RewriteOptions` | `no` | - | if defined then rewrite connection options for each subsequence connections | | ||
| 1. Authorization: | ||
| ```js | ||
| const server = createHttpProxy({ | ||
| authType: 'Basic', | ||
| onAuth: (auth, callback) => { | ||
| switch (auth.type) { | ||
| case 'Basic': | ||
| // authorize connection | ||
| callback(auth.username === 'test' && auth.password === '1234') | ||
| break | ||
| default: | ||
| // reject connection | ||
| callback(false) | ||
| break | ||
| } | ||
| }, | ||
| server.on('connection', (socket) => { | ||
| socket.setTimeout(30 * 1000, () => socket.destroy()) | ||
| }) | ||
| ``` | ||
| 2. Rewrite options: | ||
| ## Custom proxy connection | ||
| Use `createProxyConnection` method. | ||
| By default `net` module is used to create connection, e.i. | ||
| ```js | ||
| const server = createHttpProxy({ | ||
| rewriteOptions: (req, callback) => { | ||
| if (req.url === 'localhost:443') { | ||
| callback({ rejectUnauthorized: false }) | ||
| } | ||
| }, | ||
| const net = require('net') | ||
| const { createProxyServer } = require('./src') | ||
| const server = createProxyServer({ | ||
| createProxyConnection: async (info) => { | ||
| const socket = net.createConnection({ host: info.dstHost, port: info.dstPort }) | ||
| return new Promise((resolve, reject) => { | ||
| socket.on('connect', () => resolve(socket)) | ||
| socket.on('error', (error) => reject(error)) | ||
| }) | ||
| } | ||
| }) | ||
| ``` | ||
| One can also use other methods to create connection, e.i [ssh2-dynamic-port-forwarding](https://github.com/mscdex/ssh2#dynamic-11-port-forwarding-using-a-socksv5-proxy-using-socksv5) | ||
| ### `createSocksProxy([serverOrOptions [, options]])` | ||
| ```js | ||
| const { createProxyServer } = require('./src') | ||
| const { Client } = require('ssh2') | ||
| const util = require('util') | ||
| - `serverOrOptions` - `net.Server` or `SocksProxyServerOptions` | ||
| - `options` - `SocksProxyServerOptions` | ||
| const sshClient = new Client() | ||
| const forwardOut = util.promisify(sshClient.forwardOut) | ||
| `SocksProxyServerOptions`: | ||
| | name | type | required | default | description | | ||
| | ---------------- | ---------------- | -------- | ------- | --------------------------------------------------------------------------- | | ||
| | `keepAlive` | `boolean` | `no` | `true` | controls keep-alive behavior | | ||
| | `keepAliveMsecs` | `number` | `no` | `1000` | inactivity timeout before close connection | | ||
| | `onAuth` | `OnAuth` | `no` | - | if defined then prefer password authentication | | ||
| | `rewriteOptions` | `RewriteOptions` | `no` | - | if defined then rewrite connection options for each subsequence connections | | ||
| 1. Authorization | ||
| ```js | ||
| const server = createSocksProxy({ | ||
| onAuth: (username, password, callback) => { | ||
| callback(username === 'test' && password === '1234') | ||
| const server = createProxyServer({ | ||
| createProxyConnection: async (info) => { | ||
| const stream = await forwardOut.call(sshClient, info.srcHost, info.srcPort, info.dstHost, info.dstPort) | ||
| return stream | ||
| }, | ||
| }) | ||
| sshClient.connect({ | ||
| host: 'localhost', | ||
| username: 'username', | ||
| password: '12345', | ||
| }) | ||
| sshClient.on('ready', () => { | ||
| const port = 8080 | ||
| server.listen(port, '0.0.0.0', () => console.log('proxy-server listening port', port)) | ||
| }) | ||
| ``` | ||
| 2. Rewrite options: | ||
| ## Examples | ||
| ```js | ||
| const server = createSocksProxy({ | ||
| rewriteOptions: (_, callback) => { | ||
| callback({ rejectUnauthorized: false }) | ||
| }, | ||
| }) | ||
| ``` | ||
| see [examples](./example) |
-4
| const { createHttpProxy } = require('./src/http-proxy') | ||
| const { createSocksProxy } = require('./src/socks-proxy') | ||
| module.exports = { createHttpProxy, createSocksProxy } |
| const crypto = require('crypto') | ||
| const net = require('net'); | ||
| const tls = require('tls'); | ||
| const http = require('http'); | ||
| const https = require('https'); | ||
| const { | ||
| _parseRequest, | ||
| _getRawHeaders, | ||
| _setKeepAlive, | ||
| _onError, | ||
| _onClose, | ||
| _status407, | ||
| _setTimeout, | ||
| } = require('./tools') | ||
| module.exports = { createHttpProxy } | ||
| /** | ||
| * @template T | ||
| * @typedef {import('./tools').IfAny<T>} IfAny | ||
| */ | ||
| /** | ||
| * @typedef {{ | ||
| * type: 'Basic' | 'Bearer'; | ||
| * token?: string; | ||
| * username?: string; | ||
| * password?: string; | ||
| * }} AuthParams | ||
| */ | ||
| /** | ||
| * @typedef {import('./tools').IRequest} IRequest | ||
| */ | ||
| /** | ||
| * @typedef {(auth: AuthParams, callback: (authorized: boolean) => void) => void} OnAuth | ||
| * @typedef {(req: Pick<IRequest, 'method' | 'url' | 'headers'>, callback: (opts?: SSLProxyOptions & HttpProxyOptions) => void) => void} RewriteOptions | ||
| * @typedef {{ | ||
| * onAuth?: OnAuth; | ||
| * keepAlive?: boolean; | ||
| * keepAliveMsecs?: number; | ||
| * authType?: 'Basic' | 'Bearer'; | ||
| * authRealm?: string; | ||
| * useHttpRequest?: boolean; | ||
| * }} HttpProxyOptions | ||
| * @typedef {{ | ||
| * rejectUnauthorized?: boolean; | ||
| * ca?: string; | ||
| * cert?: string; | ||
| * key?: string; | ||
| * }} SSLProxyOptions | ||
| * @typedef {HttpProxyOptions & { rewriteOptions?: RewriteOptions }} HttpProxyServerOptions | ||
| */ | ||
| /** | ||
| * @typedef {net.Server & { _http: HttpProxyOptions; _rewriteOptions?: RewriteOptions }} HttpProxyServer | ||
| * @typedef {net.Socket & { _http: HttpProxyOptions & SSLProxyOptions }} HttpProxySocket | ||
| */ | ||
| /** | ||
| * @template T | ||
| * @param {IfAny<T> extends true ? HttpProxyServerOptions : Extract<T, net.Server | HttpProxyServerOptions>} [server] | ||
| * @param {IfAny<T> extends true ? never : HttpProxyServerOptions} [opts] | ||
| * @return {HttpProxyServer} | ||
| */ | ||
| function createHttpProxy(server, opts) { | ||
| if (server instanceof http.Server) { | ||
| throw new Error('WARNING! http.Server not allowed, use net.Server instead') | ||
| } | ||
| /** @type {HttpProxyServer} */ | ||
| const proxy = server instanceof net.Server ? server : net.createServer(); | ||
| /** @type {HttpProxyServerOptions} */ | ||
| const _opts = server instanceof net.Server ? opts : server; | ||
| proxy._http = Object.assign({ keepAlive: true, keepAliveMsecs: 1000 }, _opts) | ||
| proxy._rewriteOptions = _opts ? _opts.rewriteOptions : undefined | ||
| proxy.on('connection', _onConnection) | ||
| return proxy; | ||
| } | ||
| /** | ||
| * @this {HttpProxyServer} | ||
| * @param {HttpProxySocket} socket | ||
| */ | ||
| function _onConnection(socket) { | ||
| const server = this; | ||
| _setTimeout(socket, server._http.keepAliveMsecs) | ||
| socket._http = Object.assign({}, server._http); | ||
| socket._rewriteOptions = server._rewriteOptions; | ||
| socket.once('data', _onceData) | ||
| socket.on('proxy-headers', _onProxyHeaders) | ||
| socket.on('proxy-authorization', _onProxyAuthorization) | ||
| socket.on('proxy-request', _onProxyRequest) | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onceData(data) { | ||
| const socket = this; | ||
| const string = data.toString('utf-8') | ||
| const line = string.split('\n', 1)[0].trim() | ||
| const [method] = line.split(' ', 1) | ||
| if (http.METHODS.includes(method)) { | ||
| socket.emit('proxy-headers', _parseRequest(data)) | ||
| } | ||
| } | ||
| /** | ||
| * @this {HttpProxySocket} | ||
| * @param {import('./tools').IRequest} req | ||
| */ | ||
| function _onProxyHeaders(req) { | ||
| const socket = this; | ||
| /** @type {RewriteOptions} */ | ||
| const rewriteOptions = socket._rewriteOptions | ||
| if (rewriteOptions) { | ||
| rewriteOptions(req, (opts) => { | ||
| Object.assign(socket._http, opts) | ||
| }) | ||
| } | ||
| socket.on('error', _onError) | ||
| socket.on('close', _onClose) | ||
| const { keepAlive, keepAliveMsecs } = socket._http; | ||
| if (keepAlive && req.headers['connection'] && req.headers['connection'].toLowerCase() === 'keep-alive') { | ||
| _setKeepAlive(socket, keepAliveMsecs) | ||
| } | ||
| socket.emit('proxy-authorization', req) | ||
| } | ||
| /** | ||
| * @this {HttpProxySocket} | ||
| * @param {import('./tools').IRequest} req | ||
| */ | ||
| function _onProxyAuthorization(req) { | ||
| const socket = this; | ||
| const { authType, authRealm } = socket._http; | ||
| if (!authType) { | ||
| socket.emit('proxy-request', req) | ||
| return; | ||
| } | ||
| if (authType && !req.headers['proxy-authorization']) { | ||
| socket.end(_status407(authType, authRealm)) | ||
| return; | ||
| } | ||
| const [type, token = ''] = req.headers['proxy-authorization'].split(/\s+/g) | ||
| if (type !== authType || !token) { | ||
| socket.end(_status407(authType, authRealm)) | ||
| return; | ||
| } | ||
| const authCallback = (authorized) => { | ||
| if (authorized) { | ||
| socket.emit('proxy-request', req) | ||
| } else { | ||
| socket.end(_status407(authType, authRealm)) | ||
| } | ||
| } | ||
| const { onAuth = _unAuth } = socket._http; | ||
| let username, password; | ||
| switch (type) { | ||
| case 'Basic': | ||
| ([username, password] = Buffer.from(token, 'base64').toString('utf-8').split(':')) | ||
| onAuth({ type, username, password }, authCallback) | ||
| break; | ||
| case 'Bearer': | ||
| onAuth({ type, token }, authCallback) | ||
| break; | ||
| } | ||
| } | ||
| /** | ||
| * @this {HttpProxySocket} | ||
| * @param {import('./tools').IRequest} req | ||
| */ | ||
| function _onProxyRequest(req) { | ||
| const socket = this; | ||
| const { keepAlive, keepAliveMsecs } = socket._http; | ||
| if (req.method === 'CONNECT') { | ||
| const [host, port] = req.url.split(':'); | ||
| const proxy = net.createConnection({ host, port }) | ||
| if (keepAlive && req.headers['proxy-connection'] && req.headers['proxy-connection'].toLowerCase() === 'keep-alive') { | ||
| _setKeepAlive(proxy, keepAliveMsecs) | ||
| } else { | ||
| _setTimeout(proxy, keepAliveMsecs) | ||
| } | ||
| socket.pause(); | ||
| proxy._name = `${host}:${port}` | ||
| proxy.on('error', _onError) | ||
| proxy.on('close', _onClose) | ||
| proxy.on('connect', () => { | ||
| socket.write('HTTP/1.1 200 OK\r\n\r\n', 'utf-8') | ||
| socket.resume(); | ||
| socket.pipe(proxy, { end: false }) | ||
| proxy.pipe(socket, { end: false }) | ||
| }) | ||
| } else { | ||
| if (req.url.indexOf('http://') !== 0 && req.url.indexOf('https://') !== 0) { | ||
| socket.end(_getRawHeaders({ statusCode: 400 }), 'utf-8') | ||
| return; | ||
| } | ||
| const url = new URL(req.url) | ||
| const defaultPort = url.protocol === 'http:' ? 80 : 443; | ||
| const { useHttpRequest } = socket._http; | ||
| if (useHttpRequest) { | ||
| const headers = Object.assign({}, req.headers) | ||
| delete headers['proxy-authorization'] | ||
| socket.resume() | ||
| const httpx = url.protocol === 'https:' ? https : http; | ||
| const { httpAgent, httpsAgent } = _getAgent(socket._http) | ||
| const xReq = httpx.request({ | ||
| agent: url.protocol === 'https:' ? httpsAgent : httpAgent, | ||
| method: req.method, | ||
| headers, | ||
| hostname: url.hostname, | ||
| protocol: url.protocol, | ||
| port: url.port, | ||
| path: url.pathname + url.search + url.hash, | ||
| }, (xRes) => { | ||
| socket.write(_getRawHeaders(xRes), 'utf-8') | ||
| if (xRes.headers['transfer-encoding'] === 'chunked') { | ||
| xRes.on('data', (chunk) => { | ||
| socket.write(chunk.length.toString(16) + '\r\n' + chunk + '\r\n') | ||
| }) | ||
| xRes.on('end', () => socket.end('0\r\n\r\n')) | ||
| } else { | ||
| xRes.pipe(socket) | ||
| } | ||
| }) | ||
| xReq.end(req.body.toString('utf-8')) | ||
| socket.pipe(xReq) | ||
| const _name = `${url.hostname}:${url.port || defaultPort}` | ||
| xReq._name = _name; | ||
| xReq.on('error', _onError) | ||
| } else { | ||
| const { rejectUnauthorized } = socket._http; | ||
| const port = url.port || defaultPort; | ||
| const conn = net.createConnection({ host: url.hostname, port }); | ||
| const proxy = url.protocol === 'https:' ? new tls.TLSSocket(conn, { rejectUnauthorized }) : conn; | ||
| if (keepAlive && req.headers['connection'] && req.headers['connection'].toLowerCase() === 'keep-alive') { | ||
| _setKeepAlive(proxy, keepAliveMsecs) | ||
| } else { | ||
| _setTimeout(proxy, keepAliveMsecs) | ||
| } | ||
| const _name = `${url.hostname}:${port}` | ||
| proxy._name = _name; | ||
| const headers = Object.assign({}, req.headers) | ||
| delete headers['proxy-authorization'] | ||
| const rawHeaders = _getRawHeaders({ ...req, headers }) | ||
| proxy.write(Buffer.from(rawHeaders, 'utf-8')) | ||
| proxy.write(req.body) | ||
| proxy.on('error', _onError) | ||
| proxy.on('close', _onClose) | ||
| proxy.pipe(socket, { end: false }) | ||
| socket.pipe(proxy, { end: false }) | ||
| } | ||
| } | ||
| } | ||
| /** @type {OnAuth} */ | ||
| function _unAuth(_auth, callback) { | ||
| callback(false) | ||
| } | ||
| function _hash(value) { | ||
| if (!value) { | ||
| return value; | ||
| } | ||
| /** @type {Record<string, string>} */ | ||
| const results = _hash.results = _hash.results || {} | ||
| if (!results[value]) { | ||
| results[value] = crypto.createHash('sha256').update(value).digest('hex'); | ||
| } | ||
| return results[value] | ||
| } | ||
| /** | ||
| * @param {HttpProxyOptions & { maxSockets?: number; ca?: string; cert?: string; key?: string; }} opts | ||
| */ | ||
| function _getAgent(opts) { | ||
| /** @type {Record<string, http.Agent>} */ | ||
| const _httpAgents = _getAgent._httpAgents = _getAgent._httpAgents || {} | ||
| /** @type {Record<string, https.Agent>} */ | ||
| const _httpsAgents = _getAgent._httpsAgents = _getAgent._httpsAgents || {} | ||
| const { keepAlive, keepAliveMsecs, rejectUnauthorized, maxSockets, ca, cert, key } = opts; | ||
| const name = `${keepAlive}:${keepAliveMsecs}:${maxSockets}:${rejectUnauthorized}:${_hash(ca)}:${_hash(cert)}:${_hash(key)}` | ||
| const httpAgent = _httpAgents[name] || new http.Agent({ keepAlive, keepAliveMsecs, maxSockets }) | ||
| const httpsAgent = _httpsAgents[name] || new https.Agent({ keepAlive, keepAliveMsecs, maxSockets, rejectUnauthorized, ca, cert, key }) | ||
| _httpAgents[name] = httpAgent; | ||
| _httpsAgents[name] = httpsAgent; | ||
| return { httpAgent, httpsAgent } | ||
| } |
| const net = require('net') | ||
| const http = require('http') | ||
| const { | ||
| _setKeepAlive, | ||
| _readChars, | ||
| _onError, | ||
| _onClose, | ||
| toHex, | ||
| _setTimeout, | ||
| } = require('./tools') | ||
| const CMD = { | ||
| CONNECT: 0x01, | ||
| BIND: 0x02, | ||
| UDP: 0x03, | ||
| [0x01]: 'CONNECT', | ||
| [0x02]: 'BIND', | ||
| [0x03]: 'UDP', | ||
| } | ||
| const REPv4 = { | ||
| SUCCESS: 0x5A, | ||
| REJECTED: 0x5B, | ||
| UNREACHABLE: 0x5C, | ||
| NOUSERID: 0x5D, | ||
| } | ||
| const REPv5 = { | ||
| SUCCESS: 0x00, | ||
| FAILED: 0x01, | ||
| NOTALLOWED: 0x02, | ||
| NETUNREACH: 0x03, | ||
| HOSTUNREACH: 0x04, | ||
| CONREFUSED: 0x05, | ||
| TTLEXPIRED: 0x06, | ||
| CMDUNSUPP: 0x07, | ||
| ATYPUNSUPP: 0x08, | ||
| } | ||
| const ATYP = { | ||
| IPv4: 0x01, | ||
| Name: 0x03, | ||
| IPv6: 0x04, | ||
| } | ||
| module.exports = { createSocksProxy } | ||
| /** | ||
| * @template T | ||
| * @typedef {import('./tools').IfAny<T>} IfAny | ||
| */ | ||
| /** | ||
| * @typedef {(username: string, password: string, callback: (authorized: boolean) => void) => void} OnAuth | ||
| * @typedef {{ onAuth?: OnAuth, keepAlive?: boolean; keepAliveMsecs?: number; }} SocksProxyOptions | ||
| * @typedef {(opts: { version: 4 | 5; }, callback: (opts: SocksProxyOptions) => void) => void} RewriteOptions | ||
| * @typedef {SocksProxyOptions & { rewriteOptions?: RewriteOptions }} SocksProxyServerOptions | ||
| */ | ||
| /** | ||
| * @typedef {net.Server & { _socks: SocksProxyOptions; _socksRewriteOptions?: RewriteOptions }} SocksProxyServer | ||
| * @typedef {net.Socket & { _socks: SocksProxyOptions }} SocksProxySocket | ||
| */ | ||
| /** | ||
| * @template T | ||
| * @param {IfAny<T> extends true ? SocksProxyServerOptions : Extract<T, net.Server | SocksProxyServerOptions>} [server] | ||
| * @param {IfAny<T> extends true ? never : SocksProxyServerOptions} [opts] | ||
| * @return {SocksProxyServer} | ||
| */ | ||
| function createSocksProxy(server, opts) { | ||
| if (server instanceof http.Server) { | ||
| throw new Error('http.Server not allowed, use net.Server instead') | ||
| } | ||
| const proxy = server instanceof net.Server ? server : net.createServer() | ||
| const _socks = server instanceof net.Server ? opts : server; | ||
| proxy._socks = Object.assign({ keepAlive: true, keepAliveMsecs: 1000 }, _socks) | ||
| proxy._socksRewriteOptions = _socks ? _socks.rewriteOptions : undefined; | ||
| proxy.on('connection', _onConnection) | ||
| return proxy; | ||
| } | ||
| /** | ||
| * @this {SocksProxyServer} | ||
| * @param {SocksProxySocket} socket | ||
| */ | ||
| function _onConnection(socket) { | ||
| const _socks = this._socks; | ||
| socket._socks = Object.assign({}, _socks); | ||
| socket._socksRewriteOptions = this._socksRewriteOptions | ||
| socket.once('data', _onceData) | ||
| _setTimeout(socket, _socks.keepAliveMsecs) | ||
| socket.on('socks4', _onSocks4) | ||
| socket.on('socks5', _onSocks5) | ||
| } | ||
| /** | ||
| * @this {SocksProxySocket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onceData(data) { | ||
| const version = data[0] | ||
| switch (version) { | ||
| case 0x04: | ||
| this.emit('socks4', data) | ||
| break; | ||
| case 0x05: | ||
| this.emit('socks5', data) | ||
| break; | ||
| default: | ||
| return; | ||
| } | ||
| } | ||
| /** | ||
| * @this {SocksProxySocket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onSocks4(data) { | ||
| const socket = this; | ||
| socket.on('error', _onError) | ||
| socket.on('close', _onClose) | ||
| const command = data[1] | ||
| const port = data.readUint16BE(2) | ||
| const ip = data.subarray(4, 8) | ||
| const userid = _readChars(data, 8) | ||
| if (!userid) { | ||
| socket.end(_socks4Rep(REPv4.NOUSERID)) | ||
| return; | ||
| } | ||
| const address = ip.join('.') | ||
| let host = address; | ||
| if (ip[0] === 0x00 && ip[1] === 0x00 && ip[2] === 0x00 && ip[3] !== 0x00) { | ||
| const domain = _readChars(data, 8 + userid.length + 1) | ||
| if (!domain) { | ||
| socket.end(_socks4Rep(REPv4.REJECTED)) | ||
| return; | ||
| } | ||
| host = domain.toString('ascii') | ||
| } | ||
| /** @type {RewriteOptions} */ | ||
| const rewriteOptions = socket._socksRewriteOptions; | ||
| if (rewriteOptions) { | ||
| rewriteOptions({ version: 4 }, (opts) => { | ||
| Object.assign(socket._socks, opts) | ||
| }) | ||
| } | ||
| const { keepAlive, keepAliveMsecs } = socket._socks; | ||
| if (keepAlive) { | ||
| _setKeepAlive(socket, keepAliveMsecs) | ||
| } | ||
| /** @type {net.Socket} */ | ||
| let proxy | ||
| switch (command) { | ||
| case CMD.CONNECT: | ||
| proxy = net.createConnection({ host, port }) | ||
| proxy._name = `${host}:${port}` | ||
| socket.write(_socks4Rep(REPv4.SUCCESS)) | ||
| break; | ||
| case CMD.BIND: | ||
| proxy = net.createConnection({ port }) | ||
| proxy._name = `:${port}` | ||
| socket.write(_socks4Rep(REPv4.SUCCESS)) | ||
| break; | ||
| default: | ||
| socket.end(_socks4Rep(REPv4.REJECTED)) | ||
| return; | ||
| } | ||
| if (keepAlive) { | ||
| _setKeepAlive(proxy, keepAliveMsecs) | ||
| } else { | ||
| _setTimeout(proxy, keepAliveMsecs) | ||
| } | ||
| proxy.on('error', _onError) | ||
| proxy.on('close', _onClose) | ||
| socket.pause() | ||
| proxy.on('connect', () => { | ||
| socket.resume() | ||
| proxy.pipe(socket) | ||
| socket.pipe(proxy) | ||
| }) | ||
| } | ||
| /** | ||
| * @this {SocksProxySocket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onSocks5(data) { | ||
| const socket = this; | ||
| socket.on('error', _onError) | ||
| socket.on('close', _onClose) | ||
| /** @type {RewriteOptions} */ | ||
| const rewriteOptions = socket._socksRewriteOptions; | ||
| if (rewriteOptions) { | ||
| rewriteOptions({ version: 5 }, (opts) => { | ||
| Object.assign(socket._socks, opts) | ||
| }) | ||
| } | ||
| const { onAuth, keepAlive, keepAliveMsecs } = socket._socks; | ||
| if (keepAlive) { | ||
| _setKeepAlive(socket, keepAliveMsecs) | ||
| } | ||
| const nauth = data[1] | ||
| const auth = data.subarray(2, 2 + nauth) | ||
| if (auth.includes(0x02) && onAuth) { | ||
| socket.write(Buffer.from([0x05, 0x02])) | ||
| socket.once('data', _onPwAuth) | ||
| return; | ||
| } | ||
| if (auth.includes(0x00) && !onAuth) { | ||
| socket.write(Buffer.from([0x05, 0x00])) | ||
| socket.once('data', _onSocks5Connection) | ||
| return; | ||
| } | ||
| socket.end(Buffer.from([0x05, 0xFF])) | ||
| } | ||
| /** | ||
| * @this {SocksProxySocket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onPwAuth(data) { | ||
| const socket = this; | ||
| const { onAuth } = this._socks; | ||
| const version = data[0] | ||
| const idlen = data[1] | ||
| const id = data.subarray(2, 2 + idlen).toString('ascii') | ||
| const pwlen = data[2 + idlen] | ||
| const pw = data.subarray(3 + idlen, 3 + idlen + pwlen).toString('ascii') | ||
| onAuth(id, pw, (auth) => { | ||
| if (auth) { | ||
| socket.write(Buffer.from([version, 0x00])) | ||
| socket.once('data', _onSocks5Connection) | ||
| } else { | ||
| socket.end(Buffer.from([version, 0x01])) | ||
| } | ||
| }) | ||
| } | ||
| /** | ||
| * @this {SocksProxySocket} | ||
| * @param {Buffer} data | ||
| */ | ||
| function _onSocks5Connection(data) { | ||
| const socket = this; | ||
| const version = data[0] | ||
| const command = data[1] | ||
| const addrtype = data[3] | ||
| let ipv4, domain, ipv6; | ||
| let port, portBuf; | ||
| let host; | ||
| let addr; | ||
| switch (addrtype) { | ||
| case ATYP.IPv4: | ||
| addr = data.subarray(3, 8) | ||
| ipv4 = data.subarray(4, 8) | ||
| port = data.readUint16BE(8) | ||
| portBuf = data.subarray(8, 10) | ||
| host = ipv4.join('.') | ||
| break; | ||
| case ATYP.Name: | ||
| addr = data.subarray(3, 5 + data[4]) | ||
| domain = data.subarray(5, 5 + data[4]).toString('ascii') | ||
| port = data.readUint16BE(5 + data[4]) | ||
| portBuf = data.subarray(5 + data[4], 7 + data[4]) | ||
| host = domain | ||
| break; | ||
| case ATYP.IPv6: | ||
| addr = data.subarray(3, 20) | ||
| ipv6 = data.subarray(4, 20) | ||
| port = data.readUint16BE(20) | ||
| portBuf = data.subarray(20, 22) | ||
| host = ipv6.map(toHex).join(':') | ||
| break; | ||
| default: | ||
| socket.end(Buffer.from([0x05, REPv5.ATYPUNSUPP, 0])) | ||
| return; | ||
| } | ||
| /** @type {net.Socket} */ | ||
| let proxy; | ||
| switch (command) { | ||
| case CMD.CONNECT: | ||
| case CMD.BIND: | ||
| proxy = net.createConnection({ host, port }) | ||
| proxy._name = `${host}:${port}` | ||
| // TODO | ||
| socket.write(Buffer.from([0x05, REPv5.SUCCESS, 0, ...addr, ...portBuf])) | ||
| break; | ||
| case CMD.UDP: | ||
| // TODO | ||
| socket.end(Buffer.from([0x05, REPv5.CMDUNSUPP, 0])) | ||
| return; | ||
| default: | ||
| socket.end(Buffer.from([0x05, REPv5.CMDUNSUPP, 0])) | ||
| return; | ||
| } | ||
| const { keepAlive, keepAliveMsecs } = this._socks; | ||
| if (keepAlive) { | ||
| _setKeepAlive(proxy, keepAliveMsecs) | ||
| } | ||
| socket.pause() | ||
| proxy.on('error', _onError) | ||
| proxy.on('close', _onClose) | ||
| proxy.on('connect', () => { | ||
| socket.resume() | ||
| proxy.pipe(socket) | ||
| socket.pipe(proxy) | ||
| }) | ||
| } | ||
| function _socks4Rep(message) { | ||
| return Buffer.from([0, message, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]) | ||
| } |
-192
| const net = require('net') | ||
| const time = () => new Date().toISOString() | ||
| /** | ||
| * @template T | ||
| * @typedef {0 extends (1 & T) ? true : false} IfAny | ||
| * @see https://stackoverflow.com/questions/55541275/typescript-check-for-the-any-type | ||
| */ | ||
| /** | ||
| * @param {net.Socket} socket | ||
| */ | ||
| const info = (socket) => { | ||
| const { remoteAddress: address, remotePort: port, remoteFamily: family } = socket; | ||
| if (!address && socket._name) { | ||
| return socket._name; | ||
| } | ||
| return `${address}:${port}:${family}` | ||
| } | ||
| module.exports = { | ||
| time, | ||
| info, | ||
| _status407, | ||
| _parseRequest, | ||
| _getRawHeaders, | ||
| _setKeepAlive, | ||
| _onError, | ||
| _onClose, | ||
| _onTimeout, | ||
| _readChars, | ||
| _setTimeout, | ||
| toHex, | ||
| } | ||
| /** | ||
| * @typedef {{ | ||
| * method: string; | ||
| * url: string; | ||
| * headers: Record<string, string>; | ||
| * body: Buffer; | ||
| * _rawHeaders: string; | ||
| * _rawData: Buffer; | ||
| * }} IRequest | ||
| */ | ||
| /** | ||
| */ | ||
| /** | ||
| * @param {'Basic' | 'Bearer'} [type] | ||
| * @param {string} [realm] | ||
| */ | ||
| function _status407(type, realm) { | ||
| return '' | ||
| + 'HTTP/1.1 407 Proxy Authentication Required' | ||
| + '\r\n' | ||
| + `Proxy-Authenticate: ${type || 'Basic'} realm="${realm || 'Proxy authentication required'}"` | ||
| + '\r\n\r\n'; | ||
| } | ||
| /** | ||
| * @param {Buffer} data | ||
| * @return {IRequest} | ||
| */ | ||
| function _parseRequest(data) { | ||
| let _rawHeaders = '' | ||
| /** @type {Buffer} */ | ||
| let body | ||
| for (let i = 0; i < data.length && i < 10000; ++i) { | ||
| if (data[i] === 0x0A && data[i + 1] === 0x0A) { | ||
| _rawHeaders = data.subarray(0, i).toString('utf-8') | ||
| body = data.subarray(i + 2) | ||
| } | ||
| if (data[i] === 0x0D && data[i + 1] === 0x0A && data[i + 2] === 0x0D && data[i + 3] === 0x0A) { | ||
| _rawHeaders = data.subarray(0, i).toString('utf-8') | ||
| body = data.subarray(i + 4) | ||
| } | ||
| } | ||
| if (!_rawHeaders) { | ||
| return { _rawHeaders, body, _rawData: data } | ||
| } | ||
| const [methodLine, ...restLines] = _rawHeaders.split('\n') | ||
| const [method, url] = methodLine.trim().split(' ', 2) | ||
| /** @type {Record<string, string>} */ | ||
| const headers = {} | ||
| for (let i = 0, line, index; i < restLines.length; ++i) { | ||
| line = restLines[i].trim() | ||
| index = line.indexOf(':') | ||
| headers[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim(); | ||
| } | ||
| return { | ||
| method, | ||
| url, | ||
| headers, | ||
| body, | ||
| _rawHeaders, | ||
| _rawData: data, | ||
| } | ||
| } | ||
| /** | ||
| * @param {http.IncomingMessage} message | ||
| * @param {string} [version] default `1.1` | ||
| */ | ||
| function _getRawHeaders(message, version = '1.1') { | ||
| const { statusCode, statusMessage, headers, method, url } = message; | ||
| let rawHeaders = '' | ||
| if (method && url) { | ||
| rawHeaders += `${method} ${url} HTTP/${version}\r\n` | ||
| } else if (statusCode) { | ||
| rawHeaders += `HTTP/${version} ${statusCode} ${statusMessage || ''}\r\n` | ||
| } | ||
| for (let key in headers) { | ||
| rawHeaders += `${key}: ${headers[key]}\r\n` | ||
| } | ||
| return rawHeaders + '\r\n'; | ||
| } | ||
| /** | ||
| * @param {net.Socket} socket | ||
| * @param {number} [msec] default `5000` ms | ||
| */ | ||
| function _setKeepAlive(socket, msec = 5000) { | ||
| socket.setKeepAlive(true, msec) | ||
| _setTimeout(socket, msec) | ||
| } | ||
| /** | ||
| * Destory `socket` after `msec` of inactivity | ||
| * @param {net.Socket} socket | ||
| * @param {number} [msec] default `5000` ms | ||
| */ | ||
| function _setTimeout(socket, msec = 5000) { | ||
| socket.setTimeout(msec) | ||
| if (!socket.listeners('timeout').includes(_streamDestroy)) { | ||
| socket.once('timeout', _streamDestroy) | ||
| } | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| * @param {Error} error | ||
| */ | ||
| function _onError(error) { | ||
| this.destroy(error) | ||
| } | ||
| /** | ||
| * @this {net.Socket} | ||
| */ | ||
| function _onClose() { | ||
| } | ||
| function _onTimeout() { | ||
| this.destroy() | ||
| } | ||
| /** | ||
| * @this {import('stream').Duplex} | ||
| * @param {Error} [error] | ||
| */ | ||
| function _streamDestroy(error) { | ||
| this.destroy(error) | ||
| } | ||
| /** | ||
| * @param {Buffer} chars | ||
| */ | ||
| function _readChars(chars, start = 0) { | ||
| const maxcount = 1000; | ||
| for (let i = start; i < maxcount && i < chars.length; ++i) { | ||
| if (chars[i] === 0x00) { | ||
| return chars.subarray(start, i); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * | ||
| * @param {number} v | ||
| */ | ||
| function toHex(v) { | ||
| switch (typeof v) { | ||
| case 'number': | ||
| return v.toString(16) | ||
| case 'string': | ||
| default: | ||
| return v; | ||
| } | ||
| } |
| const fs = require('fs') | ||
| const path = require('path') | ||
| const net = require('net') | ||
| const http = require('http') | ||
| const https = require('https') | ||
| const axios = require('axios').default | ||
| const ProxyAgent = require('proxy-agent') | ||
| const { describe, beforeAll, afterAll, it, expect, afterEach } = require('@jest/globals') | ||
| const { createHttpProxy } = require('../src/http-proxy') | ||
| const time = () => new Date().toISOString() | ||
| /** | ||
| * @param {net.Server} server | ||
| */ | ||
| const trackSockets = (server) => { | ||
| /** @type {Record<string, net.Socket>} */ | ||
| const sockets = {} | ||
| server.on('connection', (socket) => { | ||
| const name = socket.remoteAddress + ':' + socket.remotePort | ||
| sockets[name] = socket; | ||
| socket.on('close', () => { | ||
| delete sockets[name] | ||
| }) | ||
| }) | ||
| server.on('destroy', () => { | ||
| for (const name in sockets) { | ||
| sockets[name].destroy() | ||
| } | ||
| }) | ||
| } | ||
| describe('http-proxy', () => { | ||
| /** @template T */ | ||
| function Deffer() { | ||
| /** @type {Promise<T>} */ | ||
| this.promise = new Promise((resolve, reject) => { | ||
| this.resolve = resolve; | ||
| this.reject = reject; | ||
| }) | ||
| } | ||
| /** @param {import('net').Server} server */ | ||
| const serverListen = async (server) => { | ||
| const deffer = new Deffer(); | ||
| server.listen(() => { | ||
| const address = server.address(); | ||
| deffer.resolve(address ? address.port : null) | ||
| }) | ||
| server.on('error', (e) => { | ||
| console.log(time(), 'server-error', e) | ||
| }) | ||
| trackSockets(server) | ||
| return deffer.promise; | ||
| } | ||
| /** @param {import('net').Server} server */ | ||
| const serverClose = async (server) => { | ||
| const deffer = new Deffer(); | ||
| server.emit('destroy') | ||
| server.close((error) => { | ||
| error ? deffer.reject(error) : deffer.resolve(); | ||
| }) | ||
| return deffer.promise; | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: import('../src/http-proxy').HttpProxyServer; | ||
| * port: number; | ||
| * auth: { username: string; password: string; } | ||
| * options: import('../src/http-proxy').HttpProxyServerOptions; | ||
| * }} | ||
| */ | ||
| const proxy = { | ||
| auth: { | ||
| username: 'test', | ||
| password: '1234', | ||
| }, | ||
| options: { | ||
| keepAlive: true, | ||
| keepAliveMsecs: 1000, | ||
| authType: 'Basic', | ||
| authRealm: 'Need authentication', | ||
| onAuth: myAuth, | ||
| } | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: http.Server; | ||
| * port: number; | ||
| * onRequest: http.RequestListener; | ||
| * }} | ||
| */ | ||
| const _http = { | ||
| onRequest: function onRequest(req, res) { | ||
| if (req.url.includes('/json')) { | ||
| const json = { foo: 'bar' } | ||
| res.writeHead(200, { | ||
| 'Server': 'test-server', | ||
| 'Content-Type': 'application/json', | ||
| 'Content-Length': JSON.stringify(json).length, | ||
| }) | ||
| res.end(JSON.stringify(json)) | ||
| } else { | ||
| res.writeHead(200, { 'Server': 'test-server', 'Content-Type': 'text/plain' }) | ||
| res.end('test-body') | ||
| } | ||
| }, | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: https.Server; | ||
| * port: number; | ||
| * ssl: { | ||
| * cert: string; | ||
| * key: string; | ||
| * } | ||
| * }} | ||
| */ | ||
| const _https = { | ||
| ssl: { | ||
| cert: fs.readFileSync(path.join(__dirname, './public-cert.pem'), 'utf-8'), | ||
| key: fs.readFileSync(path.join(__dirname, './private-key.pem'), 'utf-8'), | ||
| } | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * proxy: import('axios').AxiosProxyConfig; | ||
| * }} | ||
| */ | ||
| const _client = {} | ||
| /** @type {import('../src/http-proxy').OnAuth */ | ||
| function myAuth(auth, callback) { | ||
| switch (auth.type) { | ||
| case 'Basic': | ||
| callback(auth.username === proxy.auth.username && auth.password === proxy.auth.password); | ||
| break; | ||
| case 'Bearer': | ||
| callback(auth.token === proxy.auth.password) | ||
| break; | ||
| default: | ||
| callback(false) | ||
| } | ||
| } | ||
| /** | ||
| * @param {{ method?: string; url: string; data?: any; headers?: Record<string, string>; } | string} config | ||
| * @param {import('axios').AxiosRequestConfig} [opts] | ||
| */ | ||
| const request = async (config, opts) => { | ||
| config = typeof config === 'string' ? { method: 'GET', url: config } : Object.assign({ method: 'GET' }, config) | ||
| const res = await axios.request({ | ||
| ...config, | ||
| validateStatus: (status) => { | ||
| return status >= 200 && status < 600; | ||
| }, | ||
| proxy: { ..._client.proxy }, | ||
| ...opts, | ||
| headers: { | ||
| ...config.headers, | ||
| ...(opts ? opts.headers : undefined), | ||
| 'connection': 'keep-alive', | ||
| }, | ||
| }) | ||
| return res | ||
| } | ||
| beforeAll(async () => { | ||
| proxy.server = createHttpProxy({ ...proxy.options }) | ||
| _http.server = http.createServer(_http.onRequest) | ||
| _https.server = https.createServer(_https.ssl, _http.onRequest) | ||
| proxy.port = await serverListen(proxy.server); | ||
| _http.port = await serverListen(_http.server) | ||
| _https.port = await serverListen(_https.server) | ||
| _client.proxy = { | ||
| protocol: 'http:', | ||
| host: '127.0.0.1', | ||
| port: proxy.port, | ||
| } | ||
| }) | ||
| afterAll(async () => { | ||
| await serverClose(proxy.server) | ||
| await serverClose(_http.server) | ||
| await serverClose(_https.server) | ||
| }) | ||
| afterEach(() => { | ||
| proxy.server._rewriteOptions = proxy.options.rewriteOptions; | ||
| }) | ||
| describe('create', () => { | ||
| it('http.Server', () => { | ||
| expect.assertions(1) | ||
| try { | ||
| const server = http.createServer() | ||
| const _proxy = createHttpProxy(server) | ||
| } catch (e) { | ||
| expect(e.message).toMatch(/http\.Server not allowed/i) | ||
| } | ||
| }) | ||
| it('net.Server', () => { | ||
| const server = net.createServer() | ||
| const proxy = createHttpProxy(server) | ||
| expect(proxy).toEqual(server) | ||
| }) | ||
| }) | ||
| describe('Proxy Authentication Required', () => { | ||
| it('Basic', async () => { | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| opts: { | ||
| proxy: { ..._client.proxy }, | ||
| } | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise; | ||
| const res = await request(url, opts) | ||
| expect(res.status).toBe(407) | ||
| expect(proxy.options.authRealm).toBeDefined() | ||
| expect(res.headers['proxy-authenticate']).toMatch('Basic') | ||
| expect(res.headers['proxy-authenticate']).not.toMatch('Bearer') | ||
| expect(res.headers['proxy-authenticate']).toMatch(proxy.options.authRealm) | ||
| }, Promise.resolve()) | ||
| }) | ||
| it('Bearer', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ authType: 'Bearer' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, opts) | ||
| expect(res.status).toBe(407) | ||
| expect(proxy.options.authRealm).toBeDefined() | ||
| expect(res.headers['proxy-authenticate']).toMatch('Bearer') | ||
| expect(res.headers['proxy-authenticate']).not.toMatch('Basic') | ||
| expect(res.headers['proxy-authenticate']).toMatch(proxy.options.authRealm) | ||
| }, Promise.resolve()) | ||
| }) | ||
| it('Type mismatch', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ authType: 'Basic' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Basic' }, | ||
| } | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Basic' }, | ||
| } | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, opts) | ||
| expect(res.status).toBe(407) | ||
| expect(proxy.options.authRealm).toBeDefined() | ||
| expect(res.headers['proxy-authenticate']).toMatch('Basic') | ||
| expect(res.headers['proxy-authenticate']).not.toMatch('Bearer') | ||
| expect(res.headers['proxy-authenticate']).toMatch(proxy.options.authRealm) | ||
| }, Promise.resolve()) | ||
| }) | ||
| it('Wrong credentials - Bearer', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ authType: 'Bearer' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Bearer 1' }, | ||
| } | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Bearer 321' }, | ||
| } | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, opts) | ||
| expect(res.status).toBe(407) | ||
| expect(proxy.options.authRealm).toBeDefined() | ||
| expect(res.headers['proxy-authenticate']).toMatch('Bearer') | ||
| expect(res.headers['proxy-authenticate']).not.toMatch('Basic') | ||
| expect(res.headers['proxy-authenticate']).toMatch(proxy.options.authRealm) | ||
| }, Promise.resolve()) | ||
| }) | ||
| it('Wrong credentials - Basic', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ authType: 'Basic' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Basic ' + Buffer.from('user:321').toString('base64') }, | ||
| } | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| opts: { | ||
| headers: { 'proxy-authorization': 'Basic ' + Buffer.from('user:321').toString('base64') }, | ||
| } | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, opts) | ||
| expect(res.status).toBe(407) | ||
| expect(proxy.options.authRealm).toBeDefined() | ||
| expect(res.headers['proxy-authenticate']).toMatch('Basic') | ||
| expect(res.headers['proxy-authenticate']).not.toMatch('Bearer') | ||
| expect(res.headers['proxy-authenticate']).toMatch(proxy.options.authRealm) | ||
| }, Promise.resolve()) | ||
| }) | ||
| }) | ||
| describe('Proxy authorization', () => { | ||
| it('Basic', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| const opts = {} | ||
| if (req.url.includes('https://') && req.method !== 'CONNECT') { | ||
| Object.assign(opts, { rejectUnauthorized: false }) | ||
| } | ||
| callback({ ...opts, authType: 'Basic' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, { | ||
| proxy: { | ||
| ..._client.proxy, | ||
| auth: proxy.auth, | ||
| }, | ||
| headers: { | ||
| connection: 'keep-alive', | ||
| }, | ||
| ...opts, | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| expect(res.headers['server']).toBe('test-server') | ||
| expect(res.headers['content-type']).toBe('text/plain') | ||
| expect(res.data).toBe('test-body') | ||
| }, Promise.resolve()) | ||
| }) | ||
| it('Bearer', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| const opts = {} | ||
| if (req.url.includes('https://') && req.method !== 'CONNECT') { | ||
| Object.assign(opts, { rejectUnauthorized: false }) | ||
| } | ||
| callback({ ...opts, authType: 'Bearer' }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, { | ||
| ...opts, | ||
| headers: { | ||
| 'proxy-authorization': 'Bearer ' + proxy.auth.password, | ||
| } | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| expect(res.headers['server']).toBe('test-server') | ||
| expect(res.headers['content-type']).toBe('text/plain') | ||
| expect(res.data).toBe('test-body') | ||
| }, Promise.resolve()) | ||
| }) | ||
| }) | ||
| describe('Proxy https', () => { | ||
| it('google.com', async () => { | ||
| proxy.server._rewriteOptions = (_req, callback) => { | ||
| callback({ authType: undefined }) | ||
| } | ||
| const res = await request('https://google.com', { | ||
| proxy: false, | ||
| httpsAgent: new ProxyAgent({ | ||
| ..._client.proxy, | ||
| }) | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| }) | ||
| }) | ||
| describe('useHttpRequest', () => { | ||
| it('true', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| const opts = {} | ||
| if (req.url.includes('https:')) { | ||
| Object.assign(opts, { rejectUnauthorized: false }) | ||
| } | ||
| callback({ ...opts, useHttpRequest: true }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, { | ||
| ...opts, | ||
| proxy: { | ||
| ..._client.proxy, | ||
| auth: proxy.auth, | ||
| } | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| expect(res.headers['server']).toBe('test-server') | ||
| expect(res.headers['content-type']).toBe('text/plain') | ||
| expect(res.data).toBe('test-body') | ||
| }, Promise.resolve()) | ||
| }) | ||
| }) | ||
| describe('keepAlive', () => { | ||
| it('false', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ keepAlive: false, rejectUnauthorized: false, keepAliveMsecs: 400 }) | ||
| } | ||
| const requests = [{ | ||
| url: `http://127.0.0.1:${_http.port}/path?_=123`, | ||
| }, { | ||
| url: `https://127.0.0.1:${_https.port}/path?_=123`, | ||
| }] | ||
| await requests.reduce(async (promise, { url, opts }) => { | ||
| await promise | ||
| const res = await request(url, { | ||
| ...opts, | ||
| proxy: { | ||
| ..._client.proxy, | ||
| auth: proxy.auth, | ||
| } | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| expect(res.headers['server']).toBe('test-server') | ||
| expect(res.headers['content-type']).toBe('text/plain') | ||
| expect(res.data).toBe('test-body') | ||
| }, Promise.resolve()) | ||
| }) | ||
| }) | ||
| describe('Bad request', () => { | ||
| it('Wrong url', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ keepAlive: false, authType: undefined }) | ||
| } | ||
| const url = `http://127.0.0.1:${proxy.port}/path?_=1` | ||
| const res = await request(url, { proxy: false }) | ||
| expect(res.status).toBeGreaterThanOrEqual(400) | ||
| expect(res.status).toBeLessThan(500) | ||
| }) | ||
| }) | ||
| describe('Proxy misc', () => { | ||
| it('json', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ keepAlive: false, authType: undefined, useHttpRequest: true }) | ||
| } | ||
| const url = `http://127.0.0.1:${_http.port}/json?_=1` | ||
| const res = await request(url) | ||
| expect(res.status).toBe(200) | ||
| expect(res.data).toBeDefined() | ||
| expect(res.data).toHaveProperty('foo') | ||
| }) | ||
| it('onAuth defaults', async () => { | ||
| proxy.server._rewriteOptions = (req, callback) => { | ||
| callback({ keepAlive: false, authType: 'Bearer', onAuth: undefined }) | ||
| } | ||
| const url = `http://127.0.0.1:${_http.port}/json?_=1` | ||
| const res = await request(url, { | ||
| headers: { | ||
| 'proxy-authorization': 'Bearer ' + proxy.auth.password, | ||
| } | ||
| }) | ||
| expect(res.status).toBe(407) | ||
| }) | ||
| }) | ||
| }) |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| const fs = require('fs') | ||
| const path = require('path') | ||
| const http = require('http') | ||
| const https = require('https') | ||
| const { describe, beforeAll, afterAll, it, expect } = require('@jest/globals') | ||
| const axios = require('axios').default; | ||
| const { SocksProxyAgent } = require('socks-proxy-agent') | ||
| const { createSocksProxy } = require('../src/socks-proxy') | ||
| describe('socks-proxy', () => { | ||
| /** @template T */ | ||
| function Deffer() { | ||
| /** @type {Promise<T>} */ | ||
| this.promise = new Promise((resolve, reject) => { | ||
| this.resolve = resolve; | ||
| this.reject = reject; | ||
| }) | ||
| } | ||
| /** @param {import('net').Server} server */ | ||
| const trackSockets = (server) => { | ||
| if (server._tracking) { | ||
| return; | ||
| } | ||
| server._tracking = true; | ||
| /** @type {Record<string, import('net').Socket>} */ | ||
| const sockets = {} | ||
| server.on('connection', (socket) => { | ||
| const name = `${socket.remoteAddress}:${socket.remotePort}` | ||
| sockets[name] = socket; | ||
| socket.on('close', () => { | ||
| delete sockets[name] | ||
| }) | ||
| }) | ||
| server.on('destroy', () => { | ||
| for (const name in sockets) { | ||
| sockets[name].destroy() | ||
| delete sockets[name] | ||
| } | ||
| }) | ||
| } | ||
| /** @param {import('net').Server} server */ | ||
| const serverListen = async (server) => { | ||
| /** @type {Deffer<number>} */ | ||
| const deffer = new Deffer() | ||
| server.listen(() => { | ||
| deffer.resolve(server.address().port) | ||
| }) | ||
| trackSockets(server) | ||
| return deffer.promise; | ||
| } | ||
| /** @param {import('net').Server} server */ | ||
| const serverClose = async (server) => { | ||
| const deffer = new Deffer() | ||
| server.close((error) => { | ||
| error ? deffer.reject(error) : deffer.resolve() | ||
| }) | ||
| server.emit('destroy') | ||
| await deffer.promise; | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: import('../src/socks-proxy').SocksProxyServer; | ||
| * port: number; | ||
| * options: import('../src/socks-proxy').SocksProxyOptions; | ||
| * auth: { username: string; password: string; }; | ||
| * }} | ||
| */ | ||
| const proxy = { | ||
| auth: { | ||
| username: 'test', | ||
| password: '1234', | ||
| }, | ||
| options: { | ||
| keepAlive: true, | ||
| keepAliveMsecs: 1000, | ||
| onAuth: (username, password, callback) => { | ||
| callback(proxy.auth.username === username && proxy.auth.password === password) | ||
| }, | ||
| } | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: http.Server; | ||
| * port: number; | ||
| * onRequest: http.RequestListener; | ||
| * }} | ||
| */ | ||
| const _http = { | ||
| onRequest: (req, res) => { | ||
| if (req.url.includes('/json')) { | ||
| const json = JSON.stringify({ foo: 'bar' }) | ||
| res.writeHead(200, { | ||
| 'Content-Type': 'application/json', | ||
| 'Content-Length': json.length, | ||
| }) | ||
| res.end(json) | ||
| } else { | ||
| const text = 'test-socks' | ||
| res.writeHead(200, { | ||
| 'Content-Type': 'text/plain', | ||
| 'Content-Length': text.length, | ||
| }) | ||
| res.end(text) | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @type {{ | ||
| * server: https.Server; | ||
| * port: number; | ||
| * cert: string; | ||
| * key: string; | ||
| * }} | ||
| */ | ||
| const _https = {} | ||
| beforeAll(async () => { | ||
| proxy.server = createSocksProxy(proxy.options) | ||
| _http.server = http.createServer(_http.onRequest); | ||
| _https.cert = fs.readFileSync(path.join(__dirname, 'public-cert.pem'), 'utf-8') | ||
| _https.key = fs.readFileSync(path.join(__dirname, 'private-key.pem'), 'utf-8') | ||
| _https.server = https.createServer({ cert: _https.cert, key: _https.key }, _http.onRequest); | ||
| proxy.port = await serverListen(proxy.server) | ||
| _http.port = await serverListen(_http.server) | ||
| _https.port = await serverListen(_https.server) | ||
| }) | ||
| afterAll(async () => { | ||
| await serverClose(proxy.server) | ||
| await serverClose(_http.server) | ||
| await serverClose(_https.server) | ||
| }) | ||
| it('Invalid params', () => { | ||
| const server = http.createServer() | ||
| expect.assertions(1) | ||
| try { | ||
| const proxy = createSocksProxy(server) | ||
| } catch (e) { | ||
| expect(e.message).toMatch(/http\.Server not allowed/i) | ||
| } | ||
| }) | ||
| it('Successfull request', async () => { | ||
| const config = { | ||
| hostname: '127.0.0.1', | ||
| port: proxy.port, | ||
| tls: { | ||
| rejectUnauthorized: false, | ||
| }, | ||
| } | ||
| const agents = [{ | ||
| protocol: 'socks4:' | ||
| }, { | ||
| protocol: 'socks4a:', | ||
| }, { | ||
| protocol: 'socks5:', | ||
| username: proxy.auth.username, | ||
| password: proxy.auth.password, | ||
| }, { | ||
| protocol: 'socks5h:', | ||
| username: proxy.auth.username, | ||
| password: proxy.auth.password, | ||
| }].map(opts => new SocksProxyAgent({ ...config, ...opts })) | ||
| const run = async () => { | ||
| const requests = [{ | ||
| url: `http://localhost:${_http.port}/path?_=${Date.now()}`, | ||
| }, { | ||
| url: `https://localhost:${_https.port}/json?_=${Date.now()}`, | ||
| }, { | ||
| url: `https://example.com/?_=${Date.now()}`, | ||
| }] | ||
| const promises = agents.map(async (agent) => { | ||
| await requests.reduce(async (promise, { url }) => { | ||
| await promise; | ||
| const res = await axios.get(url, { | ||
| httpAgent: agent, | ||
| httpsAgent: agent, | ||
| validateStatus: (status) => status >= 200 && status < 600, | ||
| }) | ||
| expect(res.status).toEqual(200) | ||
| expect(res.data).toBeDefined() | ||
| if (url.includes('/json')) { | ||
| expect(res.data).toHaveProperty('foo') | ||
| } else if (!url.includes('example.com')) { | ||
| expect(res.data).toEqual('test-socks') | ||
| } | ||
| }, Promise.resolve()) | ||
| }) | ||
| await Promise.all(promises) | ||
| } | ||
| await run() | ||
| proxy.server._socksRewriteOptions = (_, callback) => { | ||
| callback({ keepAlive: false }) | ||
| } | ||
| await run() | ||
| }, 10 * 1000) | ||
| it('Unsupported auth type error', async () => { | ||
| const config = { | ||
| hostname: '127.0.0.1', | ||
| port: proxy.port, | ||
| tls: { | ||
| rejectUnauthorized: false, | ||
| }, | ||
| } | ||
| const agent = new SocksProxyAgent({ ...config, protocol: 'socks5h:' }) | ||
| const url = `http://localhost:${_http.port}/path?_=${Date.now()}` | ||
| expect.assertions(1) | ||
| try { | ||
| const res = await axios.get(url, { | ||
| httpAgent: agent, | ||
| validateStatus: (status) => status >= 200 && status < 600, | ||
| }) | ||
| } catch (e) { | ||
| expect(e.message).toBeDefined() | ||
| } | ||
| }) | ||
| it('No auth', async () => { | ||
| const config = { | ||
| hostname: '127.0.0.1', | ||
| port: proxy.port, | ||
| tls: { | ||
| rejectUnauthorized: false, | ||
| }, | ||
| } | ||
| proxy.server._socksRewriteOptions = (_, callback) => { | ||
| callback({ onAuth: undefined }) | ||
| } | ||
| const agent = new SocksProxyAgent({ ...config, protocol: 'socks5h:' }) | ||
| const url = `http://localhost:${_http.port}/path?_=${Date.now()}` | ||
| const res = await axios.get(url, { | ||
| httpAgent: agent, | ||
| validateStatus: (status) => status >= 200 && status < 600, | ||
| }) | ||
| expect(res.status).toBe(200) | ||
| }) | ||
| }) |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
2
-60%1
-50%5
-61.54%25960
-49.44%695
-53.26%93
-19.13%