1// @effect-diagnostics nodeBuiltinImport:off - Effect has no SOCKS proxy or address block list.
2import * as NodeDnsPromises from "node:dns/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5
6import * as Effect from "effect/Effect";
7
8/** This machine and its local networks, which HTML previews must never reach. */
9const LOCAL_ADDRESSES = new NodeNet.BlockList();
10for (const [network, prefix] of [
11 ["0.0.0.0", 8],
12 ["10.0.0.0", 8],
13 ["100.64.0.0", 10],
14 ["127.0.0.0", 8],
15 ["169.254.0.0", 16],
16 ["172.16.0.0", 12],
17 ["192.168.0.0", 16],
18 ["198.18.0.0", 15],
19 ["224.0.0.0", 3],
20] as const) {
21 LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4");
22}
23// Also the IPv6 ranges that carry an IPv4 address a translator may route to
24// without checking it: IPv4-compatible, local-use NAT64, and Teredo.
25for (const [network, prefix] of [
26 ["::", 96],
27 ["64:ff9b:1::", 48],
28 ["2001::", 32],
29 ["fc00::", 7],
30 ["fe80::", 10],
31 ["ff00::", 8],
32] as const) {
33 LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv6");
34}
35
36const NAT64 = new NodeNet.BlockList();
37NAT64.addSubnet("64:ff9b::", 96, "ipv6");
38const SIX_TO_FOUR = new NodeNet.BlockList();
39SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6");
40
41/** The sixteen-bit groups of an IPv6 address, which may end in dotted IPv4. */
42const ipv6Groups = (address: string) => {
43 const bare = address.split("%", 1)[0]!;
44 const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(bare);
45 const text = dotted
46 ? `${bare.slice(0, dotted.index)}${((+dotted[1]! << 8) | +dotted[2]!).toString(16)}:${((+dotted[3]! << 8) | +dotted[4]!).toString(16)}`
47 : bare;
48 const [head = "", tail] = text.split("::");
49 const left = head ? head.split(":") : [];
50 const right = tail ? tail.split(":") : [];
51 const fill = tail === undefined ? 0 : 8 - left.length - right.length;
52 return [...left, ...Array<string>(fill).fill("0"), ...right].map((group) =>
53 Number.parseInt(group, 16),
54 );
55};
56
57/**
58 * The IPv4 address a NAT64 or 6to4 address stands for, which a translator
59 * will route to, so it is checked as well. Public NAT64 targets stay
60 * reachable, as on IPv6-only networks.
61 */
62const embeddedIPv4 = (address: string) => {
63 const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1;
64 if (at === -1) return undefined;
65 const groups = ipv6Groups(address);
66 const high = groups[at] ?? 0;
67 const low = groups[at + 1] ?? 0;
68 return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
69};
70
71/** The addresses this machine's interfaces hold right now. */
72const ownAddresses = () => {
73 const own = new NodeNet.BlockList();
74 for (const entry of Object.values(NodeOS.networkInterfaces()).flat()) {
75 if (entry) own.addAddress(entry.address, entry.family === "IPv6" ? "ipv6" : "ipv4");
76 }
77 return own;
78};
79
80/**
81 * Whether `address` belongs to a local network or to this machine itself,
82 * including a public address one of its interfaces holds. Both lists match
83 * IPv4-mapped IPv6 against their IPv4 entries.
84 */
85const isLocal = (address: string, family: number): boolean => {
86 const type = family === 6 ? "ipv6" : "ipv4";
87 if (LOCAL_ADDRESSES.check(address, type) || ownAddresses().check(address, type)) return true;
88 const embedded = family === 6 ? embeddedIPv4(address) : undefined;
89 return embedded !== undefined && isLocal(embedded, 4);
90};
91
92/**
93 * The addresses to connect to for `host`, or undefined when any address it
94 * resolves to is local. The caller connects only to addresses checked here,
95 * so a name that later resolves elsewhere (DNS rebinding) changes nothing.
96 */
97const publicAddresses = async (host: string) => {
98 const addresses = NodeNet.isIP(host)
99 ? [{ address: host, family: NodeNet.isIP(host) }]
100 : await NodeDnsPromises.lookup(host, { all: true, verbatim: true }).catch(() => []);
101 if (addresses.length === 0) return undefined;
102 const local = addresses.some(({ address, family }) => isLocal(address, family));
103 return local ? undefined : addresses;
104};
105
106// SOCKS5 (RFC 1928) replies: success, refused by rule, host unreachable, command unsupported.
107const reply = (code: number) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]);
108
109// What a client may send before its tunnel opens; a TLS hello fits easily.
110const MAX_EARLY_BYTES = 64 * 1024;
111
112/**
113 * Answers and closes. The client is never read from again, so anything it
114 * sent after its request is dropped rather than left buffered.
115 */
116const refuse = (client: NodeNet.Socket, code: number) => {
117 client.end(reply(code), () => client.destroy());
118};
119
120/** The CONNECT target in a complete SOCKS5 request, or "short" when more bytes are needed. */
121const readRequest = (data: Buffer) => {
122 if (data.length < 5) return "short" as const;
123 // Version 5, reserved byte 0.
124 if (data[0] !== 5 || data[2] !== 0) return undefined;
125 const type = data[3];
126 const end = type === 1 ? 10 : type === 3 ? 7 + data[4]! : type === 4 ? 22 : -1;
127 if (end === -1) return undefined;
128 if (data.length < end) return "short" as const;
129 const host =
130 type === 1
131 ? [...data.subarray(4, 8)].join(".")
132 : type === 3
133 ? data.subarray(5, 5 + data[4]!).toString("latin1")
134 : Array.from({ length: 8 }, (_, index) =>
135 data.readUInt16BE(4 + index * 2).toString(16),
136 ).join(":");
137 return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) };
138};
139
140/**
141 * Starts a SOCKS5 proxy on loopback for the life of the scope and returns its
142 * port. The preview browser sends every connection through it, and it only
143 * connects to public addresses. Chrome's Local Network Access misses some
144 * requests, such as speculation-rules prefetches; nothing misses this. It
145 * carries bytes only, so HTTP, TLS, and WebSockets pass through unchanged.
146 */
147export const publicProxy = Effect.acquireRelease(
148 Effect.callback<
149 { readonly server: NodeNet.Server; readonly sockets: Set<NodeNet.Socket> },
150 Error
151 >((resume) => {
152 const sockets = new Set<NodeNet.Socket>();
153 const track = (socket: NodeNet.Socket) => {
154 sockets.add(socket);
155 socket.on("close", () => sockets.delete(socket));
156 socket.on("error", () => socket.destroy());
157 return socket;
158 };
159 const server = NodeNet.createServer((client) => {
160 track(client);
161 let data = Buffer.alloc(0);
162 let greeted = false;
163 const onData = (chunk: Buffer) => {
164 data = Buffer.concat([data, chunk]);
165 if (!greeted) {
166 if (data.length < 2 || data.length < 2 + data[1]!) return;
167 // Version 5, offering "no authentication".
168 if (data[0] !== 5 || !data.subarray(2, 2 + data[1]!).includes(0)) {
169 return void client.end(Buffer.from([5, 0xff]));
170 }
171 data = data.subarray(2 + data[1]!);
172 greeted = true;
173 client.write(Buffer.from([5, 0]));
174 }
175 const request = readRequest(data);
176 if (request === "short") return;
177 client.off("data", onData);
178 if (request === undefined || request.command !== 1 || request.port === 0) {
179 return refuse(client, 7);
180 }
181 // Keeps reading while the target resolves, so a client that leaves is
182 // noticed, and holds what it sends early up to a small cap.
183 const early: Array<Buffer> = [request.rest];
184 let earlyBytes = request.rest.length;
185 const holdEarly = (chunk: Buffer) => {
186 earlyBytes += chunk.length;
187 if (earlyBytes > MAX_EARLY_BYTES) return void client.destroy();
188 early.push(chunk);
189 };
190 client.on("data", holdEarly);
191 void publicAddresses(request.host).then((addresses) => {
192 if (client.destroyed) return;
193 client.off("data", holdEarly);
194 if (addresses === undefined) return refuse(client, 2);
195 client.pause();
196 // Tries every checked address, IPv6 and IPv4 alike, so a host is
197 // still reached on a network whose IPv6 route is broken. A name goes
198 // through `lookup`, which hands back only the checked addresses; an
199 // address literal connects as itself.
200 const upstream = track(
201 NodeNet.connect({
202 host: request.host,
203 port: request.port,
204 autoSelectFamily: true,
205 lookup: (_host, options, callback) =>
206 options.all
207 ? callback(null, addresses)
208 : callback(null, addresses[0]!.address, addresses[0]!.family),
209 }),
210 );
211 upstream.once("connect", () => {
212 client.write(reply(0));
213 for (const chunk of early) upstream.write(chunk);
214 upstream.pipe(client);
215 client.pipe(upstream);
216 });
217 upstream.on("close", () => client.destroy());
218 client.on("close", () => upstream.destroy());
219 });
220 };
221 client.on("data", onData);
222 });
223 // A failure to listen fails this preview; once listening, a server error
224 // must not reach Node as an unhandled event either.
225 let listening = false;
226 server.on("error", (error) => {
227 if (!listening) resume(Effect.fail(error));
228 });
229 server.listen(0, "127.0.0.1", () => {
230 listening = true;
231 resume(Effect.succeed({ server, sockets }));
232 });
233 }),
234 ({ server, sockets }) =>
235 Effect.callback<void>((resume) => {
236 for (const socket of sockets) socket.destroy();
237 server.close(() => resume(Effect.void));
238 }),
239).pipe(
240 Effect.map(({ server }) => {
241 const address = server.address();
242 return typeof address === "object" && address !== null ? address.port : 0;
243 }),
244);