/**
 * Node.js SOCKS5 proxy server.
 *
 * Implements RFC 1928 (SOCKS5) with no-auth method and CONNECT command only.
 * Domain filtering is delegated to a callback.
 *
 * Covers: SSH, git-over-SSH, database connections, gRPC, and any
 * SOCKS-aware tool that respects ALL_PROXY.
 */
import * as net from 'net';
// SOCKS5 constants (RFC 1928)
const SOCKS_VERSION = 0x05;
const AUTH_NO_AUTH = 0x00;
const AUTH_NO_ACCEPTABLE = 0xff;
const CMD_CONNECT = 0x01;
const ATYP_IPV4 = 0x01;
const ATYP_DOMAIN = 0x03;
const ATYP_IPV6 = 0x04;
const REP_SUCCESS = 0x00;
const REP_GENERAL_FAILURE = 0x01;
const REP_CONNECTION_NOT_ALLOWED = 0x02;
const REP_HOST_UNREACHABLE = 0x04;
const REP_COMMAND_NOT_SUPPORTED = 0x07;
const REP_ADDRESS_TYPE_NOT_SUPPORTED = 0x08;
/** Max bytes buffered during handshake before we drop the connection. */
const MAX_HANDSHAKE_BUFFER = 4096;
/**
 * Start a SOCKS5 proxy on a random localhost port.
 *
 * Supports CONNECT command with IPv4, IPv6, and domain name addressing.
 * Only no-auth method is offered.
 */
export function startSocksProxy(options) {
    return new Promise((resolve, reject) => {
        const sockets = new Set();
        const server = net.createServer((client) => {
            sockets.add(client);
            client.on('close', () => sockets.delete(client));
            handleSocksClient(client, options.checkDomain);
        });
        server.on('error', reject);
        // Bind to all interfaces — the socat bridge in WSL connects via the host IP
        // (172.29.x.x), not 127.0.0.1. Windows Firewall blocks external access.
        server.listen(0, '0.0.0.0', () => {
            const addr = server.address();
            if (!addr || typeof addr === 'string') {
                server.close();
                reject(new Error('Failed to get server address'));
                return;
            }
            resolve({
                port: addr.port,
                stop: () => {
                    for (const socket of sockets)
                        socket.destroy();
                    server.close();
                }
            });
        });
    });
}
// ---------------------------------------------------------------------------
// Buffered reader — maintains a persistent data listener on the socket
// ---------------------------------------------------------------------------
/**
 * Buffered reader over a raw TCP socket for the SOCKS5 handshake.
 *
 * Accumulates incoming data and delivers exact byte counts via read().
 * After the handshake, detach() returns the underlying socket for piping.
 */
