How to test if a proxy support UDP protocol by resolving DNS using NodeJS

Below is a script I made which you can use to test if a proxy supports UDP protocol

It works by trying to resolve a DNS request through the proxy server and if successful returns the response.

Example usage:

node udp_dns_socks5_test.js --proxy-host unmetered.residential.proxyrack.net --proxy-port 9000 --proxy-username YOURUSERNAME --proxy-password YOURAPIKEY

Successful output:

% node udp_dns_socks5_test.js --proxy-host unmetered.residential.proxyrack.net --proxy-port 9000 --proxy-username $PUSER --proxy-password $PPASS

Testing UDP DNS via SOCKS5 proxy unmetered.residential.proxyrack.net:9000 -> DNS 8.8.8.8:53, query A example.com
UDP via SOCKS5 proxy appears to work. Answers:
- 23.220.75.232
- 23.220.75.245
- 23.192.228.80
- 23.192.228.84
- 23.215.0.136
- 23.215.0.138

As you can see it has a list of IPs that example.com resolves to for that particular IP/GEO/request

If unsuccessful it will print an error like this instead:

Testing UDP DNS via SOCKS5 proxy unmetered.residential.proxyrack.net:9000 -> DNS 8.8.8.8:53, query A example.com
UDP via SOCKS5 test failed: TCP timeout connecting to proxy

Script below (nodejs)

Full script below

Save the file as: udp_dns_socks5_test.js

Run with node udp_dns_socks5_test.js

Requirements: Node.js v21.4.0

const net = require('net');
const dgram = require('dgram');

function parseArgs(argv) {
	const args = {};
	for (let i = 2; i < argv.length; i++) {
		const arg = argv[i];
		if (arg.startsWith('--')) {
			const key = arg.slice(2);
			const next = argv[i + 1];
			if (next && !next.startsWith('--')) {
				args[key] = next;
				i++;
			} else {
				args[key] = 'true';
			}
		}
	}
	return args;
}

function isIPv4(address) {
	return /^\d+\.\d+\.\d+\.\d+$/.test(address);
}

function isIPv6(address) {
	// Basic detection; Node will validate when sending
	return address.includes(':');
}

function buildDnsQuery(hostname, recordType) {
	const id = Math.floor(Math.random() * 0xffff);
	const flags = 0x0100; // standard query, recursion desired
	const qdcount = 1;
	const ancount = 0;
	const nscount = 0;
	const arcount = 0;

	const parts = hostname.split('.');
	const labels = parts.map((p) => {
		const buf = Buffer.alloc(1 + Buffer.byteLength(p));
		buf.writeUInt8(Buffer.byteLength(p), 0);
		buf.write(p, 1, 'ascii');
		return buf;
	});
	const qname = Buffer.concat([...labels, Buffer.from([0x00])]);

	const qtypeMap = { A: 1, AAAA: 28, NS: 2, CNAME: 5, MX: 15, TXT: 16 }; // minimal set
	const qtype = qtypeMap[recordType.toUpperCase()] || 1;
	const qclass = 1; // IN

	const header = Buffer.alloc(12);
	header.writeUInt16BE(id, 0);
	header.writeUInt16BE(flags, 2);
	header.writeUInt16BE(qdcount, 4);
	header.writeUInt16BE(ancount, 6);
	header.writeUInt16BE(nscount, 8);
	header.writeUInt16BE(arcount, 10);

	const question = Buffer.alloc(4);
	question.writeUInt16BE(qtype, 0);
	question.writeUInt16BE(qclass, 2);

	return { id, packet: Buffer.concat([header, qname, question]) };
}

