Skip to content
Source
Sign inSign up

publicProxy.test.ts

183 lines6.5 KB

1// @effect-diagnostics nodeBuiltinImport:off - raw sockets speak SOCKS5 to the proxy.
2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vite-plus/test";
4import * as Effect from "effect/Effect";
5import type * as NodeDns from "node:dns";
6import * as NodeDnsPromises from "node:dns/promises";
7import * as NodeNet from "node:net";
8import * as NodeOS from "node:os";
9​
10import { publicProxy } from "./publicProxy.ts";
11​
12const OWN_PUBLIC_IPV4 = "198.51.100.7";
13vi.mock("node:os", async (importOriginal) => {
14 const actual = await importOriginal<typeof NodeOS>();
15 return {
16 ...actual,
17 networkInterfaces: () => ({
18 ...actual.networkInterfaces(),
19 "t3-test": [
20 {
21 address: OWN_PUBLIC_IPV4,
22 netmask: "255.255.255.0",
23 family: "IPv4",
24 mac: "00:00:00:00:00:00",
25 internal: false,
26 cidr: `${OWN_PUBLIC_IPV4}/24`,
27 },
28 ],
29 }),
30 };
31});
32​
33// Clash and Mihomo answer every name from 198.18.0.0/16 in fake-IP mode.
34const TEST_DNS: Record<string, ReadonlyArray<{ address: string; family: number }>> = {
35 "cdn.fake-ip.test": [{ address: "198.18.2.40", family: 4 }],
36 "mixed.fake-ip.test": [
37 { address: "93.184.215.14", family: 4 },
38 { address: "198.18.2.41", family: 4 },
39 ],
40};
41vi.mock("node:dns/promises", async (importOriginal) => {
42 const actual = await importOriginal<typeof NodeDnsPromises>();
43 return {
44 ...actual,
45 lookup: (host: string, options: NodeDns.LookupOptions) =>
46 TEST_DNS[host] ? Promise.resolve(TEST_DNS[host]) : actual.lookup(host, options),
47 };
48});
49​
50const connectThrough = (proxyPort: number, target: Buffer, version = 5) =>
51 Effect.callback<{ readonly code: number; readonly socket: NodeNet.Socket }>((resume) => {
52 const socket = NodeNet.connect(proxyPort, "127.0.0.1", () => {
53 socket.write(Buffer.from([5, 1, 0]));
54 socket.write(Buffer.concat([Buffer.from([version, 1, 0]), target]));
55 });
56 // The proxy may reset a refused connection.
57 socket.on("error", () => {});
58 let received = Buffer.alloc(0);
59 socket.on("data", (chunk) => {
60 received = Buffer.concat([received, chunk]);
61 if (received.length >= 12) resume(Effect.succeed({ code: received[3]!, socket }));
62 });
63 socket.on("close", () => resume(Effect.succeed({ code: received[3] ?? -1, socket })));
64 });
65​
66const ipv4Target = (address: string, port: number) => {
67 const target = Buffer.alloc(7);
68 target[0] = 1;
69 address.split(".").forEach((octet, index) => (target[1 + index] = Number(octet)));
70 target.writeUInt16BE(port, 5);
71 return target;
72};
73​
74const ipv6Target = (address: string, port: number) => {
75 const target = Buffer.alloc(19);
76 target[0] = 4;
77 const [head = "", tail = ""] = address.split("::");
78 const left = head ? head.split(":") : [];
79 const right = tail ? tail.split(":") : [];
80 const groups = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
81 groups.forEach((group, index) => target.writeUInt16BE(Number.parseInt(group, 16), 1 + index * 2));
82 target.writeUInt16BE(port, 17);
83 return target;
84};
85​
86const toHexPair = (address: string) => {
87 const [a = 0, b = 0, c = 0, d = 0] = address.split(".").map(Number);
88 return `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
89};
90​
91const domainTarget = (host: string, port: number) => {
92 const name = Buffer.from(host, "latin1");
93 const target = Buffer.alloc(4 + name.length);
94 target[0] = 3;
95 target[1] = name.length;
96 name.copy(target, 2);
97 target.writeUInt16BE(port, 2 + name.length);
98 return target;
99};
100​
101describe("publicProxy", () => {
102 it.effect("refuses loopback and private targets, by address or by name", () =>
103 Effect.gen(function* () {
104 const { port } = yield* publicProxy;
105 for (const target of [
106 ipv4Target("127.0.0.1", 80),
107 ipv4Target("10.1.2.3", 80),
108 ipv4Target("169.254.169.254", 80),
109 ipv4Target("198.18.0.1", 80),
110 domainTarget("localhost", 80),
111 ipv6Target("64:ff9b::7f00:1", 80),
112 ipv6Target("64:ff9b::a00:1", 80),
113 ipv6Target("2002:7f00:1::1", 80),
114 ipv6Target("2001::1", 80),
115 ipv6Target("::7f00:1", 80),
116 ]) {
117 const { code, socket } = yield* connectThrough(port, target);
118 socket.destroy();
119 expect(code).toBe(2);
120 }
121 const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 0));
122 socket.destroy();
123 expect(code).toBe(7);
124 }).pipe(Effect.scoped),
125 );
126​
127 it.effect("refuses fake-IP names and records hosts whose every address is fake", () =>
128 Effect.gen(function* () {
129 const { port, fakeIpHosts } = yield* publicProxy;
130 for (const host of ["cdn.fake-ip.test", "mixed.fake-ip.test"]) {
131 const { code, socket } = yield* connectThrough(port, domainTarget(host, 443));
132 socket.destroy();
133 expect(code).toBe(2);
134 }
135 expect([...fakeIpHosts]).toEqual([["cdn.fake-ip.test", "198.18.2.40"]]);
136 }).pipe(Effect.scoped),
137 );
138​
139 it.effect("refuses every address this machine holds, even a public one", () =>
140 Effect.gen(function* () {
141 const { port } = yield* publicProxy;
142 const own = Object.values(NodeOS.networkInterfaces())
143 .flatMap((entries) => entries ?? [])
144 .filter((entry) => !entry.internal && !entry.address.startsWith("fe80"));
145 const targets = own.flatMap((entry) =>
146 entry.family === "IPv6"
147 ? [ipv6Target(entry.address, 80)]
148 : [
149 ipv4Target(entry.address, 80),
150 ipv6Target(`::ffff:${toHexPair(entry.address)}`, 80),
151 ipv6Target(`64:ff9b::${toHexPair(entry.address)}`, 80),
152 ],
153 );
154 for (const target of targets) {
155 const { code, socket } = yield* connectThrough(port, target);
156 socket.destroy();
157 expect(code).toBe(2);
158 }
159 }).pipe(Effect.scoped),
160 );
161​
162 it.effect("closes every connection when its scope closes", () =>
163 Effect.gen(function* () {
164 const socket = yield* Effect.scoped(
165 Effect.gen(function* () {
166 const { port } = yield* publicProxy;
167 return yield* Effect.callback<NodeNet.Socket>((resume) => {
168 const client = NodeNet.connect(port, "127.0.0.1", () => {
169 client.write(Buffer.from([5, 1, 0]));
170 resume(Effect.succeed(client));
171 });
172 client.on("error", () => {});
173 });
174 }),
175 );
176 yield* Effect.callback<void>((resume) => {
177 if (socket.closed) return resume(Effect.void);
178 socket.once("close", () => resume(Effect.void));
179 });
180 expect(socket.closed).toBe(true);
181 }),
182 );
183});