class SocketReader {
    socket;
    buffer = Buffer.alloc(0);
    waiting = null;
    closed = false;
    // Track our own handlers so detach() only removes ours
    onDataHandler;
    onErrorHandler;
    onCloseHandler;
    constructor(socket) {
        this.socket = socket;
        this.onDataHandler = (chunk) => this.onData(chunk);
        this.onErrorHandler = () => this.onClose();
        this.onCloseHandler = () => this.onClose();
        socket.on('data', this.onDataHandler);
        socket.on('error', this.onErrorHandler);
        socket.on('close', this.onCloseHandler);
    }
    onData(chunk) {
        this.buffer = Buffer.concat([this.buffer, chunk]);
        if (this.buffer.length > MAX_HANDSHAKE_BUFFER && !this.waiting) {
            // Buffer overflow during handshake — abort
            this.closed = true;
            this.socket.destroy();
            return;
        }
        this.tryDeliver();
    }
    onClose() {
        this.closed = true;
        if (this.waiting) {
            this.waiting.reject(new Error('socket closed'));
            this.waiting = null;
        }
    }
    tryDeliver() {
        if (!this.waiting)
            return;
        if (this.buffer.length >= this.waiting.count) {
            const { count, resolve } = this.waiting;
            this.waiting = null;
            const result = this.buffer.subarray(0, count);
            this.buffer = this.buffer.subarray(count);
            resolve(result);
        }
    }
    /** Read exactly `count` bytes. Rejects on timeout or socket close. */
    read(count, timeoutMs) {
        if (this.buffer.length >= count) {
            const result = this.buffer.subarray(0, count);
            this.buffer = this.buffer.subarray(count);
            return Promise.resolve(result);
        }
        if (this.closed) {
            return Promise.reject(new Error('socket closed'));
        }
        return new Promise((resolve, reject) => {
            this.waiting = { count, resolve, reject };
            const timer = setTimeout(() => {
                if (this.waiting) {
                    this.waiting = null;
                    reject(new Error('read timeout'));
                }
            }, timeoutMs);
            const origResolve = resolve;
            const origReject = reject;
            this.waiting.resolve = (buf) => {
                clearTimeout(timer);
                origResolve(buf);
            };
            this.waiting.reject = (err) => {
                clearTimeout(timer);
                origReject(err);
            };
            this.tryDeliver();
        });
    }
    /** Stop reading and return the underlying socket for piping. */
    detach() {
        this.socket.removeListener('data', this.onDataHandler);
        this.socket.removeListener('error', this.onErrorHandler);
        this.socket.removeListener('close', this.onCloseHandler);
        if (this.buffer.length > 0) {
            this.socket.unshift(this.buffer);
            this.buffer = Buffer.alloc(0);
        }
        return this.socket;
    }
}
// ---------------------------------------------------------------------------
// SOCKS5 connection handler
// ---------------------------------------------------------------------------
async function handleSocksClient(client, checkDomain) {
    const reader = new SocketReader(client);
    try {
        // Phase 1: Method negotiation
        const greeting = await reader.read(2, 5000);
        if (greeting[0] !== SOCKS_VERSION) {
            client.destroy();
            return;
        }
        const nmethods = greeting[1];
        const methods = await reader.read(nmethods, 3000);
        if (!methods.includes(AUTH_NO_AUTH)) {
            client.write(Buffer.from([SOCKS_VERSION, AUTH_NO_ACCEPTABLE]));
            client.destroy();
            return;
        }
        client.write(Buffer.from([SOCKS_VERSION, AUTH_NO_AUTH]));
        // Phase 2: CONNECT request
        const header = await reader.read(4, 5000);
        if (header[0] !== SOCKS_VERSION) {
            client.destroy();
            return;
        }
        const cmd = header[1];
        const atyp = header[3];
        if (cmd !== CMD_CONNECT) {
            sendReply(client, REP_COMMAND_NOT_SUPPORTED);
            client.destroy();
            return;
        }
        // Parse address
        let host;
        if (atyp === ATYP_IPV4) {
            const addr = await reader.read(4, 3000);
            host = `${addr[0]}.${addr[1]}.${addr[2]}.${addr[3]}`;
        }
        else if (atyp === ATYP_DOMAIN) {
            const lenBuf = await reader.read(1, 3000);
            const domainLen = lenBuf[0];
            const domainBuf = await reader.read(domainLen, 3000);
            host = domainBuf.toString('utf-8');
        }
        else if (atyp === ATYP_IPV6) {
            const addr = await reader.read(16, 3000);
            const parts = [];
            for (let i = 0; i < 16; i += 2) {
                parts.push(((addr[i] << 8) | addr[i + 1]).toString(16));
            }
            host = parts.join(':');
        }
        else {
            sendReply(client, REP_ADDRESS_TYPE_NOT_SUPPORTED);
            client.destroy();
            return;
        }
        const portBuf = await reader.read(2, 3000);
        const port = (portBuf[0] << 8) | portBuf[1];
        const allowed = await checkDomain(host);
        if (!allowed) {
            sendReply(client, REP_CONNECTION_NOT_ALLOWED);
            client.destroy();
            return;
        }
        // Detach the reader so we can pipe the raw socket
        const rawClient = reader.detach();
        // Track whether we've sent the SOCKS reply (exactly one allowed)
        let replied = false;
        const target = net.createConnection({ host, port }, () => {
            replied = true;
            sendReply(rawClient, REP_SUCCESS);
            rawClient.pipe(target);
            target.pipe(rawClient);
        });
        target.on('error', () => {
            if (!replied) {
                replied = true;
                sendReply(rawClient, REP_HOST_UNREACHABLE);
            }
            rawClient.destroy();
        });
        rawClient.on('error', () => target.destroy());
        rawClient.on('close', () => target.destroy());
        target.on('close', () => rawClient.destroy());
        target.setTimeout(10000, () => {
            if (!replied) {
                replied = true;
                sendReply(rawClient, REP_GENERAL_FAILURE);
            }
            target.destroy();
            rawClient.destroy();
        });
    }
    catch {
        client.destroy();
    }
}
// ---------------------------------------------------------------------------
// SOCKS5 reply
// ---------------------------------------------------------------------------
function sendReply(client, rep) {
    const reply = Buffer.from([
        SOCKS_VERSION,
        rep,
        0x00,
        ATYP_IPV4,
        0,
        0,
        0,
        0, // BND.ADDR
        0,
        0 // BND.PORT
    ]);
    try {
        client.write(reply);
    }
    catch {
        // Client already gone
    }
}
//# sourceMappingURL=socks-proxy.js.map