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
8const LOCAL_ADDRESSES = new NodeNet.BlockList();
9for (const [network, prefix] of [
10 ["0.0.0.0", 8],
11 ["10.0.0.0", 8],
12 ["100.64.0.0", 10],
13 ["127.0.0.0", 8],
14 ["169.254.0.0", 16],
15 ["172.16.0.0", 12],
16 ["192.168.0.0", 16],
17 ["198.18.0.0", 15],
18 ["224.0.0.0", 3],
19] as const) {
20 LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4");
21}
22// Block IPv4-carrying IPv6 ranges that translators may route without checking.
23for (const [network, prefix] of [
24 ["::", 96],
25 ["64:ff9b:1::", 48],
26 ["2001::", 32],
27 ["fc00::", 7],
28 ["fe80::", 10],
29 ["ff00::", 8],
30] as const) {
31 LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv6");
32}
33
34// Clash and Mihomo fake-IP DNS answers from here, inside the blocked 198.18.0.0/15.
35const FAKE_IP = new NodeNet.BlockList();
36FAKE_IP.addSubnet("198.18.0.0", 15, "ipv4");
37const MAX_FAKE_IP_HOSTS = 3;
38
39const NAT64 = new NodeNet.BlockList();
40NAT64.addSubnet("64:ff9b::", 96, "ipv6");
41const SIX_TO_FOUR = new NodeNet.BlockList();
42SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6");
43
44const ipv6Groups = (address: string) => {
45 const bare = address.split("%", 1)[0]!;
46 const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(bare);
47 const text = dotted
48 ? `${bare.slice(0, dotted.index)}${((+dotted[1]! << 8) | +dotted[2]!).toString(16)}:${((+dotted[3]! << 8) | +dotted[4]!).toString(16)}`
49 : bare;
50 const [head = "", tail] = text.split("::");
51 const left = head ? head.split(":") : [];
52 const right = tail ? tail.split(":") : [];
53 const fill = tail === undefined ? 0 : 8 - left.length - right.length;
54 return [...left, ...Array<string>(fill).fill("0"), ...right].map((group) =>
55 Number.parseInt(group, 16),
56 );
57};
58
59// Check the translated IPv4 too, while allowing public NAT64 targets on IPv6-only networks.
60const embeddedIPv4 = (address: string) => {
61 const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1;
62 if (at === -1) return undefined;
63 const groups = ipv6Groups(address);
64 const high = groups[at] ?? 0;
65 const low = groups[at + 1] ?? 0;
66 return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
67};
68
69const ownAddresses = () => {
70 const own = new NodeNet.BlockList();
71 for (const entry of Object.values(NodeOS.networkInterfaces()).flat()) {
72 if (entry) own.addAddress(entry.address, entry.family === "IPv6" ? "ipv6" : "ipv4");
73 }
74 return own;
75};
76
77const isLocal = (address: string, family: number): boolean => {
78 const type = family === 6 ? "ipv6" : "ipv4";
79 if (LOCAL_ADDRESSES.check(address, type) || ownAddresses().check(address, type)) return true;
80 const embedded = family === 6 ? embeddedIPv4(address) : undefined;
81 return embedded !== undefined && isLocal(embedded, 4);
82};
83
84// Connect only to checked addresses to prevent DNS rebinding.
85const publicAddresses = async (host: string, fakeIpHosts: Map<string, string>) => {
86 const addresses = NodeNet.isIP(host)
87 ? [{ address: host, family: NodeNet.isIP(host) }]
88 : await NodeDnsPromises.lookup(host, { all: true, verbatim: true }).catch(() => []);
89 if (addresses.length === 0) return undefined;
90 const local = addresses.some(({ address, family }) => isLocal(address, family));
91 if (!local) return addresses;
92 const fake =
93 !NodeNet.isIP(host) &&
94 addresses.every(({ address, family }) => family === 4 && FAKE_IP.check(address, "ipv4"));
95 if (fake && fakeIpHosts.size < MAX_FAKE_IP_HOSTS && !fakeIpHosts.has(host)) {
96 fakeIpHosts.set(host, addresses[0]!.address);
97 }
98 return undefined;
99};
100
101// SOCKS5 (RFC 1928) replies: success, refused by rule, host unreachable, command unsupported.
102const reply = (code: number) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]);
103
104const MAX_EARLY_BYTES = 64 * 1024;
105
106const refuse = (client: NodeNet.Socket, code: number) => {
107 client.end(reply(code), () => client.destroy());
108};
109
110const readRequest = (data: Buffer) => {
111 if (data.length < 5) return "short" as const;
112 if (data[0] !== 5 || data[2] !== 0) return undefined;
113 const type = data[3];
114 const end = type === 1 ? 10 : type === 3 ? 7 + data[4]! : type === 4 ? 22 : -1;
115 if (end === -1) return undefined;
116 if (data.length < end) return "short" as const;
117 const host =
118 type === 1
119 ? [...data.subarray(4, 8)].join(".")
120 : type === 3
121 ? data.subarray(5, 5 + data[4]!).toString("latin1")
122 : Array.from({ length: 8 }, (_, index) =>
123 data.readUInt16BE(4 + index * 2).toString(16),
124 ).join(":");
125 return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) };
126};
127
128/** Covers requests Chrome's Local Network Access misses, including speculation prefetches. */
129export const publicProxy = Effect.acquireRelease(
130 Effect.callback<
131 {
132 readonly server: NodeNet.Server;
133 readonly sockets: Set<NodeNet.Socket>;
134 readonly fakeIpHosts: ReadonlyMap<string, string>;
135 },
136 Error
137 >((resume) => {
138 const sockets = new Set<NodeNet.Socket>();
139 const fakeIpHosts = new Map<string, string>();
140 const track = (socket: NodeNet.Socket) => {
141 sockets.add(socket);
142 socket.on("close", () => sockets.delete(socket));
143 socket.on("error", () => socket.destroy());
144 return socket;
145 };
146 const server = NodeNet.createServer((client) => {
147 track(client);
148 let data = Buffer.alloc(0);
149 let greeted = false;
150 const onData = (chunk: Buffer) => {
151 data = Buffer.concat([data, chunk]);
152 if (!greeted) {
153 if (data.length < 2 || data.length < 2 + data[1]!) return;
154 if (data[0] !== 5 || !data.subarray(2, 2 + data[1]!).includes(0)) {
155 return void client.end(Buffer.from([5, 0xff]));
156 }
157 data = data.subarray(2 + data[1]!);
158 greeted = true;
159 client.write(Buffer.from([5, 0]));
160 }
161 const request = readRequest(data);
162 if (request === "short") return;
163 client.off("data", onData);
164 if (request === undefined || request.command !== 1 || request.port === 0) {
165 return refuse(client, 7);
166 }
167 const early: Array<Buffer> = [request.rest];
168 let earlyBytes = request.rest.length;
169 const holdEarly = (chunk: Buffer) => {
170 earlyBytes += chunk.length;
171 if (earlyBytes > MAX_EARLY_BYTES) return void client.destroy();
172 early.push(chunk);
173 };
174 client.on("data", holdEarly);
175 void publicAddresses(request.host, fakeIpHosts).then((addresses) => {
176 if (client.destroyed) return;
177 client.off("data", holdEarly);
178 if (addresses === undefined) return refuse(client, 2);
179 client.pause();
180 // Try all checked addresses so broken IPv6 routes do not block IPv4 access.
181 const upstream = track(
182 NodeNet.connect({
183 host: request.host,
184 port: request.port,
185 autoSelectFamily: true,
186 lookup: (_host, options, callback) =>
187 options.all
188 ? callback(null, addresses)
189 : callback(null, addresses[0]!.address, addresses[0]!.family),
190 }),
191 );
192 upstream.once("connect", () => {
193 client.write(reply(0));
194 for (const chunk of early) upstream.write(chunk);
195 upstream.pipe(client);
196 client.pipe(upstream);
197 });
198 upstream.on("close", () => client.destroy());
199 client.on("close", () => upstream.destroy());
200 });
201 };
202 client.on("data", onData);
203 });
204 let listening = false;
205 server.on("error", (error) => {
206 if (!listening) resume(Effect.fail(error));
207 });
208 server.listen(0, "127.0.0.1", () => {
209 listening = true;
210 resume(Effect.succeed({ server, sockets, fakeIpHosts }));
211 });
212 }),
213 ({ server, sockets }) =>
214 Effect.callback<void>((resume) => {
215 for (const socket of sockets) socket.destroy();
216 server.close(() => resume(Effect.void));
217 }),
218).pipe(
219 Effect.map(({ server, fakeIpHosts }) => {
220 const address = server.address();
221 const port = typeof address === "object" && address !== null ? address.port : 0;
222 return { port, fakeIpHosts };
223 }),
224);