Files
Tim Perry 31946e9999 Set up a native testing harness for low-level network testing
Primarily relevant for SOCKS, but probably more generally in future.
2026-09-08 17:40:47 +02:00

157 lines
5.7 KiB
TypeScript

/**
* A minimal SOCKS5 server that can be told to misbehave in each of the ways a real proxy
* plausibly might, so we can check the connect hook fails cleanly rather than hanging or
* corrupting the connection.
*
* After a successful handshake it echoes everything it receives, which lets a test confirm
* that the tunnel starts exactly where it should: any handshake bytes left unread would
* arrive at the application as if the server had sent them.
*/
import * as net from 'net';
export type SocksMode =
| 'normal'
| 'dribble' // Replies one byte at a time, forcing short reads
| 'silent' // Accepts the connection, then never replies at all
| 'stalls' // Replies to the greeting, then never replies to the request
| 'refuses' // Replies to the request with a failure code
| 'demands-auth' // Requires an auth method we don't offer
| 'names-itself'; // Replies with a hostname (ATYP 3) rather than an address
export interface SocksRequest {
addressType: number;
address: string;
port: number;
}
const [VERSION, NO_AUTH, NO_ACCEPTABLE_AUTH] = [0x05, 0x00, 0xFF];
const [IPV4, HOSTNAME, IPV6] = [0x01, 0x03, 0x04];
export class SocksServer {
/** Every destination a client has asked us to connect to. */
readonly requests: SocksRequest[] = [];
private server: net.Server;
private mode: SocksMode;
private constructor(mode: SocksMode) {
this.mode = mode;
this.server = net.createServer((socket) => this.handle(socket));
}
static async start(mode: SocksMode = 'normal') {
const server = new SocksServer(mode);
await new Promise<void>((resolve) =>
server.server.listen(0, '127.0.0.1', resolve));
return server;
}
get port() {
return (this.server.address() as net.AddressInfo).port;
}
async stop() {
await new Promise((resolve) => this.server.close(resolve));
}
private write(socket: net.Socket, data: Buffer) {
if (this.mode !== 'dribble') return socket.write(data);
// One byte at a time, with a gap, so that a reader that assumes it gets everything
// it asked for in one go will see only part of the message:
let sent = 0;
const timer = setInterval(() => {
if (sent >= data.length || socket.destroyed) return clearInterval(timer);
socket.write(data.subarray(sent, sent + 1));
sent += 1;
}, 5);
}
private handle(socket: net.Socket) {
let stage: 'greeting' | 'request' | 'tunnelling' = 'greeting';
let buffer = Buffer.alloc(0);
socket.on('error', () => {}); // Clients hang up mid-handshake constantly here
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (stage === 'greeting') {
// VER, NMETHODS, then that many method bytes:
if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return;
if (buffer[0] !== VERSION) return socket.destroy();
buffer = buffer.subarray(2 + buffer[1]);
stage = 'request';
if (this.mode === 'silent') return;
if (this.mode === 'demands-auth') {
return this.write(socket, Buffer.from([VERSION, NO_ACCEPTABLE_AUTH]));
}
this.write(socket, Buffer.from([VERSION, NO_AUTH]));
return;
}
if (stage === 'request') {
const request = this.parseRequest(buffer);
if (!request) return; // Not all here yet
this.requests.push(request);
buffer = Buffer.alloc(0);
stage = 'tunnelling';
if (this.mode === 'stalls') return;
if (this.mode === 'refuses') {
return this.write(socket, this.reply(0x05)); // Connection refused
}
this.write(socket, this.reply(0x00));
return;
}
socket.write(buffer); // Echo, so the tunnel's first bytes can be checked
buffer = Buffer.alloc(0);
});
}
private parseRequest(buffer: Buffer): SocksRequest | undefined {
if (buffer.length < 4) return;
const addressType = buffer[3];
const addressLength =
addressType === IPV4 ? 4 :
addressType === IPV6 ? 16 :
addressType === HOSTNAME ? (buffer.length >= 5 ? buffer[4] : undefined) :
undefined;
if (addressLength === undefined) return;
const addressStart = addressType === HOSTNAME ? 5 : 4;
if (buffer.length < addressStart + addressLength + 2) return;
const addressBytes = buffer.subarray(addressStart, addressStart + addressLength);
const address =
addressType === IPV4 ? [...addressBytes].join('.') :
addressType === IPV6 ? [...addressBytes].map((b) => b.toString(16).padStart(2, '0')).join('') :
addressBytes.toString();
return {
addressType,
address,
port: buffer.readUInt16BE(addressStart + addressLength)
};
}
private reply(status: number) {
if (this.mode === 'names-itself') {
const name = Buffer.from('proxy.example');
return Buffer.concat([
Buffer.from([VERSION, status, 0x00, HOSTNAME, name.length]),
name,
Buffer.from([0x00, 0x00])
]);
}
// A blank IPv4 bound address, which is what a proxy that never makes an outgoing
// connection of its own has to report:
return Buffer.from([VERSION, status, 0x00, IPV4, 0, 0, 0, 0, 0, 0]);
}
}