function parseDnsName(buffer, offset) {
    const start = offset;
    let labels = [];
    let consumed = 0; // bytes consumed at the original location
    let jumped = false;
    let safety = 0;
    while (true) {
        if (offset >= buffer.length) break;
        const len = buffer.readUInt8(offset);
        // end of name
        if (len === 0) {
            if (!jumped) consumed += 1; // consume the zero only if we didn't jump
            break;
        }
        // pointer (compression)
        if ((len & 0xc0) === 0xc0) {
            if (offset + 1 >= buffer.length) break;
            const pointer = ((len & 0x3f) << 8) | buffer.readUInt8(offset + 1);
            if (!jumped) consumed += 2; // the pointer itself is two bytes in the original stream
            offset = pointer; // jump to the pointed name location
            jumped = true;
        } else {
            const labelLen = len;
            if (offset + 1 + labelLen > buffer.length) break;
            if (!jumped) consumed += 1 + labelLen;
            const label = buffer.slice(offset + 1, offset + 1 + labelLen).toString('ascii');
            labels.push(label);
            offset += 1 + labelLen;
        }
        if (++safety > 255) break; // guard against malformed loops
    }
    return { name: labels.filter(Boolean).join('.'), length: consumed };
}

function parseDnsResponseAnswers(buffer) {
    if (buffer.length < 12) return [];
    const qdcount = buffer.readUInt16BE(4);
    const ancount = buffer.readUInt16BE(6);
    let offset = 12;

    // skip questions
    for (let i = 0; i < qdcount; i++) {
        const qnameInfo = parseDnsName(buffer, offset);
        if (qnameInfo.length <= 0) return [];
        offset += qnameInfo.length;
        if (offset + 4 > buffer.length) return [];
        // QTYPE + QCLASS
        offset += 4;
    }

    const answers = [];
    for (let i = 0; i < ancount; i++) {
        const nameInfo = parseDnsName(buffer, offset);
        if (nameInfo.length <= 0) break;
        offset += nameInfo.length;
        if (offset + 10 > buffer.length) break; // TYPE(2)+CLASS(2)+TTL(4)+RDLENGTH(2)
        const type = buffer.readUInt16BE(offset); offset += 2;
        const cls = buffer.readUInt16BE(offset); offset += 2; // eslint-disable-line no-unused-vars
        const ttl = buffer.readUInt32BE(offset); offset += 4; // eslint-disable-line no-unused-vars
        const rdlength = buffer.readUInt16BE(offset); offset += 2;
        if (offset + rdlength > buffer.length) break;
        const rdataStart = offset;
        const rdata = buffer.slice(offset, offset + rdlength);
        offset += rdlength;

        if (type === 1 && rdlength === 4) {
            answers.push(`${rdata[0]}.${rdata[1]}.${rdata[2]}.${rdata[3]}`);
        } else if (type === 28 && rdlength === 16) {
            const parts = [];
            for (let j = 0; j < 16; j += 2) parts.push(rdata.readUInt16BE(j).toString(16));
            // Keep minimal formatting; not full RFC5952 compression
            answers.push(parts.join(':'));
        } else if (type === 5) {
            // CNAME is a domain name possibly compressed; parse from the original buffer at rdataStart
            const cnameInfo = parseDnsName(buffer, rdataStart);
            answers.push(`CNAME:${cnameInfo.name}`);
        }
    }
    return answers;
}

function buildSocks5UdpHeader(targetHost, targetPort) {
	const rsvFrag = Buffer.from([0x00, 0x00, 0x00]);
	let atypBuf;
	if (isIPv4(targetHost)) {
		const parts = targetHost.split('.').map((x) => parseInt(x, 10));
		atypBuf = Buffer.from([0x01, ...parts]);
	} else if (isIPv6(targetHost)) {
		const addr = targetHost;
		// Normalize via Node's URL class fallback: require user to provide full IPv6
		// Build 16-byte buffer from IPv6 string
		const b = Buffer.alloc(16);
		// Use Node to parse IPv6 by creating a UDP6 socket and using lookup? Keep simple:
		// Accept only full hextet form for simplicity here
		const expanded = addr.split(':');
		if (expanded.includes('')) {
			// Expand :: shorthand
			const missing = 8 - (expanded.filter((s) => s !== '').length);
			const idx = expanded.indexOf('');
			expanded.splice(idx, 1, ...Array(missing + 1).fill('0'));
		}
		for (let i = 0; i < 8; i++) {
			const v = parseInt(expanded[i] || '0', 16) & 0xffff;
			b.writeUInt16BE(v, i * 2);
		}
		atypBuf = Buffer.concat([Buffer.from([0x04]), b]);
	} else {
		const hostBuf = Buffer.from(targetHost, 'ascii');
		atypBuf = Buffer.concat([Buffer.from([0x03, hostBuf.length]), hostBuf]);
	}
	const portBuf = Buffer.alloc(2);
	portBuf.writeUInt16BE(targetPort, 0);
	return Buffer.concat([rsvFrag, atypBuf, portBuf]);
}

