Skill Tracer
Skills › gstack › browse › socks-bridge.ts

socks-bridge.ts

314 lines · browse/src/socks-bridge.ts at 3f79d81

1/**
2 * Local SOCKS5 bridge — accepts unauthenticated connections on 127.0.0.1:<ephemeral>
3 * and relays them through an authenticated upstream SOCKS5 proxy.
4 *
5 * Why this exists: Chromium does not prompt for SOCKS5 auth at launch. To use
6 * an auth-required upstream (residential SOCKS5 from a VPN provider, for
7 * example), we run a no-auth listener locally that the browser talks to, and
8 * the bridge handles the auth handshake with upstream.
9 *
10 * Architecture:
11 * Chromium → socks5://127.0.0.1:<ephemeral> (this bridge, no auth)
12 * └→ authenticated SOCKS5 to upstream → destination
13 *
14 * Ported from wintermute's scripts/socks-bridge.mjs with TS types, ephemeral
15 * port (no hardcoded 1090), 127.0.0.1-only bind, and a stream-error policy
16 * that closes the affected client connection without transport retries (a
17 * SOCKS bridge is transport, not request-aware — retries can corrupt browser
18 * traffic mid-stream).
19 */
21import * as net from 'net';
22import { SocksClient, type SocksProxy } from 'socks';
24export interface UpstreamConfig {
25 host: string;
26 port: number;
27 userId?: string;
28 password?: string;
31export interface BridgeHandle {
32 /** Local port the bridge is listening on (ephemeral). */
33 port: number;
34 /** Underlying server. Exposed for tests; production code uses close(). */
35 server: net.Server;
36 /** Close the listener and all in-flight client sockets. */
37 close: () => Promise<void>;
40const SOCKS5_VERSION = 0x05;
41const NO_AUTH_METHOD = 0x00;
42const CMD_CONNECT = 0x01;
43const ATYP_IPV4 = 0x01;
44const ATYP_DOMAINNAME = 0x03;
45const ATYP_IPV6 = 0x04;
46const REPLY_SUCCESS = 0x00;
47const REPLY_GENERAL_FAILURE = 0x01;
48const REPLY_HOST_UNREACHABLE = 0x04;
49const UPSTREAM_CONNECT_TIMEOUT_MS = 15000;
51function buildUpstream(upstream: UpstreamConfig): SocksProxy {
52 return {
53 host: upstream.host,
54 port: upstream.port,
55 type: 5,
56 ...(upstream.userId ? { userId: upstream.userId } : {}),
57 ...(upstream.password ? { password: upstream.password } : {}),
58 };
61function parseConnectRequest(reqData: Buffer): { host: string; port: number } | null {
62 if (reqData.length < 7 || reqData[0] !== SOCKS5_VERSION || reqData[1] !== CMD_CONNECT) {
63 return null;
64 }
65 const atyp = reqData[3];
66 if (atyp === ATYP_IPV4) {
67 if (reqData.length < 10) return null;
68 const host = `${reqData[4]}.${reqData[5]}.${reqData[6]}.${reqData[7]}`;
69 const port = reqData.readUInt16BE(8);
70 return { host, port };
71 }
72 if (atyp === ATYP_DOMAINNAME) {
73 const len = reqData[4];
74 if (reqData.length < 5 + len + 2) return null;
75 const host = reqData.subarray(5, 5 + len).toString('utf8');
76 const port = reqData.readUInt16BE(5 + len);
77 return { host, port };
78 }
79 if (atyp === ATYP_IPV6) {
80 if (reqData.length < 22) return null;
81 const parts: string[] = [];
82 for (let i = 4; i < 20; i += 2) parts.push(reqData.readUInt16BE(i).toString(16));
83 const host = parts.join(':');
84 const port = reqData.readUInt16BE(20);
85 return { host, port };
86 }
87 return null;
90function writeReply(sock: net.Socket, code: number): void {
91 // SOCKS5 reply: VER REP RSV ATYP BND.ADDR(0.0.0.0) BND.PORT(0)
92 const reply = Buffer.from([SOCKS5_VERSION, code, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
93 try { sock.write(reply); } catch { /* peer already gone */ }
96/**
97 * Start a local SOCKS5 bridge that relays to an authenticated upstream.
98 * Listens on 127.0.0.1 only (never 0.0.0.0). port: 0 picks an ephemeral port.
99 *
100 * Stream-error policy: on any error during a relayed connection, the affected
101 * client socket and its upstream pair are destroyed. No transport retries.
102 * Browser sees a proxy/connection error and surfaces it as such.
103 */
104export async function startSocksBridge(opts: {
105 upstream: UpstreamConfig;
106 port?: number;
107}): Promise<BridgeHandle> {
108 const upstreamProxy = buildUpstream(opts.upstream);
109 const requestedPort = opts.port ?? 0;
110 const inFlight = new Set<net.Socket>();
112 // Frame-size predicates for the two SOCKS5 messages we read from the
113 // client. Both return null when we don't yet have enough bytes to know
114 // the frame size, or a positive integer when we do.
115 function greetingSize(buf: Buffer): number | null {
116 if (buf.length < 2) return null;
117 return 2 + buf[1]; // VER NMETHODS + N method bytes
118 }
119 function connectSize(buf: Buffer): number | null {
120 if (buf.length < 5) return null;
121 const atyp = buf[3];
122 if (atyp === ATYP_IPV4) return 10; // VER CMD RSV ATYP + 4 + 2
123 if (atyp === ATYP_IPV6) return 22; // VER CMD RSV ATYP + 16 + 2
124 if (atyp === ATYP_DOMAINNAME) return 7 + buf[4]; // VER CMD RSV ATYP LEN + N + 2
125 return null;
126 }
128 type State = 'greeting' | 'connect' | 'connecting' | 'piped' | 'closed';
130 const server = net.createServer((clientSocket) => {
131 inFlight.add(clientSocket);
132 clientSocket.once('close', () => inFlight.delete(clientSocket));
134 let state: State = 'greeting';
135 let buf: Buffer = Buffer.alloc(0);
136 let upstreamSocket: net.Socket | null = null;
138 const killBoth = (reason?: string) => {
139 void reason;
140 state = 'closed';
141 try { clientSocket.destroy(); } catch { /* already gone */ }
142 if (upstreamSocket) {
143 try { upstreamSocket.destroy(); } catch { /* already gone */ }
144 }
145 };
147 const handshakeTimeout = setTimeout(() => {
148 if (state === 'greeting' || state === 'connect' || state === 'connecting') {
149 killBoth('handshake timeout');
150 }
151 }, 30000);
152 clientSocket.once('close', () => clearTimeout(handshakeTimeout));
154 const onData = (chunk: Buffer) => {
155 if (state === 'closed' || state === 'piped') return;
156 buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
158 if (state === 'greeting') {
159 const sz = greetingSize(buf);
160 if (sz == null || buf.length < sz) return;
161 const greeting = buf.subarray(0, sz);
162 buf = buf.subarray(sz);
163 if (greeting[0] !== SOCKS5_VERSION) { killBoth('bad version'); return; }
164 try { clientSocket.write(Buffer.from([SOCKS5_VERSION, NO_AUTH_METHOD])); }
165 catch { killBoth('write greeting reply failed'); return; }
166 state = 'connect';
167 // Fall through — buf may already contain CONNECT bytes (coalesced).
168 }
170 if (state === 'connect') {
171 const sz = connectSize(buf);
172 if (sz == null || buf.length < sz) return;
173 const reqData = buf.subarray(0, sz);
174 const remainder = buf.subarray(sz);
175 const dest = parseConnectRequest(reqData);
176 if (!dest) {
177 writeReply(clientSocket, REPLY_GENERAL_FAILURE);
178 killBoth('bad connect request');
179 return;
180 }
181 state = 'connecting';
182 // Pause client reads so any post-handshake bytes don't get dropped.
183 // We replay `remainder` after upstream is established.
184 clientSocket.pause();
185 SocksClient.createConnection({
186 proxy: upstreamProxy,
187 command: 'connect',
188 destination: { host: dest.host, port: dest.port },
189 timeout: UPSTREAM_CONNECT_TIMEOUT_MS,
190 }).then((result) => {
191 if (state === 'closed') {
192 try { result.socket.destroy(); } catch { /* shutdown */ }
193 return;
194 }
195 upstreamSocket = result.socket;
196 writeReply(clientSocket, REPLY_SUCCESS);
197 // Replay any pre-buffered post-handshake bytes BEFORE we pipe.
198 if (remainder.length > 0) {
199 try { upstreamSocket.write(remainder); } catch { killBoth('replay write failed'); return; }
200 }
201 // Wire the rest of the connection through the pipe.
202 upstreamSocket.on('error', () => killBoth('upstream error'));
203 upstreamSocket.on('close', () => { try { clientSocket.destroy(); } catch { /* already gone */ } });
204 clientSocket.removeListener('data', onData);
205 clientSocket.pipe(upstreamSocket);
206 upstreamSocket.pipe(clientSocket);
207 clientSocket.resume();
208 state = 'piped';
209 }).catch(() => {
210 writeReply(clientSocket, REPLY_HOST_UNREACHABLE);
211 killBoth('upstream connect failed');
212 });
213 return;
214 }
215 };
217 clientSocket.on('data', onData);
218 clientSocket.on('error', () => killBoth('client error'));
219 });
221 await new Promise<void>((resolve, reject) => {
222 const onErr = (e: unknown) => { server.off('listening', onListen); reject(e); };
223 const onListen = () => { server.off('error', onErr); resolve(); };
224 server.once('error', onErr);
225 server.once('listening', onListen);
226 server.listen(requestedPort, '127.0.0.1');
227 });
229 const address = server.address();
230 if (!address || typeof address === 'string') {
231 throw new Error('socks-bridge: unexpected listener address');
232 }
234 return {
235 port: address.port,
236 server,
237 close: async () => {
238 for (const sock of inFlight) {
239 try { sock.destroy(); } catch { /* already gone */ }
240 }
241 inFlight.clear();
242 await new Promise<void>((resolve) => server.close(() => resolve()));
243 },
244 };
247export interface UpstreamTestOpts {
248 upstream: UpstreamConfig;
249 /** Hostname to test connectivity to through the upstream. Default 1.1.1.1. */
250 testHost?: string;
251 /** Port. Default 443. */
252 testPort?: number;
253 /** Total time budget across all retries. Default 5000ms. */
254 budgetMs?: number;
255 /** Number of attempts. Default 3. */
256 retries?: number;
257 /** Backoff between attempts. Default 500ms. */
258 backoffMs?: number;
261/**
262 * Pre-flight: verify the upstream proxy actually accepts our credentials and
263 * can reach a known endpoint. Called before chromium.launch so failures
264 * surface as a clear startup error instead of a confusing 'connection
265 * refused' on first navigation.
266 *
267 * Retries a few times with backoff because residential VPNs can take a
268 * second to fully establish on first connect.
269 *
270 * Throws on final failure. Caller is responsible for redacting any error
271 * that may leak credentials.
272 */
273export async function testUpstream(opts: UpstreamTestOpts): Promise<{ ok: true; attempts: number; ms: number }> {
274 const upstreamProxy = buildUpstream(opts.upstream);
275 const testHost = opts.testHost ?? '1.1.1.1';
276 const testPort = opts.testPort ?? 443;
277 const budgetMs = opts.budgetMs ?? 5000;
278 const retries = opts.retries ?? 3;
279 const backoffMs = opts.backoffMs ?? 500;
281 const start = Date.now();
282 let lastErr: unknown;
284 for (let attempt = 1; attempt <= retries; attempt++) {
285 const elapsed = Date.now() - start;
286 const remaining = budgetMs - elapsed;
287 if (remaining <= 0) break;
288 const perAttempt = Math.min(remaining, Math.max(500, Math.floor(budgetMs / retries)));
290 try {
291 const result = await SocksClient.createConnection({
292 proxy: upstreamProxy,
293 command: 'connect',
294 destination: { host: testHost, port: testPort },
295 timeout: perAttempt,
296 });
297 try { result.socket.destroy(); } catch { /* test connection done */ }
298 return { ok: true, attempts: attempt, ms: Date.now() - start };
299 } catch (err) {
300 lastErr = err;
301 if (attempt < retries) {
302 const elapsedAfter = Date.now() - start;
303 if (elapsedAfter + backoffMs >= budgetMs) break;
304 await new Promise<void>((r) => setTimeout(r, backoffMs));
305 }
306 }
307 }
309 const reason = lastErr instanceof Error ? lastErr.message : String(lastErr);
310 const err = new Error(`SOCKS5 upstream rejected or unreachable after ${retries} attempts (${Date.now() - start}ms): ${reason}`);
311 (err as Error & { upstreamHost?: string; upstreamPort?: number }).upstreamHost = opts.upstream.host;
312 (err as Error & { upstreamHost?: string; upstreamPort?: number }).upstreamPort = opts.upstream.port;
313 throw err;