| 1 | // @effect-diagnostics nodeBuiltinImport:off - Effect has no SOCKS proxy or address block list. |
| 2 | import * as NodeDnsPromises from "node:dns/promises"; |
| 3 | import * as NodeNet from "node:net"; |
| 4 | import * as NodeOS from "node:os"; |
| 5 | |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | |
| 8 | /** This machine and its local networks, which HTML previews must never reach. */ |
| 9 | const LOCAL_ADDRESSES = new NodeNet.BlockList(); |
| 10 | for (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. |
| 25 | for (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 | |
| 36 | const NAT64 = new NodeNet.BlockList(); |
| 37 | NAT64.addSubnet("64:ff9b::", 96, "ipv6"); |
| 38 | const SIX_TO_FOUR = new NodeNet.BlockList(); |
| 39 | SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6"); |
| 40 | |
| 41 | /** The sixteen-bit groups of an IPv6 address, which may end in dotted IPv4. */ |
| 42 | const 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 | */ |
| 62 | const 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. */ |
| 72 | const 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 | */ |
| 85 | const 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 | */ |
| 97 | const 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. |
| 107 | const 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. |
| 110 | const 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 | */ |
| 116 | const 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. */ |
| 121 | const 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 | */ |
| 147 | export 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 | ); |