| 1 | import * as NodeChild from "node:child_process"; |
| 2 | import * as NodeFs from "node:fs"; |
| 3 | import * as NodeNet from "node:net"; |
| 4 | import * as NodeOs from "node:os"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | |
| 7 | /** |
| 8 | * Hostname rewritten into container env so loopback still looks local |
| 9 | * (Prisma's `prisma+postgres://` plain-HTTP gate). Mapped in the sidecar |
| 10 | * ExtraHosts — to `127.0.0.1` on native Linux (unix-socket tunnel) or |
| 11 | * Docker `host-gateway` on Docker Desktop. |
| 12 | */ |
| 13 | export const CONTAINER_LOOPBACK_ALIAS = "host.docker.localhost"; |
| 14 | |
| 15 | /** Bind-mount target inside the workerd `<name>-proxy` sidecar. */ |
| 16 | export const CONTAINER_LOOPBACK_MOUNT = "/alchemy/host-loopback"; |
| 17 | |
| 18 | const LOOPBACK_HOST_IN_VALUE = |
| 19 | /(?:^|[\s,;=]|\/\/(?:[^/\s@]*@)?)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.localhost)(?=[:/?#]|[\s,;]|$)/i; |
| 20 | |
| 21 | const LOOPBACK_HOST_PORT = |
| 22 | /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.localhost):(\d{1,5})\b/gi; |
| 23 | |
| 24 | const DSN_PORT = /(?:^|[\s;])port=(\d{1,5})\b/gi; |
| 25 | |
| 26 | const unixServers = new Map<number, NodeNet.Server>(); |
| 27 | const netnsForwarders = new Map<string, NodeChild.ChildProcess>(); |
| 28 | |
| 29 | /** |
| 30 | * Native Linux Docker: a SYN to `host-gateway` (bridge IP) is host INPUT |
| 31 | * and UFW/nftables can drop it. Docker Desktop's gateway already reaches |
| 32 | * host loopback, so the unix-socket path is Linux-only. |
| 33 | */ |
| 34 | export const usesUnixSocketLoopback = () => process.platform === "linux"; |
| 35 | |
| 36 | const nsenterAvailable = () => { |
| 37 | try { |
| 38 | const result = NodeChild.spawnSync("nsenter", ["--version"], { |
| 39 | encoding: "utf8", |
| 40 | }); |
| 41 | return result.error === undefined; |
| 42 | } catch { |
| 43 | return false; |
| 44 | } |
| 45 | }; |
| 46 | |
| 47 | export const loopbackSocketDir = () => |
| 48 | NodePath.join(NodeOs.tmpdir(), "alchemy-dev-loopback"); |
| 49 | |
| 50 | const addPort = (ports: Set<number>, raw: string) => { |
| 51 | const port = Number(raw); |
| 52 | if (Number.isInteger(port) && port > 0 && port <= 65535) ports.add(port); |
| 53 | }; |
| 54 | |
| 55 | /** |
| 56 | * Ports a container will dial if this env value is a loopback URL / DSN. |
| 57 | * Empty when the value is not loopback (Neon, PlanetScale, …). |
| 58 | */ |
| 59 | export const loopbackPortsFromEnvValue = (value: string): number[] => { |
| 60 | if (!LOOPBACK_HOST_IN_VALUE.test(value)) return []; |
| 61 | LOOPBACK_HOST_IN_VALUE.lastIndex = 0; |
| 62 | const ports = new Set<number>(); |
| 63 | for (const match of value.matchAll(LOOPBACK_HOST_PORT)) { |
| 64 | if (match[1] !== undefined) addPort(ports, match[1]); |
| 65 | } |
| 66 | for (const match of value.matchAll(DSN_PORT)) { |
| 67 | if (match[1] !== undefined) addPort(ports, match[1]); |
| 68 | } |
| 69 | return [...ports]; |
| 70 | }; |
| 71 | |
| 72 | export const loopbackPortsFromEnv = ( |
| 73 | env: Record<string, string> | ReadonlyArray<string> | undefined, |
| 74 | ): number[] => { |
| 75 | const ports = new Set<number>(); |
| 76 | if (env === undefined) return []; |
| 77 | const values = Array.isArray(env) |
| 78 | ? env.map((entry) => { |
| 79 | const eq = entry.indexOf("="); |
| 80 | return eq === -1 ? "" : entry.slice(eq + 1); |
| 81 | }) |
| 82 | : Object.values(env); |
| 83 | for (const value of values) { |
| 84 | for (const port of loopbackPortsFromEnvValue(value)) ports.add(port); |
| 85 | } |
| 86 | return [...ports]; |
| 87 | }; |
| 88 | |
| 89 | export const sidecarLoopbackExtraHost = () => |
| 90 | usesUnixSocketLoopback() |
| 91 | ? `${CONTAINER_LOOPBACK_ALIAS}:127.0.0.1` |
| 92 | : `${CONTAINER_LOOPBACK_ALIAS}:host-gateway`; |
| 93 | |
| 94 | export const sidecarLoopbackBinds = ( |
| 95 | ports: readonly number[], |
| 96 | ): readonly string[] => { |
| 97 | if (!usesUnixSocketLoopback() || ports.length === 0) return []; |
| 98 | return [`${loopbackSocketDir()}:${CONTAINER_LOOPBACK_MOUNT}`]; |
| 99 | }; |
| 100 | |
| 101 | type HostConfig = { |
| 102 | ExtraHosts?: Array<string>; |
| 103 | Binds?: Array<string>; |
| 104 | }; |
| 105 | |
| 106 | export const mergeSidecarLoopbackHostConfig = <T extends HostConfig>( |
| 107 | original: T | undefined, |
| 108 | ports: readonly number[], |
| 109 | ): T & { ExtraHosts: string[]; Binds?: string[] } => { |
| 110 | const extraHost = sidecarLoopbackExtraHost(); |
| 111 | const extraHosts = [...(original?.ExtraHosts ?? [])]; |
| 112 | if (!extraHosts.includes(extraHost)) extraHosts.push(extraHost); |
| 113 | const binds = [...(original?.Binds ?? [])]; |
| 114 | for (const bind of sidecarLoopbackBinds(ports)) { |
| 115 | if (!binds.includes(bind)) binds.push(bind); |
| 116 | } |
| 117 | return { |
| 118 | ...(original as T), |
| 119 | ExtraHosts: extraHosts, |
| 120 | ...(binds.length > 0 ? { Binds: binds } : {}), |
| 121 | }; |
| 122 | }; |
| 123 | |
| 124 | const pipeSockets = (incoming: NodeNet.Socket, outgoing: NodeNet.Socket) => { |
| 125 | incoming.pipe(outgoing); |
| 126 | outgoing.pipe(incoming); |
| 127 | const fail = () => { |
| 128 | incoming.destroy(); |
| 129 | outgoing.destroy(); |
| 130 | }; |
| 131 | incoming.on("error", fail); |
| 132 | outgoing.on("error", fail); |
| 133 | }; |
| 134 | |
| 135 | const socketPathFor = (port: number) => |
| 136 | NodePath.join(loopbackSocketDir(), `${port}.sock`); |
| 137 | |
| 138 | const FORWARDER_SOURCE = `\ |
| 139 | import * as net from "node:net"; |
| 140 | import * as path from "node:path"; |
| 141 | |
| 142 | const dir = process.argv[2]; |
| 143 | const ports = process.argv.slice(3).map(Number).filter((p) => p > 0 && p <= 65535); |
| 144 | if (dir === undefined || ports.length === 0) process.exit(1); |
| 145 | |
| 146 | for (const port of ports) { |
| 147 | const server = net.createServer((incoming) => { |
| 148 | const outgoing = net.connect(path.join(dir, \`\${port}.sock\`)); |
| 149 | incoming.pipe(outgoing); |
| 150 | outgoing.pipe(incoming); |
| 151 | const fail = () => { |
| 152 | incoming.destroy(); |
| 153 | outgoing.destroy(); |
| 154 | }; |
| 155 | incoming.on("error", fail); |
| 156 | outgoing.on("error", fail); |
| 157 | }); |
| 158 | server.listen({ host: "127.0.0.1", port, exclusive: false }); |
| 159 | } |
| 160 | `; |
| 161 | |
| 162 | const writeForwarderScript = (dir: string) => { |
| 163 | const file = NodePath.join(dir, "forward.mjs"); |
| 164 | NodeFs.writeFileSync(file, FORWARDER_SOURCE); |
| 165 | return file; |
| 166 | }; |
| 167 | |
| 168 | /** |
| 169 | * Host unix socket per port → `127.0.0.1:port`. The container never SYNs |
| 170 | * the docker bridge; UFW INPUT is not on this path. |
| 171 | */ |
| 172 | export const ensureLoopbackUnixSockets = (ports: readonly number[]) => { |
| 173 | if (!usesUnixSocketLoopback()) return; |
| 174 | const unique = [ |
| 175 | ...new Set(ports.filter((port) => Number.isInteger(port) && port > 0)), |
| 176 | ]; |
| 177 | if (unique.length === 0) return; |
| 178 | const dir = loopbackSocketDir(); |
| 179 | NodeFs.mkdirSync(dir, { recursive: true }); |
| 180 | writeForwarderScript(dir); |
| 181 | for (const port of unique) { |
| 182 | if (unixServers.has(port)) continue; |
| 183 | const sock = socketPathFor(port); |
| 184 | try { |
| 185 | NodeFs.unlinkSync(sock); |
| 186 | } catch { |
| 187 | // no leftover socket |
| 188 | } |
| 189 | const server = NodeNet.createServer((incoming) => { |
| 190 | const outgoing = NodeNet.connect({ host: "127.0.0.1", port }); |
| 191 | pipeSockets(incoming, outgoing); |
| 192 | }); |
| 193 | server.on("error", () => { |
| 194 | unixServers.delete(port); |
| 195 | }); |
| 196 | server.listen(sock); |
| 197 | unixServers.set(port, server); |
| 198 | } |
| 199 | }; |
| 200 | |
| 201 | export const closeLoopbackUnixSockets = () => { |
| 202 | for (const [port, server] of unixServers) { |
| 203 | unixServers.delete(port); |
| 204 | server.close(); |
| 205 | try { |
| 206 | NodeFs.unlinkSync(socketPathFor(port)); |
| 207 | } catch { |
| 208 | // already gone |
| 209 | } |
| 210 | } |
| 211 | for (const [id, child] of netnsForwarders) { |
| 212 | netnsForwarders.delete(id); |
| 213 | child.kill(); |
| 214 | } |
| 215 | }; |
| 216 | |
| 217 | export const ufwAllowHint = (ports: readonly number[]) => { |
| 218 | const portList = ports.length > 0 ? ports.join(",") : "<prisma-or-dev-ports>"; |
| 219 | return `sudo ufw allow from 172.16.0.0/12 to any port ${portList} proto tcp`; |
| 220 | }; |
| 221 | |
| 222 | /** |
| 223 | * Listen on `127.0.0.1:<port>` in the sidecar netns and forward through |
| 224 | * the host unix socket. Only the network namespace is entered so this |
| 225 | * process still uses the host filesystem (and host bun/node). |
| 226 | */ |
| 227 | export const attachLoopbackNetnsForwarder = (input: { |
| 228 | keys: readonly string[]; |
| 229 | pid: number; |
| 230 | ports: readonly number[]; |
| 231 | }): { ok: true } | { ok: false; error: string } => { |
| 232 | if (!usesUnixSocketLoopback()) return { ok: true }; |
| 233 | const ports = [ |
| 234 | ...new Set( |
| 235 | input.ports.filter((port) => Number.isInteger(port) && port > 0), |
| 236 | ), |
| 237 | ]; |
| 238 | if (ports.length === 0) return { ok: true }; |
| 239 | if (input.pid <= 0) { |
| 240 | return { ok: false, error: "container pid is not available" }; |
| 241 | } |
| 242 | if (!nsenterAvailable()) { |
| 243 | return { ok: false, error: "nsenter not found on PATH" }; |
| 244 | } |
| 245 | const keys = [...new Set(input.keys.filter((key) => key.length > 0))]; |
| 246 | for (const key of keys) { |
| 247 | netnsForwarders.get(key)?.kill(); |
| 248 | } |
| 249 | const dir = loopbackSocketDir(); |
| 250 | const script = writeForwarderScript(dir); |
| 251 | try { |
| 252 | const child = NodeChild.spawn( |
| 253 | "nsenter", |
| 254 | [ |
| 255 | "-t", |
| 256 | String(input.pid), |
| 257 | "-n", |
| 258 | "--", |
| 259 | process.execPath, |
| 260 | script, |
| 261 | dir, |
| 262 | ...ports.map(String), |
| 263 | ], |
| 264 | { stdio: "ignore" }, |
| 265 | ); |
| 266 | child.on("error", () => { |
| 267 | for (const key of keys) { |
| 268 | if (netnsForwarders.get(key) === child) netnsForwarders.delete(key); |
| 269 | } |
| 270 | }); |
| 271 | child.on("exit", () => { |
| 272 | for (const key of keys) { |
| 273 | if (netnsForwarders.get(key) === child) netnsForwarders.delete(key); |
| 274 | } |
| 275 | }); |
| 276 | for (const key of keys) netnsForwarders.set(key, child); |
| 277 | return { ok: true }; |
| 278 | } catch (error) { |
| 279 | const message = error instanceof Error ? error.message : String(error); |
| 280 | return { ok: false, error: message }; |
| 281 | } |
| 282 | }; |
| 283 | |
| 284 | export const detachLoopbackNetnsForwarder = (containerId: string) => { |
| 285 | const child = netnsForwarders.get(containerId); |
| 286 | if (child === undefined) return; |
| 287 | netnsForwarders.delete(containerId); |
| 288 | child.kill(); |
| 289 | }; |
| 290 | |
| 291 | export const isContainerStartPath = (url: string | undefined) => |
| 292 | url !== undefined && /\/containers\/[^/]+\/start(?:\?|$)/.test(url); |
| 293 | |
| 294 | export const containerIdFromPath = (url: string | undefined) => { |
| 295 | if (url === undefined) return undefined; |
| 296 | return /\/containers\/([^/?]+)/.exec(url)?.[1]; |
| 297 | }; |