diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 4af5e52658..87d465136f 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -171,6 +171,7 @@ not implemented. | `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | | `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | +| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 7d92e89285..fd9ffb2a73 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -47,6 +47,7 @@ import { legacyIsEncryptedSecret, } from "../../shared/legacy-vault-decrypt.ts"; import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; +import { legacyConfigureLoopbackProxyBypass } from "../../shared/legacy-hostname.ts"; import { legacyCliProjectFilterValue, legacyServiceContainerIds, @@ -1979,6 +1980,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); + // Keep the synthetic value out of project dotenv resolution and container environments. + legacyConfigureLoopbackProxyBypass(); const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { postgrest: postgrestGateway, edgeRuntime: edgeRuntimeGateway, diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts index 06a2dd05c5..fcfd71f2a0 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.live.test.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; +import { once } from "node:events"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -201,6 +203,68 @@ describeLive("supabase start (live)", () => { }, ); + test( + "bypasses an HTTPS proxy for loopback gateway health checks", + { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-proxy-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS, + }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + let proxyConnections = 0; + const proxy = createServer((socket) => { + proxyConnections += 1; + socket.destroy(); + }); + + try { + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (address === null || typeof address === "string") { + throw new Error("Failed to allocate a proxy port"); + } + + const excludeArgs = LEGACY_SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || + entry.excludeKey === "kong" || + entry.excludeKey === "postgrest" + ? [] + : ["--exclude", entry.excludeKey], + ); + const proxyUrl = `http://127.0.0.1:${address.port}`; + const start = await runSupabaseLive(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + env: { + HTTP_PROXY: "", + http_proxy: "", + HTTPS_PROXY: proxyUrl, + https_proxy: proxyUrl, + NO_PROXY: "", + no_proxy: "", + SUPABASE_API_TLS_ENABLED: "true", + SUPABASE_SERVICES_HOSTNAME: "127.0.0.1", + }, + }); + + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + expect(start.stdout).toContain("https://127.0.0.1:"); + expect(proxyConnections).toBe(0); + } finally { + if (proxy.listening) { + await new Promise((resolve, reject) => { + proxy.close((error) => (error === undefined ? resolve() : reject(error))); + }); + } + } + }, + ); + // The health watch inspects and dumps logs by container NAME against a real // daemon, and derives recovery advice from a real container's real log bytes. // Neither is observable through the in-process mocks, so this reproduces diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 783503d89b..8bafb29722 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; const LOCAL_HOST = "127.0.0.1"; +const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; /** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ const DEFAULT_CONTEXT_NAME = "default"; @@ -141,3 +142,10 @@ export function legacyGetHostname(): string { } return LOCAL_HOST; } + +/** Keeps Bun from proxying the legacy CLI's loopback HTTP requests. */ +export function legacyConfigureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { + const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY"; + const current = env[key]; + env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY; +} diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 0f1c351d0e..88656f8b2f 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { legacyGetHostname } from "./legacy-hostname.ts"; +import { legacyConfigureLoopbackProxyBypass, legacyGetHostname } from "./legacy-hostname.ts"; + +const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]"; function withEnv(entries: Record, run: () => T): T { const previous: Record = {}; @@ -186,3 +188,31 @@ describe("legacyGetHostname", () => { }); }); }); + +describe("legacyConfigureLoopbackProxyBypass", () => { + it.each([ + ["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }], + [ + "preserves an existing NO_PROXY value", + { NO_PROXY: "example.com" }, + { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}` }, + ], + [ + "updates the non-empty lowercase value preferred by Bun", + { NO_PROXY: "uppercase.example", no_proxy: "lowercase.example" }, + { + NO_PROXY: "uppercase.example", + no_proxy: `lowercase.example,${LOOPBACK_NO_PROXY}`, + }, + ], + [ + "falls back to NO_PROXY when lowercase no_proxy is empty", + { NO_PROXY: "example.com", no_proxy: "" }, + { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}`, no_proxy: "" }, + ], + ])("%s", (_name, env, expected) => { + legacyConfigureLoopbackProxyBypass(env); + + expect(env).toEqual(expected); + }); +});