use socks5 instead of HTTP Proxy to allow more protocols

This commit is contained in:
2025-09-05 14:26:57 +08:00
parent 0e1980df9b
commit b98cd1bb22
4 changed files with 902 additions and 547 deletions

View File

@@ -1,9 +1,12 @@
#!/usr/bin/env node
/**
* PSK HTTP/HTTPS Proxy (Client)
* PSK SOCKS5 Proxy (Client)
*
* - Runs a local HTTP proxy (supports CONNECT for HTTPS and absolute/origin-form HTTP for port 80)
* - Runs a local SOCKS5 proxy (supports:
* - CONNECT for arbitrary TCP protocols (HTTP, HTTPS, SSH, etc.)
* - UDP ASSOCIATE for UDP-based protocols (e.g., DNS)
* - BIND: not supported, returns error)
* - Maintains a single TLS-PSK tunnel to a remote out-node (proxy-server.js)
* - Multiplexes many local client connections over one TLS tunnel using frames:
*
@@ -11,28 +14,32 @@
* Types:
* DATA (2)
* CLOSE (3)
* OPEN (4) payload = [2B hostLen][host utf8][2B port]
* OPEN_RESULT (5) payload = [1B status] 1=success,0=failure
* OPEN (4) payload = [2B hostLen][host utf8][2B port]
* OPEN_RESULT (5) payload = [1B status] 1=success,0=failure
*
* HTTPS: Use CONNECT method. We respond 200 after OPEN_RESULT success, then tunnel raw TLS.
* HTTP: Convert absolute-form to origin-form and forward over opened upstream (default port 80 if not specified).
* UDP_OPEN (6) payload = empty
* UDP_OPEN_RESULT (7) payload = [1B status]
* UDP_SEND (8) payload = [2B hostLen][host][2B port][2B dataLen][data...]
* UDP_RECV (9) payload = same as UDP_SEND (host:port,data)
* UDP_CLOSE (10) payload = empty
*/
const net = require('net');
const tls = require('tls');
const fs = require('fs');
const { URL } = require('url');
const dgram = require('dgram');
const { program } = require('commander');
program
.requiredOption('--server-host <host>', 'Proxy out-node server host')
.requiredOption('--server-port <port>', 'Proxy out-node server port')
.requiredOption('--psk-file <path>', 'Path to PSK key file')
.requiredOption('--psk-file <path>', 'Path to PSK key file (hex)')
.requiredOption('--identity <identity>', 'PSK identity string')
.requiredOption('--proxy-port <port>', 'Local HTTP proxy port to listen on')
.requiredOption('--socks-port <port>', 'Local SOCKS5 proxy port to listen on')
.option('--bind-host <host>', 'Local bind host', '127.0.0.1')
.option('--connect-timeout <ms>', 'Timeout waiting OPEN_RESULT (ms)', '10000')
.option('--idle-timeout <ms>', 'Idle timeout for local sockets (ms)', '60000')
.option('--connect-timeout <ms>', 'Timeout waiting OPEN/UDP_OPEN result (ms)', '10000')
.option('--idle-timeout <ms>', 'Idle timeout for local TCP sockets (ms)', '60000')
.option('--udp-idle-timeout <ms>', 'Idle timeout for UDP associate socket (ms, 0=never)', '60000')
.parse();
const options = program.opts();
@@ -47,6 +54,7 @@ try {
const OPEN_RESULT_TIMEOUT = parseInt(options.connectTimeout, 10) || 10000;
const IDLE_TIMEOUT = parseInt(options.idleTimeout, 10) || 60000;
const UDP_IDLE_TIMEOUT = parseInt(options.udpIdleTimeout, 10) || 60000;
// Message Types
const MSG_TYPES = {
@@ -54,6 +62,12 @@ const MSG_TYPES = {
CLOSE: 3,
OPEN: 4,
OPEN_RESULT: 5,
UDP_OPEN: 6,
UDP_OPEN_RESULT: 7,
UDP_SEND: 8,
UDP_RECV: 9,
UDP_CLOSE: 10,
};
function buildOpenPayload(host, port) {
@@ -66,6 +80,18 @@ function buildOpenPayload(host, port) {
return buf;
}
function buildUdpPayload(host, port, data) {
const hostBuf = Buffer.from(host, 'utf8');
const buf = Buffer.allocUnsafe(2 + hostBuf.length + 2 + 2 + data.length);
let off = 0;
buf.writeUInt16BE(hostBuf.length, off); off += 2;
hostBuf.copy(buf, off); off += hostBuf.length;
buf.writeUInt16BE(port, off); off += 2;
buf.writeUInt16BE(data.length, off); off += 2;
data.copy(buf, off);
return buf;
}
function writeMessage(socket, type, connectionId, data = Buffer.alloc(0)) {
if (!socket || socket.destroyed) return;
const header = Buffer.allocUnsafe(9);
@@ -112,7 +138,7 @@ function createMessageReader() {
function pskCallback(/* hint */) {
return {
identity: options.identity,
psk: Buffer.from(pskKey, 'hex')
psk: Buffer.from(pskKey, 'hex'),
};
}
@@ -120,15 +146,22 @@ function pskCallback(/* hint */) {
let tunnelSocket = null;
let reader = null;
const connections = new Map(); // connectionId -> ConnState
// TCP connection state: id -> { id, localSocket, opened, openTimer }
const connections = new Map();
let nextConnId = 1;
// UDP association state: udpId -> {
// id, controlSocket, udpSocket, openTimer, opened,
// clients: Set of "host:port" strings to reply to,
// }
const udpAssocs = new Map();
function genConnId() {
let id = nextConnId >>> 0;
do {
id = (id + 1) >>> 0;
if (id === 0) id = 1;
} while (connections.has(id));
} while (connections.has(id) || udpAssocs.has(id));
nextConnId = id;
return id;
}
@@ -138,15 +171,18 @@ function connectTunnel() {
const port = parseInt(options.serverPort, 10);
console.log(`Connecting to proxy out-node ${host}:${port} via TLS-PSK...`);
const sock = tls.connect({
host,
port,
pskCallback,
ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256',
checkServerIdentity: () => undefined
}, () => {
console.log('Proxy tunnel connected');
});
const sock = tls.connect(
{
host,
port,
pskCallback,
ciphers: 'PSK-AES256-GCM-SHA384:PSK-AES128-GCM-SHA256',
checkServerIdentity: () => undefined,
},
() => {
console.log('Proxy tunnel connected');
}
);
sock.setNoDelay(true);
sock.setKeepAlive(true, 30000);
@@ -156,66 +192,94 @@ function connectTunnel() {
sock.on('data', (data) => {
reader(data, (type, connectionId, payload) => {
const st = connections.get(connectionId);
if (!st) {
// Unknown or already closed
if (type === MSG_TYPES.OPEN_RESULT) {
// No state; ignore
}
return;
}
if (type === MSG_TYPES.OPEN_RESULT) {
const st = connections.get(connectionId);
if (!st) return;
const ok = payload.length > 0 ? payload.readUInt8(0) === 1 : false;
if (st.opened) return;
clearTimeout(st.openTimer);
if (!ok) {
// Notify client error
if (st.mode === 'CONNECT') {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 502 Bad Gateway\r\nProxy-Agent: PSK-Proxy\r\n\r\n'
));
} else {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 11\r\nContent-Type: text/plain\r\n\r\nBad Gateway'
));
}
destroyLocal(connectionId);
// SOCKS5 CONNECT failure -> reply and close
try {
const rep = buildSocksReply(0x05 /* connection refused */, '0.0.0.0', 0);
st.localSocket.write(rep);
} catch (_) {}
destroyLocalTCP(connectionId);
return;
}
st.opened = true;
// For CONNECT: send 200 OK to client, then flush any buffered data (likely none)
if (st.mode === 'CONNECT') {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 200 Connection Established\r\nProxy-Agent: PSK-Proxy\r\n\r\n'
));
// If there was any extra data after headers (shouldn't for CONNECT), flush it
flushBufferedToTunnel(connectionId);
} else {
// HTTP mode: first send the rewritten initial request, then any buffered tail
if (st.initialUpstream && st.initialUpstream.length > 0) {
writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, st.initialUpstream);
st.initialUpstream = null;
}
flushBufferedToTunnel(connectionId);
}
// Reply success for CONNECT
try {
const rep = buildSocksReply(0x00 /* succeeded */, '0.0.0.0', 0);
st.localSocket.write(rep);
} catch (_) {}
// Switch to streaming mode
armTcpStreaming(st);
} else if (type === MSG_TYPES.DATA) {
if (!st.localSocket.destroyed) {
const st = connections.get(connectionId);
if (st && !st.localSocket.destroyed) {
st.localSocket.write(payload);
}
} else if (type === MSG_TYPES.CLOSE) {
// Upstream closed
destroyLocal(connectionId);
destroyLocalTCP(connectionId);
} else if (type === MSG_TYPES.UDP_OPEN_RESULT) {
const as = udpAssocs.get(connectionId);
if (!as) return;
const ok = payload.length > 0 ? payload.readUInt8(0) === 1 : false;
clearTimeout(as.openTimer);
if (!ok) {
// Tell SOCKS5 client UDP associate failed
try {
const rep = buildSocksReply(0x01 /* general failure */, '0.0.0.0', 0);
as.controlSocket.write(rep);
} catch (_) {}
closeUdpAssoc(connectionId);
try { as.controlSocket.destroy(); } catch (_) {}
udpAssocs.delete(connectionId);
return;
}
as.opened = true;
// Reply with our UDP bind address to client
const addrInfo = as.udpSocket.address();
const bndAddr = isIPv6Address(addrInfo.address) ? '::' : addrInfo.address || '0.0.0.0';
const bndPort = addrInfo.port || 0;
try {
const rep = buildSocksReply(0x00 /* succeeded */, bndAddr, bndPort);
as.controlSocket.write(rep);
} catch (_) {}
// Keep control connection open; UDP messages handled on udpSocket
} else if (type === MSG_TYPES.UDP_RECV) {
// Payload: [hostLen][host][port][dataLen][data...]
const parsed = parseUdpPayload(payload);
if (!parsed) return;
const as = udpAssocs.get(connectionId);
if (!as) return;
// Build SOCKS5 UDP response and send to all known client endpoints
const { host, port, data } = parsed;
const udpPacket = buildSocksUdpDatagram(host, port, data);
for (const ep of as.clients) {
const [caddr, cportStr] = ep.split(':');
const cport = parseInt(cportStr, 10);
try {
as.udpSocket.send(udpPacket, cport, caddr);
} catch (_) {}
}
} else {
// ignore unknown message types
}
});
});
sock.on('close', () => {
console.log('Proxy tunnel closed. Cleaning up local connections and retrying in 2s...');
// Close all local connections
// Close all local TCP connections
for (const [id] of connections) {
destroyLocal(id);
destroyLocalTCP(id);
}
// Close all UDP assocs
for (const [id] of udpAssocs) {
closeUdpAssoc(id);
}
tunnelSocket = null;
setTimeout(connectTunnel, 2000);
@@ -227,21 +291,11 @@ function connectTunnel() {
}
function safeWrite(socket, buf) {
if (!socket.destroyed) {
try { socket.write(buf); } catch (_) {}
}
if (!socket || socket.destroyed) return;
try { socket.write(buf); } catch (_) {}
}
function flushBufferedToTunnel(connectionId) {
const st = connections.get(connectionId);
if (!st || !tunnelSocket || tunnelSocket.destroyed) return;
while (st.bufferQueue.length > 0) {
const chunk = st.bufferQueue.shift();
writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk);
}
}
function destroyLocal(connectionId) {
function destroyLocalTCP(connectionId) {
const st = connections.get(connectionId);
if (!st) return;
connections.delete(connectionId);
@@ -249,71 +303,215 @@ function destroyLocal(connectionId) {
try { st.localSocket.destroy(); } catch (_) {}
}
function parseHeadersUntil(buffer) {
const idx = buffer.indexOf('\r\n\r\n');
if (idx === -1) return null;
const headerPart = buffer.subarray(0, idx).toString('utf8');
const rest = buffer.subarray(idx + 4);
const lines = headerPart.split('\r\n');
const requestLine = lines.shift() || '';
const headers = {};
for (const line of lines) {
const p = line.indexOf(':');
if (p > -1) {
const name = line.slice(0, p).trim();
const val = line.slice(p + 1).trim();
const key = name.toLowerCase();
if (headers[key] === undefined) headers[key] = val;
else if (Array.isArray(headers[key])) headers[key].push(val);
else headers[key] = [headers[key], val];
}
}
return { requestLine, rawHeaderLines: lines, headers, rest };
}
function buildRequestLine(method, target, version) {
return `${method} ${target} ${version}`;
}
function startOpen(connectionId, host, port, st) {
function startOpenTcp(connectionId, host, port, st) {
if (!tunnelSocket || tunnelSocket.destroyed) {
// No tunnel available
if (st.mode === 'CONNECT') {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 503 Service Unavailable\r\nProxy-Agent: PSK-Proxy\r\n\r\n'
));
} else {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 19\r\nContent-Type: text/plain\r\n\r\nService Unavailable'
));
}
destroyLocal(connectionId);
// Reply SOCKS failure
try {
const rep = buildSocksReply(0x01 /* general failure */, '0.0.0.0', 0);
st.localSocket.write(rep);
} catch (_) {}
destroyLocalTCP(connectionId);
return;
}
// Set a timer for open result
st.openTimer = setTimeout(() => {
if (!st.opened) {
// Timeout
if (st.mode === 'CONNECT') {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 504 Gateway Timeout\r\nProxy-Agent: PSK-Proxy\r\n\r\n'
));
} else {
safeWrite(st.localSocket, Buffer.from(
'HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\nContent-Length: 15\r\nContent-Type: text/plain\r\n\r\nGateway Timeout'
));
}
// Best-effort to close upstream
try {
const rep = buildSocksReply(0x06 /* TTL expired (timeout) */, '0.0.0.0', 0);
st.localSocket.write(rep);
} catch (_) {}
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId);
destroyLocal(connectionId);
destroyLocalTCP(connectionId);
}
}, OPEN_RESULT_TIMEOUT);
writeMessage(tunnelSocket, MSG_TYPES.OPEN, connectionId, buildOpenPayload(host, port));
}
function handleLocalConnection(localSocket) {
function armTcpStreaming(st) {
// After CONNECT success, forward subsequent data
const forward = (chunk) => {
if (!tunnelSocket || tunnelSocket.destroyed) return;
writeMessage(tunnelSocket, MSG_TYPES.DATA, st.id, chunk);
};
st.localSocket.on('data', forward);
st.localSocket.once('close', () => {
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, st.id);
connections.delete(st.id);
clearTimeout(st.openTimer);
});
st.localSocket.once('error', () => {
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, st.id);
connections.delete(st.id);
clearTimeout(st.openTimer);
});
}
/* ============== SOCKS5 Helpers ============== */
function isIPv4Address(addr) {
return net.isIPv4(addr);
}
function isIPv6Address(addr) {
return net.isIPv6(addr);
}
function buildSocksReply(rep, bndAddr, bndPort) {
// VER=5, REP, RSV=0, ATYP, BND.ADDR, BND.PORT
const addrBufInfo = encodeSocksAddr(bndAddr);
const buf = Buffer.allocUnsafe(4 + addrBufInfo.addr.length + 2);
let off = 0;
buf.writeUInt8(5, off++); // VER
buf.writeUInt8(rep, off++); // REP
buf.writeUInt8(0, off++); // RSV
buf.writeUInt8(addrBufInfo.atyp, off++); // ATYP
addrBufInfo.addr.copy(buf, off); off += addrBufInfo.addr.length;
buf.writeUInt16BE(bndPort >>> 0, off); off += 2;
return buf;
}
function encodeSocksAddr(addr) {
if (isIPv4Address(addr)) {
return { atyp: 0x01, addr: Buffer.from(addr.split('.').map((x) => parseInt(x, 10))) };
}
if (isIPv6Address(addr)) {
return { atyp: 0x04, addr: ipToBuffer(addr) };
}
const nameBuf = Buffer.from(addr, 'utf8');
const len = Math.min(nameBuf.length, 255);
const nb = nameBuf.subarray(0, len);
return { atyp: 0x03, addr: Buffer.concat([Buffer.from([nb.length]), nb]) };
}
function ipToBuffer(ip) {
// Convert IPv6 or IPv4-mapped IPv6 to buffer
if (isIPv4Address(ip)) {
return Buffer.from(ip.split('.').map((x) => parseInt(x, 10)));
}
// For IPv6 literals, use URL parser trick
// Node doesn't provide direct conversion; implement simple parser:
const sections = ip.split('::');
let head = [];
let tail = [];
if (sections.length === 1) {
head = ip.split(':');
} else if (sections.length === 2) {
head = sections[0] ? sections[0].split(':') : [];
tail = sections[1] ? sections[1].split(':') : [];
} else {
// invalid
return Buffer.alloc(16);
}
// Expand head and tail into 8 blocks
const total = head.length + tail.length;
const fill = 8 - total;
const parts = [...head, ...Array(fill).fill('0'), ...tail].map((p) => parseInt(p || '0', 16));
const buf = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
buf.writeUInt16BE(parts[i] || 0, i * 2);
}
return buf;
}
function parseSocksRequest(buf) {
// VER CMD RSV ATYP DST.ADDR DST.PORT
if (buf.length < 4) return null;
const ver = buf.readUInt8(0);
if (ver !== 5) return { error: 'VERSION' };
const cmd = buf.readUInt8(1);
const rsv = buf.readUInt8(2);
const atyp = buf.readUInt8(3);
let off = 4;
let host = null;
if (atyp === 0x01) {
if (buf.length < off + 4 + 2) return null;
host = Array.from(buf.subarray(off, off + 4)).join('.');
off += 4;
} else if (atyp === 0x03) {
if (buf.length < off + 1) return null;
const len = buf.readUInt8(off); off += 1;
if (buf.length < off + len + 2) return null;
host = buf.subarray(off, off + len).toString('utf8');
off += len;
} else if (atyp === 0x04) {
if (buf.length < off + 16 + 2) return null;
const b = buf.subarray(off, off + 16);
host = [...Array(8).keys()].map((i) => b.readUInt16BE(i * 2).toString(16)).join(':');
off += 16;
} else {
return { error: 'ATYP' };
}
const port = buf.readUInt16BE(off); off += 2;
return { cmd, host, port, size: off };
}
function buildSocksMethodsResponse(method) {
const buf = Buffer.allocUnsafe(2);
buf.writeUInt8(5, 0);
buf.writeUInt8(method, 1);
return buf;
}
function parseUdpPayload(buf) {
if (buf.length < 2) return null;
let off = 0;
const hostLen = buf.readUInt16BE(off); off += 2;
if (buf.length < 2 + hostLen + 2 + 2) return null;
const host = buf.subarray(off, off + hostLen).toString('utf8'); off += hostLen;
const port = buf.readUInt16BE(off); off += 2;
const dataLen = buf.readUInt16BE(off); off += 2;
if (buf.length < 2 + hostLen + 2 + 2 + dataLen) return null;
const data = buf.subarray(off, off + dataLen);
return { host, port, data };
}
function buildSocksUdpDatagram(host, port, data) {
const addrInfo = encodeSocksAddr(host);
const header = Buffer.allocUnsafe(2 + 1 + 1);
header.writeUInt8(0x00, 0);
header.writeUInt8(0x00, 1);
header.writeUInt8(0x00, 2); // FRAG=0
header.writeUInt8(addrInfo.atyp, 3);
const portBuf = Buffer.allocUnsafe(2);
portBuf.writeUInt16BE(port >>> 0, 0);
return Buffer.concat([header, addrInfo.addr, portBuf, data]);
}
function parseSocksUdpRequest(buf) {
// UDP request: RSV(2)=0x0000, FRAG(1), ATYP, DST.ADDR, DST.PORT, DATA
if (buf.length < 4) return null;
const rsv0 = buf.readUInt8(0), rsv1 = buf.readUInt8(1);
if (rsv0 !== 0x00 || rsv1 !== 0x00) return { error: 'RSV' };
const frag = buf.readUInt8(2);
if (frag !== 0x00) return { error: 'FRAG' }; // we don't support fragmentation
const atyp = buf.readUInt8(3);
let off = 4;
let host = null;
if (atyp === 0x01) { // IPv4
if (buf.length < off + 4 + 2) return null;
host = Array.from(buf.subarray(off, off + 4)).join('.');
off += 4;
} else if (atyp === 0x03) { // domain
if (buf.length < off + 1) return null;
const len = buf.readUInt8(off); off += 1;
if (buf.length < off + len + 2) return null;
host = buf.subarray(off, off + len).toString('utf8'); off += len;
} else if (atyp === 0x04) { // IPv6
if (buf.length < off + 16 + 2) return null;
const b = buf.subarray(off, off + 16);
host = [...Array(8).keys()].map((i) => b.readUInt16BE(i * 2).toString(16)).join(':');
off += 16;
} else {
return { error: 'ATYP' };
}
const port = buf.readUInt16BE(off); off += 2;
const data = buf.subarray(off);
return { host, port, data };
}
/* ============== Local SOCKS5 Server and Flow ============== */
function handleSocksConnection(localSocket) {
localSocket.setNoDelay(true);
localSocket.setKeepAlive(true, 30000);
if (IDLE_TIMEOUT > 0) {
@@ -322,169 +520,210 @@ function handleLocalConnection(localSocket) {
});
}
const connectionId = genConnId();
const state = {
id: connectionId,
localSocket,
bufferQueue: [],
opened: false,
openTimer: null,
mode: null, // 'CONNECT' | 'HTTP'
initialUpstream: null // Buffer to send immediately after OPEN_RESULT in HTTP mode
};
connections.set(connectionId, state);
let firstBuffer = Buffer.alloc(0);
let headersParsed = false;
let stage = 'NEGOTIATION';
let buf = Buffer.alloc(0);
let tcpConnId = null; // for CONNECT
let udpAssocId = null; // for UDP ASSOCIATE
function onData(chunk) {
if (!headersParsed) {
firstBuffer = Buffer.concat([firstBuffer, chunk]);
buf = Buffer.concat([buf, chunk]);
// Limit header size to prevent abuse (64KB)
if (firstBuffer.length > 64 * 1024) {
safeWrite(localSocket, Buffer.from(
'HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n'
));
destroyLocal(connectionId);
if (stage === 'NEGOTIATION') {
if (buf.length < 2) return;
const ver = buf.readUInt8(0);
const nmethods = buf.readUInt8(1);
if (ver !== 5) {
localSocket.end();
return;
}
if (buf.length < 2 + nmethods) return;
const methods = Array.from(buf.subarray(2, 2 + nmethods));
buf = buf.subarray(2 + nmethods);
const parsed = parseHeadersUntil(firstBuffer);
if (!parsed) {
// Wait for more
// We support only NO AUTH (0x00)
const method = methods.includes(0x00) ? 0x00 : 0xFF;
localSocket.write(buildSocksMethodsResponse(method));
if (method === 0xFF) {
localSocket.end();
return;
}
headersParsed = true;
const { requestLine, rawHeaderLines, headers, rest } = parsed;
const parts = requestLine.split(' ');
if (parts.length < 3) {
safeWrite(localSocket, Buffer.from(
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'
));
destroyLocal(connectionId);
return;
}
const method = parts[0];
const target = parts[1];
const version = parts[2];
if (method.toUpperCase() === 'CONNECT') {
state.mode = 'CONNECT';
// target expected as host:port
let host = target;
let port = 443;
const cidx = host.lastIndexOf(':');
if (cidx !== -1) {
port = parseInt(host.slice(cidx + 1), 10) || 443;
host = host.slice(0, cidx);
}
// Initiate OPEN
startOpen(connectionId, host, port, state);
// Any rest after headers (rare for CONNECT) will be buffered until open
if (rest.length > 0) {
state.bufferQueue.push(rest);
}
} else {
state.mode = 'HTTP';
// Determine host, port, and path; rebuild request line to origin-form
let host = null;
let port = 80;
let path = target;
if (/^http:\/\//i.test(target)) {
try {
const u = new URL(target);
host = u.hostname;
port = u.port ? parseInt(u.port, 10) : 80;
path = u.pathname + (u.search || '');
if (path.length === 0) path = '/';
} catch {
safeWrite(localSocket, Buffer.from(
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'
));
destroyLocal(connectionId);
return;
}
} else if (/^https:\/\//i.test(target)) {
// We do not support absolute-form HTTPS over plaintext proxy without CONNECT
safeWrite(localSocket, Buffer.from(
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 49\r\nContent-Type: text/plain\r\n\r\nUse CONNECT method for HTTPS requests through this proxy'
));
destroyLocal(connectionId);
return;
} else {
// origin-form: must use Host header
const hostHeader = headers['host'];
if (!hostHeader) {
safeWrite(localSocket, Buffer.from(
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'
));
destroyLocal(connectionId);
return;
}
const hostVal = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
const cidx = hostVal.lastIndexOf(':');
if (cidx !== -1) {
host = hostVal.slice(0, cidx);
port = parseInt(hostVal.slice(cidx + 1), 10) || 80;
} else {
host = hostVal;
port = 80;
}
// path is already origin-form target
}
// Rebuild request line to origin-form
const newRequestLine = buildRequestLine(method, path, version);
const headerStr = [newRequestLine, ...rawHeaderLines].join('\r\n') + '\r\n\r\n';
state.initialUpstream = Buffer.concat([Buffer.from(headerStr, 'utf8'), rest]);
// Initiate OPEN then send initialUpstream upon success
startOpen(connectionId, host, port, state);
}
return;
stage = 'REQUEST';
}
// After headers parsed: if not yet opened, buffer; else forward
if (!state.opened) {
state.bufferQueue.push(chunk);
} else {
writeMessage(tunnelSocket, MSG_TYPES.DATA, connectionId, chunk);
while (stage === 'REQUEST') {
const parsed = parseSocksRequest(buf);
if (parsed === null) return; // need more
if (parsed.error) {
// invalid
try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {}
localSocket.end();
return;
}
const { cmd, host, port, size } = parsed;
buf = buf.subarray(size);
if (cmd === 0x01 /* CONNECT */) {
// Allocate TCP connection id and OPEN through tunnel
tcpConnId = genConnId();
const state = {
id: tcpConnId,
localSocket,
opened: false,
openTimer: null,
};
connections.set(tcpConnId, state);
startOpenTcp(tcpConnId, host, port, state);
// Wait for OPEN_RESULT to send reply, then go streaming
stage = 'CONNECT_WAIT';
} else if (cmd === 0x03 /* UDP ASSOCIATE */) {
// Create UDP association
udpAssocId = genConnId();
const as = {
id: udpAssocId,
controlSocket: localSocket,
udpSocket: null,
openTimer: null,
opened: false,
clients: new Set(),
};
udpAssocs.set(udpAssocId, as);
// Create local UDP socket for client to send datagrams to
const udpSock = dgram.createSocket('udp4');
as.udpSocket = udpSock;
udpSock.on('message', (msg, rinfo) => {
// Parse SOCKS5 UDP request
const req = parseSocksUdpRequest(msg);
if (!req || req.error) {
// ignore invalid/unhandled fragmentation
return;
}
// Track client endpoint so we can send replies back
as.clients.add(`${rinfo.address}:${rinfo.port}`);
// Send over tunnel as UDP_SEND
if (!tunnelSocket || tunnelSocket.destroyed) return;
writeMessage(tunnelSocket, MSG_TYPES.UDP_SEND, udpAssocId, buildUdpPayload(req.host, req.port, req.data));
});
udpSock.on('error', () => {
// Close association
try { udpSock.close(); } catch (_) {}
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
udpAssocs.delete(udpAssocId);
try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {}
try { localSocket.destroy(); } catch (_) {}
});
udpSock.bind(0, options.bindHost, () => {
// After binding, request UDP_OPEN on tunnel
if (!tunnelSocket || tunnelSocket.destroyed) {
try { localSocket.write(buildSocksReply(0x01, '0.0.0.0', 0)); } catch (_) {}
try { localSocket.destroy(); } catch (_) {}
return;
}
as.openTimer = setTimeout(() => {
if (!as.opened) {
try { localSocket.write(buildSocksReply(0x06, '0.0.0.0', 0)); } catch (_) {}
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
closeUdpAssoc(udpAssocId);
try { localSocket.destroy(); } catch (_) {}
}
}, OPEN_RESULT_TIMEOUT);
writeMessage(tunnelSocket, MSG_TYPES.UDP_OPEN, udpAssocId);
});
if (UDP_IDLE_TIMEOUT > 0) {
udpSock.on('listening', () => {
udpSock.setRecvBufferSize?.(1 << 20);
});
let idleTimer = setTimeout(() => {
try { udpSock.close(); } catch (_) {}
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
udpAssocs.delete(udpAssocId);
try { localSocket.destroy(); } catch (_) {}
}, UDP_IDLE_TIMEOUT);
// Reset idle timer on activity
udpSock.on('message', () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
try { udpSock.close(); } catch (_) {}
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
udpAssocs.delete(udpAssocId);
try { localSocket.destroy(); } catch (_) {}
}, UDP_IDLE_TIMEOUT);
});
}
// Keep stage as REQUEST to allow multiple requests? SOCKS typically one per TCP session.
// We keep control connection open until client closes.
stage = 'ASSOCIATED';
} else if (cmd === 0x02 /* BIND */) {
// Not supported
try { localSocket.write(buildSocksReply(0x07 /* Command not supported */, '0.0.0.0', 0)); } catch (_) {}
localSocket.end();
return;
} else {
// Unknown
try { localSocket.write(buildSocksReply(0x07, '0.0.0.0', 0)); } catch (_) {}
localSocket.end();
return;
}
}
}
localSocket.on('data', onData);
localSocket.on('close', () => {
// Inform remote
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId);
connections.delete(connectionId);
clearTimeout(state.openTimer);
// For TCP CONNECT stream
if (tcpConnId !== null) {
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, tcpConnId);
connections.delete(tcpConnId);
}
// For UDP associate
if (udpAssocId !== null) {
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
closeUdpAssoc(udpAssocId);
}
});
localSocket.on('error', () => {
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, connectionId);
connections.delete(connectionId);
clearTimeout(state.openTimer);
if (tcpConnId !== null) {
writeMessage(tunnelSocket, MSG_TYPES.CLOSE, tcpConnId);
connections.delete(tcpConnId);
}
if (udpAssocId !== null) {
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, udpAssocId);
closeUdpAssoc(udpAssocId);
}
});
}
// Start local HTTP proxy server
const proxyServer = net.createServer((socket) => {
handleLocalConnection(socket);
function closeUdpAssoc(id) {
const as = udpAssocs.get(id);
if (!as) return;
try { as.udpSocket && as.udpSocket.close(); } catch (_) {}
udpAssocs.delete(id);
}
/* ============== Server bootstrap ============== */
// Start local SOCKS5 server
const socksServer = net.createServer((socket) => {
handleSocksConnection(socket);
});
proxyServer.on('error', (err) => {
console.error('Local proxy server error:', err.message);
socksServer.on('error', (err) => {
console.error('Local SOCKS5 server error:', err.message);
process.exit(1);
});
proxyServer.listen(parseInt(options.proxyPort, 10), options.bindHost, () => {
console.log(`Local HTTP proxy listening on ${options.bindHost}:${options.proxyPort}`);
socksServer.listen(parseInt(options.socksPort, 10), options.bindHost, () => {
console.log(`Local SOCKS5 proxy listening on ${options.bindHost}:${options.socksPort}`);
});
// Connect tunnel and maintain it
@@ -493,10 +732,14 @@ connectTunnel();
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down...');
try { proxyServer.close(); } catch (_) {}
try { if (tunnelSocket) tunnelSocket.destroy(); } catch (_) {}
try { socksServer.close(); } catch (_) {}
try { tunnelSocket && tunnelSocket.destroy(); } catch (_) {}
for (const [id] of connections) {
destroyLocal(id);
destroyLocalTCP(id);
}
for (const [id] of udpAssocs) {
writeMessage(tunnelSocket, MSG_TYPES.UDP_CLOSE, id);
closeUdpAssoc(id);
}
process.exit(0);
});