1import * as NodeChild from "node:child_process";
2import * as NodeFs from "node:fs";
3import * as NodeNet from "node:net";
4import * as NodeOs from "node:os";
5import * 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 */
13export const CONTAINER_LOOPBACK_ALIAS = "host.docker.localhost";
14
15/** Bind-mount target inside the workerd `<name>-proxy` sidecar. */
16export const CONTAINER_LOOPBACK_MOUNT = "/alchemy/host-loopback";
17
18const LOOPBACK_HOST_IN_VALUE =
19 /(?:^|[\s,;=]|\/\/(?:[^/\s@]*@)?)(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.localhost)(?=[:/?#]|[\s,;]|$)/i;
20
21const LOOPBACK_HOST_PORT =
22 /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.localhost):(\d{1,5})\b/gi;
23
24const DSN_PORT = /(?:^|[\s;])port=(\d{1,5})\b/gi;
25
26const unixServers = new Map<number, NodeNet.Server>();
27const 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 */
34export const usesUnixSocketLoopback = () => process.platform === "linux";
35
36const 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
47export const loopbackSocketDir = () =>
48 NodePath.join(NodeOs.tmpdir(), "alchemy-dev-loopback");
49
50const 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 */
59export 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
72export 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
89export const sidecarLoopbackExtraHost = () =>
90 usesUnixSocketLoopback()
91 ? `${CONTAINER_LOOPBACK_ALIAS}:127.0.0.1`
92 : `${CONTAINER_LOOPBACK_ALIAS}:host-gateway`;
93
94export const sidecarLoopbackBinds = (
95 ports: readonly number[],
96): readonly string[] => {
97 if (!usesUnixSocketLoopback() || ports.length === 0) return [];
98 return [`${loopbackSocketDir()}:${CONTAINER_LOOPBACK_MOUNT}`];
99};
100
101type HostConfig = {
102 ExtraHosts?: Array<string>;
103 Binds?: Array<string>;
104};
105
106export 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
124const 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
135const socketPathFor = (port: number) =>
136 NodePath.join(loopbackSocketDir(), `${port}.sock`);
137
138const FORWARDER_SOURCE = `\
139import * as net from "node:net";
140import * as path from "node:path";
141
142const dir = process.argv[2];
143const ports = process.argv.slice(3).map(Number).filter((p) => p > 0 && p <= 65535);
144if (dir === undefined || ports.length === 0) process.exit(1);
145
146for (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
162const 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 */
172export 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
201export 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
217export 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 */
227export 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
284export const detachLoopbackNetnsForwarder = (containerId: string) => {
285 const child = netnsForwarders.get(containerId);
286 if (child === undefined) return;
287 netnsForwarders.delete(containerId);
288 child.kill();
289};
290
291export const isContainerStartPath = (url: string | undefined) =>
292 url !== undefined && /\/containers\/[^/]+\/start(?:\?|$)/.test(url);
293
294export const containerIdFromPath = (url: string | undefined) => {
295 if (url === undefined) return undefined;
296 return /\/containers\/([^/?]+)/.exec(url)?.[1];
297};