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 * as NodeNet from "node:net";
6import * as NodeOS from "node:os";
7
8import { publicProxy } from "./publicProxy.ts";
9
10// A public IPv4 address this machine holds, which no private range covers.
11const OWN_PUBLIC_IPV4 = "198.51.100.7";
12vi.mock("node:os", async (importOriginal) => {
13 const actual = await importOriginal<typeof NodeOS>();
14 return {
15 ...actual,
16 networkInterfaces: () => ({
17 ...actual.networkInterfaces(),
18 "t3-test": [
19 {
20 address: OWN_PUBLIC_IPV4,
21 netmask: "255.255.255.0",
22 family: "IPv4",
23 mac: "00:00:00:00:00:00",
24 internal: false,
25 cidr: `${OWN_PUBLIC_IPV4}/24`,
26 },
27 ],
28 }),
29 };
30});
31
32/** Sends a SOCKS5 greeting and CONNECT, and resolves with the reply code. */
33const connectThrough = (proxyPort: number, target: Buffer, version = 5) =>
34 Effect.callback<{ readonly code: number; readonly socket: NodeNet.Socket }>((resume) => {
35 const socket = NodeNet.connect(proxyPort, "127.0.0.1", () => {
36 socket.write(Buffer.from([5, 1, 0]));
37 socket.write(Buffer.concat([Buffer.from([version, 1, 0]), target]));
38 });
39 // The proxy may reset a refused connection.
40 socket.on("error", () => {});
41 let received = Buffer.alloc(0);
42 socket.on("data", (chunk) => {
43 received = Buffer.concat([received, chunk]);
44 // Method selection (2 bytes) then the 10-byte reply.
45 if (received.length >= 12) resume(Effect.succeed({ code: received[3]!, socket }));
46 });
47 socket.on("close", () => resume(Effect.succeed({ code: received[3] ?? -1, socket })));
48 });
49
50const ipv4Target = (address: string, port: number) => {
51 const target = Buffer.alloc(7);
52 target[0] = 1;
53 address.split(".").forEach((octet, index) => (target[1 + index] = Number(octet)));
54 target.writeUInt16BE(port, 5);
55 return target;
56};
57
58const ipv6Target = (address: string, port: number) => {
59 const target = Buffer.alloc(19);
60 target[0] = 4;
61 const [head = "", tail = ""] = address.split("::");
62 const left = head ? head.split(":") : [];
63 const right = tail ? tail.split(":") : [];
64 const groups = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
65 groups.forEach((group, index) => target.writeUInt16BE(Number.parseInt(group, 16), 1 + index * 2));
66 target.writeUInt16BE(port, 17);
67 return target;
68};
69
70/** `a.b.c.d` as the two hex groups of an IPv4-mapped IPv6 address. */
71const toHexPair = (address: string) => {
72 const [a = 0, b = 0, c = 0, d = 0] = address.split(".").map(Number);
73 return `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
74};
75
76const domainTarget = (host: string, port: number) => {
77 const name = Buffer.from(host, "latin1");
78 const target = Buffer.alloc(4 + name.length);
79 target[0] = 3;
80 target[1] = name.length;
81 name.copy(target, 2);
82 target.writeUInt16BE(port, 2 + name.length);
83 return target;
84};
85
86describe("publicProxy", () => {
87 it.effect("refuses loopback and private targets, by address or by name", () =>
88 Effect.gen(function* () {
89 const port = yield* publicProxy;
90 for (const target of [
91 ipv4Target("127.0.0.1", 80),
92 ipv4Target("10.1.2.3", 80),
93 ipv4Target("169.254.169.254", 80),
94 ipv4Target("198.18.0.1", 80),
95 domainTarget("localhost", 80),
96 // IPv6 forms that carry a local IPv4 address: NAT64, 6to4, Teredo,
97 // and IPv4-compatible.
98 ipv6Target("64:ff9b::7f00:1", 80),
99 ipv6Target("64:ff9b::a00:1", 80),
100 ipv6Target("2002:7f00:1::1", 80),
101 ipv6Target("2001::1", 80),
102 ipv6Target("::7f00:1", 80),
103 ]) {
104 const { code, socket } = yield* connectThrough(port, target);
105 socket.destroy();
106 expect(code).toBe(2);
107 }
108 // Port 0 is not a connection target.
109 const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 0));
110 socket.destroy();
111 expect(code).toBe(7);
112 }).pipe(Effect.scoped),
113 );
114
115 it.effect("refuses every address this machine holds, even a public one", () =>
116 Effect.gen(function* () {
117 const port = yield* publicProxy;
118 const own = Object.values(NodeOS.networkInterfaces())
119 .flatMap((entries) => entries ?? [])
120 .filter((entry) => !entry.internal && !entry.address.startsWith("fe80"));
121 // IPv4 also as IPv4-mapped IPv6, which names the same host.
122 const targets = own.flatMap((entry) =>
123 entry.family === "IPv6"
124 ? [ipv6Target(entry.address, 80)]
125 : [
126 ipv4Target(entry.address, 80),
127 ipv6Target(`::ffff:${toHexPair(entry.address)}`, 80),
128 ipv6Target(`64:ff9b::${toHexPair(entry.address)}`, 80),
129 ],
130 );
131 for (const target of targets) {
132 const { code, socket } = yield* connectThrough(port, target);
133 socket.destroy();
134 expect(code).toBe(2);
135 }
136 }).pipe(Effect.scoped),
137 );
138
139 it.effect("refuses a request with a wrong version byte", () =>
140 Effect.gen(function* () {
141 const port = yield* publicProxy;
142 const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 443), 4);
143 socket.destroy();
144 expect(code).toBe(7);
145 }).pipe(Effect.scoped),
146 );
147
148 it.effect("fails instead of crashing when it cannot listen", () =>
149 Effect.gen(function* () {
150 const exhausted = Object.assign(new Error("too many open files"), { code: "EMFILE" });
151 const listen = vi.spyOn(NodeNet.Server.prototype, "listen").mockImplementationOnce(function (
152 this: NodeNet.Server,
153 ) {
154 process.nextTick(() => this.emit("error", exhausted));
155 return this;
156 });
157 const error = yield* publicProxy.pipe(Effect.flip, Effect.scoped);
158 listen.mockRestore();
159 expect(error).toBe(exhausted);
160 }),
161 );
162
163 it.effect("closes every connection when its scope closes", () =>
164 Effect.gen(function* () {
165 const socket = yield* Effect.scoped(
166 Effect.gen(function* () {
167 const port = yield* publicProxy;
168 return yield* Effect.callback<NodeNet.Socket>((resume) => {
169 // A client mid-handshake, which the proxy must not wait on; closing
170 // resets it.
171 const client = NodeNet.connect(port, "127.0.0.1", () => {
172 client.write(Buffer.from([5, 1, 0]));
173 resume(Effect.succeed(client));
174 });
175 client.on("error", () => {});
176 });
177 }),
178 );
179 yield* Effect.callback<void>((resume) => {
180 if (socket.closed) return resume(Effect.void);
181 socket.once("close", () => resume(Effect.void));
182 });
183 expect(socket.closed).toBe(true);
184 }),
185 );
186});