function parseSocks5Addr(buffer, offset) {
	const atyp = buffer.readUInt8(offset);
	if (atyp === 0x01) {
		const host = `${buffer.readUInt8(offset + 1)}.${buffer.readUInt8(offset + 2)}.${buffer.readUInt8(offset + 3)}.${buffer.readUInt8(offset + 4)}`;
		const port = buffer.readUInt16BE(offset + 5);
		return { host, port, length: 1 + 4 + 2 };
	}
	if (atyp === 0x03) {
		const len = buffer.readUInt8(offset + 1);
		const host = buffer.slice(offset + 2, offset + 2 + len).toString('ascii');
		const port = buffer.readUInt16BE(offset + 2 + len);
		return { host, port, length: 1 + 1 + len + 2 };
	}
	if (atyp === 0x04) {
		const parts = [];
		for (let i = 0; i < 16; i += 2) parts.push(buffer.readUInt16BE(offset + 1 + i).toString(16));
		const host = parts.join(':');
		const port = buffer.readUInt16BE(offset + 1 + 16);
		return { host, port, length: 1 + 16 + 2 };
	}
	throw new Error('Unknown ATYP in SOCKS5');
}

function socks5UdpAssociate({ proxyHost, proxyPort, username, password }) {
	return new Promise((resolve, reject) => {
		const socket = new net.Socket();
		socket.setTimeout(10000);
		socket.once('timeout', () => reject(new Error('TCP timeout connecting to proxy')));
		socket.once('error', reject);
		socket.connect(proxyPort, proxyHost, () => {
			const methods = [];
			if (username || password) methods.push(0x02);
			methods.push(0x00);
			const hello = Buffer.from([0x05, methods.length, ...methods]);
			socket.write(hello);
		});

		let stage = 'greeting';
		let buffer = Buffer.alloc(0);
		socket.on('data', (chunk) => {
			buffer = Buffer.concat([buffer, chunk]);
			try {
				if (stage === 'greeting' && buffer.length >= 2) {
					const method = buffer.readUInt8(1);
					buffer = Buffer.alloc(0);
					if (method === 0xff) return reject(new Error('Proxy does not accept provided auth methods'));
					if (method === 0x02) {
						stage = 'auth';
						const user = Buffer.from(username || '', 'utf8');
						const pass = Buffer.from(password || '', 'utf8');
						const authReq = Buffer.concat([
							Buffer.from([0x01, user.length]),
							user,
							Buffer.from([pass.length]),
							pass,
						]);
						socket.write(authReq);
					} else if (method === 0x00) {
						stage = 'request';
						const req = Buffer.from([0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
						socket.write(req);
					}
				} else if (stage === 'auth' && buffer.length >= 2) {
					const status = buffer.readUInt8(1);
					buffer = Buffer.alloc(0);
					if (status !== 0x00) return reject(new Error('Username/password authentication failed'));
					stage = 'request';
					const req = Buffer.from([0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
					socket.write(req);
				} else if (stage === 'request' && buffer.length >= 5) {
					if (buffer.readUInt8(1) !== 0x00) {
						return reject(new Error(`UDP associate failed with code ${buffer.readUInt8(1)}`));
					}
					const addrInfo = parseSocks5Addr(buffer, 3);
					const udpRelayHost = addrInfo.host;
					const udpRelayPort = addrInfo.port;
					// Keep TCP socket open while using UDP; caller will close
					return resolve({ tcpSocket: socket, udpRelayHost, udpRelayPort });
				}
			} catch (err) {
				reject(err);
			}
		});
	});
}

async function testUdpDnsViaSocks5(opts) {
	const {
		proxyHost,
		proxyPort,
		username,
		password,
		dnsServer,
		dnsPort,
		queryName,
		recordType,
		timeoutMs,
	} = opts;

	const { id, packet } = buildDnsQuery(queryName, recordType);
	const { tcpSocket, udpRelayHost, udpRelayPort } = await socks5UdpAssociate({ proxyHost, proxyPort, username, password });

	const udpSocket = dgram.createSocket(isIPv6(udpRelayHost) ? 'udp6' : 'udp4');

	const header = buildSocks5UdpHeader(dnsServer, dnsPort);
	const message = Buffer.concat([header, packet]);

	const responsePromise = new Promise((resolve, reject) => {
		const timer = setTimeout(() => {
			udpSocket.close();
			try { tcpSocket.end(); } catch (e) {}
			reject(new Error('Timed out waiting for DNS response through proxy'));
		}, timeoutMs);

		udpSocket.on('error', (err) => {
			clearTimeout(timer);
			try { udpSocket.close(); } catch (e) {}
			try { tcpSocket.end(); } catch (e) {}
			reject(err);
		});

		udpSocket.on('message', (buf) => {
			// SOCKS5 UDP header: 2 RSV, 1 FRAG, ATYP + DST.ADDR + DST.PORT
			if (buf.length < 10) return; // too short
			if (buf.readUInt16BE(0) !== 0x0000) return;
			if (buf.readUInt8(2) !== 0x00) return; // fragments not supported
			const addrInfo = parseSocks5Addr(buf, 3);
			const payloadOffset = 3 + addrInfo.length;
			const dns = buf.slice(payloadOffset);
			if (dns.length < 12) return;
			const respId = dns.readUInt16BE(0);
			const flags = dns.readUInt16BE(2);
			const rcode = flags & 0x000f;
			if (respId !== id) return;
			clearTimeout(timer);
			udpSocket.close();
			try { tcpSocket.end(); } catch (e) {}
			if (rcode !== 0) {
				return reject(new Error(`DNS error code ${rcode} returned through proxy`));
			}
			const answers = parseDnsResponseAnswers(dns);
			resolve({ answers, raw: dns });
		});

		udpSocket.send(message, udpRelayPort, udpRelayHost);
	});

	return responsePromise;
}

async function main() {
	const args = parseArgs(process.argv);
	if (!args['proxy-host'] || !args['proxy-port']) {
		console.error('Usage: node udp_dns_socks5_test.js --proxy-host <host> --proxy-port <port> [--proxy-username <user> --proxy-password <pass>] [--dns <server>] [--dns-port <port>] [--name <domain>] [--type <A|AAAA>] [--timeout <ms>]');
		process.exit(1);
	}

	const proxyHost = args['proxy-host'];
	const proxyPort = parseInt(args['proxy-port'], 10);
	const username = args['proxy-username'] || '';
	const password = args['proxy-password'] || '';
	const dnsServer = args['dns'] || '8.8.8.8';
	const dnsPort = parseInt(args['dns-port'] || '53', 10);
	const queryName = args['name'] || 'example.com';
	const recordType = (args['type'] || 'A').toUpperCase();
	const timeoutMs = parseInt(args['timeout'] || '5000', 10);

	try {
		console.log(`Testing UDP DNS via SOCKS5 proxy ${proxyHost}:${proxyPort} -> DNS ${dnsServer}:${dnsPort}, query ${recordType} ${queryName}`);
		const result = await testUdpDnsViaSocks5({
			proxyHost,
			proxyPort,
			username,
			password,
			dnsServer,
			dnsPort,
			queryName,
			recordType,
			timeoutMs,
		});
		if (result.answers.length === 0) {
			console.log('Received DNS response but no answers in the answer section.');
		} else {
			console.log('UDP via SOCKS5 proxy appears to work. Answers:');
			for (const a of result.answers) console.log(`- ${a}`);
		}
		process.exit(0);
	} catch (err) {
		console.error('UDP via SOCKS5 test failed:', err.message);
		process.exit(2);
	}
}

if (require.main === module) {
	main();
}