Public: anyone can read this; the crew can change it.
A forge addressed by IP hands every app a hostname that is not a URL, and every app dies at boot #309
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/269-a-forge-addressed-by-ip-hands-every-app → main 1 commit fork
4 files
changed +287
−5
docs/APPS.md +12
| @@ -349,6 +349,18 @@ whole, a bare domain gets the dot. Browsers resolve every `*.localhost` name to | ||
| 349 | 349 | loopback, which is what makes a laptop forge work with no configuration at |
| 350 | 350 | all: `http://hello.localhost:3000`. |
| 351 | 351 | |
| 352 | A forge needs a hostname to hand out for this — not every address it can be | |
| 353 | reached at is one. Attach a slug to a bare IP address (`127.0.0.1`, the | |
| 354 | default when nothing sets `FORGE_URL` or `FORGE_HOST`) and the result, | |
| 355 | `notes.127.0.0.1`, is not a URL: WHATWG rejects a hostname whose last label | |
| 356 | is all digits unless the whole thing parses as an IPv4 address. The forge | |
| 357 | checks this once at boot, for the host it is about to hand out. A loopback | |
| 358 | address falls back to `localhost` on its own — same as the paragraph above, | |
| 359 | and it says so in the log. Reached any other way by IP, there is no host to | |
| 360 | fall back to that would resolve anywhere but here, so apps are left off | |
| 361 | rather than started to crash: set `FORGE_APP_DOMAIN` to a real hostname | |
| 362 | (anything DNS resolves to this machine) to serve apps on a forge like that. | |
| 363 | ||
| 352 | 364 | The proxy is a `fetch` to the deployment's loopback port, streamed back. |
| 353 | 365 | Headers are forwarded with the usual `X-Forwarded-*` additions. An |
| 354 | 366 | environment that is not running gets a page from the forge that says so and |
src/apps/host.ts +67
| @@ -8,3 +8,70 @@ | ||
| 8 | 8 | export function appHostSuffix(domain: string): string { |
| 9 | 9 | return /^[a-z0-9]/i.test(domain) ? `.${domain}` : domain; |
| 10 | 10 | } |
| 11 | ||
| 12 | /** | |
| 13 | * Whether `domain` gives a hostname a URL can hold once a slug attaches to | |
| 14 | * it. WHATWG rejects a hostname whose last label is all digits unless the | |
| 15 | * whole host parses as an IPv4 address — which is exactly what attaching a | |
| 16 | * slug to an IP address does: `notes.127.0.0.1` is not a URL, dot or `--` | |
| 17 | * attachment alike. | |
| 18 | */ | |
| 19 | export function domainHoldsAppHosts(domain: string): boolean { | |
| 20 | try { | |
| 21 | new URL(`http://check${appHostSuffix(domain)}`); | |
| 22 | return true; | |
| 23 | } catch { | |
| 24 | return false; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | |
| 29 | ||
| 30 | function isLoopbackHost(hostname: string): boolean { | |
| 31 | return LOOPBACK_HOSTS.has(hostname) || hostname.startsWith("127."); | |
| 32 | } | |
| 33 | ||
| 34 | export interface AppDomainInput { | |
| 35 | /** `FORGE_APP_DOMAIN`, trimmed — undefined when the operator set none. */ | |
| 36 | readonly explicit: string | undefined; | |
| 37 | /** Whether this forge is itself an app of another (docs/APPS.md). */ | |
| 38 | readonly hosted: boolean; | |
| 39 | /** The forge's own public hostname — `publicUrl.hostname`. */ | |
| 40 | readonly hostname: string; | |
| 41 | } | |
| 42 | ||
| 43 | export type AppDomainResolution = | |
| 44 | | { readonly ok: true; readonly domain: string; readonly note?: string } | |
| 45 | | { readonly ok: false; readonly reason: string }; | |
| 46 | ||
| 47 | /** | |
| 48 | * What app hostnames attach to, decided once at boot. `FORGE_APP_DOMAIN` | |
| 49 | * wins outright, unchanged from before this existed. Otherwise the forge's | |
| 50 | * own host is the default — unless it is one a URL cannot hold once a slug | |
| 51 | * attaches, an IP address forge's own hostname most often: a loopback | |
| 52 | * address falls back to `localhost`, which parses and which browsers | |
| 53 | * already resolve to loopback (docs/APPS.md, "Hostnames and the proxy"); | |
| 54 | * anything else leaves apps off rather than start ones guaranteed to crash | |
| 55 | * at boot (`FORGE_APP_URL` failing `new URL()` in the scaffold's own | |
| 56 | * `src/env.ts`, and in any app that parses the URL it was handed). | |
| 57 | */ | |
| 58 | export function resolveAppDomain(input: AppDomainInput): AppDomainResolution { | |
| 59 | const domain = | |
| 60 | input.explicit ?? (input.hosted ? `--${input.hostname}` : input.hostname); | |
| 61 | if (domainHoldsAppHosts(domain)) return { ok: true, domain }; | |
| 62 | if (input.explicit === undefined && isLoopbackHost(input.hostname)) { | |
| 63 | return { | |
| 64 | ok: true, | |
| 65 | domain: input.hosted ? "--localhost" : "localhost", | |
| 66 | note: | |
| 67 | `apps: ${input.hostname} is not a hostname a URL can hold once a slug attaches to it; using localhost instead, which browsers resolve to loopback — ` + | |
| 68 | "set FORGE_APP_DOMAIN to serve apps at a real hostname", | |
| 69 | }; | |
| 70 | } | |
| 71 | return { | |
| 72 | ok: false, | |
| 73 | reason: | |
| 74 | `apps: ${domain} is not a hostname a URL can hold once a slug attaches to it — apps are off; ` + | |
| 75 | "set FORGE_APP_DOMAIN to a hostname that works", | |
| 76 | }; | |
| 77 | } |
src/main.ts +17 −4
| @@ -13,6 +13,7 @@ import { serve } from "./http/server.ts"; | ||
| 13 | 13 | import { runSync } from "./origins/sync.ts"; |
| 14 | 14 | import { serveForgeSsh } from "./tui/ssh.ts"; |
| 15 | 15 | import { AppsRuntime } from "./apps/runtime.ts"; |
| 16 | import { resolveAppDomain } from "./apps/host.ts"; | |
| 16 | 17 | import { startChecks } from "./checks/service.ts"; |
| 17 | 18 | import { startLooks } from "./looks/service.ts"; |
| 18 | 19 | import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts"; |
| @@ -96,11 +97,23 @@ const forgeUrl = | ||
| 96 | 97 | `http://${host}:${servers.port}`; |
| 97 | 98 | const publicUrl = new URL(forgeUrl); |
| 98 | 99 | const appsOn = process.env.FORGE_APPS !== "0"; |
| 99 | const apps = appsOn | |
| 100 | // The host apps answer at is checked once, here: an IP-addressed forge | |
| 101 | // hands out a hostname no URL can hold once a slug attaches to it, and an | |
| 102 | // app that parses its own FORGE_APP_URL (the scaffold does) dies before it | |
| 103 | // answers — every deploy, forever. docs/APPS.md, "Hostnames and the proxy". | |
| 104 | const appDomain = appsOn | |
| 105 | ? resolveAppDomain({ | |
| 106 | explicit: (process.env.FORGE_APP_DOMAIN ?? "").trim() || undefined, | |
| 107 | hosted, | |
| 108 | hostname: publicUrl.hostname, | |
| 109 | }) | |
| 110 | : undefined; | |
| 111 | if (appDomain?.ok === false) app.log.error(appDomain.reason); | |
| 112 | if (appDomain?.ok === true && appDomain.note !== undefined) | |
| 113 | app.log.warn(appDomain.note); | |
| 114 | const apps = appDomain?.ok | |
| 100 | 115 | ? new AppsRuntime(app, { |
| 101 | domain: | |
| 102 | process.env.FORGE_APP_DOMAIN ?? | |
| 103 | (hosted ? `--${publicUrl.hostname}` : publicUrl.hostname), | |
| 116 | domain: appDomain.domain, | |
| 104 | 117 | scheme: publicUrl.protocol === "https:" ? "https" : "http", |
| 105 | 118 | publicPort: |
| 106 | 119 | publicUrl.port === "" |
test/apps-adversarial.test.ts +191 −1
| @@ -7,7 +7,11 @@ | ||
| 7 | 7 | * edges: stale state, races, forged input, and the boundary of a bound. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appHostSuffix } from "../src/apps/host.ts"; | |
| 10 | import { | |
| 11 | appHostSuffix, | |
| 12 | domainHoldsAppHosts, | |
| 13 | resolveAppDomain, | |
| 14 | } from "../src/apps/host.ts"; | |
| 11 | 15 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 12 | 16 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 13 | 17 | import { createServer } from "node:net"; |
| @@ -140,6 +144,88 @@ describe("host routing: a single-label attachment", () => { | ||
| 140 | 144 | }); |
| 141 | 145 | }); |
| 142 | 146 | |
| 147 | describe("host routing: domainHoldsAppHosts (issue #269)", () => { | |
| 148 | test("the hostnames a person really uses hold an app's slug", () => { | |
| 149 | expect(domainHoldsAppHosts("localhost")).toBe(true); | |
| 150 | expect(domainHoldsAppHosts("forge.example")).toBe(true); | |
| 151 | expect(domainHoldsAppHosts("--forge.example")).toBe(true); | |
| 152 | }); | |
| 153 | ||
| 154 | test("an IPv4 address does not: WHATWG rejects a numeric last label", () => { | |
| 155 | expect(domainHoldsAppHosts("127.0.0.1")).toBe(false); | |
| 156 | expect(domainHoldsAppHosts("--127.0.0.1")).toBe(false); | |
| 157 | expect(domainHoldsAppHosts("203.0.113.5")).toBe(false); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("resolveAppDomain: what app hostnames attach to (issue #269)", () => { | |
| 162 | test("an explicit FORGE_APP_DOMAIN wins outright, IP forge or not", () => { | |
| 163 | expect( | |
| 164 | resolveAppDomain({ | |
| 165 | explicit: "apps.example", | |
| 166 | hosted: false, | |
| 167 | hostname: "203.0.113.5", | |
| 168 | }), | |
| 169 | ).toEqual({ ok: true, domain: "apps.example" }); | |
| 170 | expect( | |
| 171 | resolveAppDomain({ | |
| 172 | explicit: "apps.example", | |
| 173 | hosted: false, | |
| 174 | hostname: "forge.example", | |
| 175 | }), | |
| 176 | ).toEqual({ ok: true, domain: "apps.example" }); | |
| 177 | }); | |
| 178 | ||
| 179 | test("an ordinary hostname is used as-is, nothing changes", () => { | |
| 180 | expect( | |
| 181 | resolveAppDomain({ | |
| 182 | explicit: undefined, | |
| 183 | hosted: false, | |
| 184 | hostname: "forge.example", | |
| 185 | }), | |
| 186 | ).toEqual({ ok: true, domain: "forge.example" }); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a loopback forge addressed by IP falls back to localhost, not off", () => { | |
| 190 | const resolved = resolveAppDomain({ | |
| 191 | explicit: undefined, | |
| 192 | hosted: false, | |
| 193 | hostname: "127.0.0.1", | |
| 194 | }); | |
| 195 | expect(resolved.ok).toBe(true); | |
| 196 | if (!resolved.ok) return; | |
| 197 | expect(resolved.domain).toBe("localhost"); | |
| 198 | expect(resolved.note).toContain("FORGE_APP_DOMAIN"); | |
| 199 | // And it actually holds a slug, unlike what it replaced. | |
| 200 | expect(domainHoldsAppHosts(resolved.domain)).toBe(true); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a forge hosted inside another falls back the same way, nested form", () => { | |
| 204 | const resolved = resolveAppDomain({ | |
| 205 | explicit: undefined, | |
| 206 | hosted: true, | |
| 207 | hostname: "127.0.0.1", | |
| 208 | }); | |
| 209 | expect(resolved).toEqual({ | |
| 210 | ok: true, | |
| 211 | domain: "--localhost", | |
| 212 | note: expect.stringContaining("FORGE_APP_DOMAIN"), | |
| 213 | }); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a forge reached by a real, non-loopback IP leaves apps off, message names the fix", () => { | |
| 217 | const resolved = resolveAppDomain({ | |
| 218 | explicit: undefined, | |
| 219 | hosted: false, | |
| 220 | hostname: "203.0.113.5", | |
| 221 | }); | |
| 222 | expect(resolved.ok).toBe(false); | |
| 223 | if (resolved.ok) return; | |
| 224 | expect(resolved.reason).toContain("FORGE_APP_DOMAIN"); | |
| 225 | expect(resolved.reason).toContain("203.0.113.5"); | |
| 226 | }); | |
| 227 | }); | |
| 228 | ||
| 143 | 229 | describe("host routing: parseAppHost", () => { |
| 144 | 230 | const DOM = "apps.test"; |
| 145 | 231 | |
| @@ -1914,3 +2000,107 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", () | ||
| 1914 | 2000 | ).toBe(true); |
| 1915 | 2001 | }, 30_000); |
| 1916 | 2002 | }); |
| 2003 | ||
| 2004 | describe("boot: a forge addressed by IP hands every app a host that works (issue #269)", () => { | |
| 2005 | let dataDir: string; | |
| 2006 | let work: string; | |
| 2007 | let app: App; | |
| 2008 | let servers: Servers; | |
| 2009 | let runtime: AppsRuntime; | |
| 2010 | let alice: User; | |
| 2011 | let domain: string; | |
| 2012 | ||
| 2013 | const viaHost = (host: string, path = "/", init: RequestInit = {}) => | |
| 2014 | fetch(`${servers.url}${path}`, { | |
| 2015 | ...init, | |
| 2016 | headers: { ...(init.headers as Record<string, string>), host }, | |
| 2017 | redirect: "manual", | |
| 2018 | }); | |
| 2019 | ||
| 2020 | async function settled( | |
| 2021 | repoId: string, | |
| 2022 | where: (run: Run) => boolean, | |
| 2023 | timeoutMs = 60_000, | |
| 2024 | ): Promise<Run> { | |
| 2025 | const deadline = Date.now() + timeoutMs; | |
| 2026 | while (Date.now() < deadline) { | |
| 2027 | const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find( | |
| 2028 | where, | |
| 2029 | ); | |
| 2030 | if ( | |
| 2031 | run && | |
| 2032 | (run.state === "ok" || | |
| 2033 | run.state === "failed" || | |
| 2034 | run.state === "cancelled") | |
| 2035 | ) | |
| 2036 | return run; | |
| 2037 | await Bun.sleep(50); | |
| 2038 | } | |
| 2039 | throw new Error("run did not settle in time"); | |
| 2040 | } | |
| 2041 | ||
| 2042 | beforeAll(async () => { | |
| 2043 | dataDir = await mkdtemp(join(tmpdir(), "forge-ip-")); | |
| 2044 | work = await mkdtemp(join(tmpdir(), "forge-ip-work-")); | |
| 2045 | app = createApp({ dataDir, port: 0 }); | |
| 2046 | servers = await serve(app); | |
| 2047 | // The exact computation main.ts does at boot, for a forge reached at | |
| 2048 | // `http://127.0.0.1:<port>` with no FORGE_APP_DOMAIN set — the report in | |
| 2049 | // the issue. Before this fix, the domain below was `127.0.0.1` and every | |
| 2050 | // app's FORGE_APP_URL failed `new URL()` before it ever answered. | |
| 2051 | const resolved = resolveAppDomain({ | |
| 2052 | explicit: undefined, | |
| 2053 | hosted: false, | |
| 2054 | hostname: "127.0.0.1", | |
| 2055 | }); | |
| 2056 | expect(resolved.ok).toBe(true); | |
| 2057 | if (!resolved.ok) throw new Error(resolved.reason); | |
| 2058 | domain = resolved.domain; | |
| 2059 | runtime = new AppsRuntime(app, { | |
| 2060 | domain, | |
| 2061 | scheme: "http", | |
| 2062 | publicPort: servers.port, | |
| 2063 | forgeUrl: servers.url, | |
| 2064 | concurrency: 2, | |
| 2065 | }); | |
| 2066 | await runtime.start(); | |
| 2067 | const created = await createUser(app.db, { | |
| 2068 | username: "alice", | |
| 2069 | email: "[email protected]", | |
| 2070 | password: "a-good-passphrase", | |
| 2071 | adminIfFirst: true, | |
| 2072 | }); | |
| 2073 | if (!created.ok) throw new Error(created.error); | |
| 2074 | alice = created.value; | |
| 2075 | }, 30_000); | |
| 2076 | ||
| 2077 | afterAll(async () => { | |
| 2078 | await runtime.stop(); | |
| 2079 | servers.stop(); | |
| 2080 | app.close(); | |
| 2081 | await rm(dataDir, { recursive: true, force: true }); | |
| 2082 | await rm(work, { recursive: true, force: true }); | |
| 2083 | }); | |
| 2084 | ||
| 2085 | test("the scaffolded app deploys and answers — never the FORGE_APP_URL crash loop", async () => { | |
| 2086 | const made = await newApp(app, alice, { | |
| 2087 | name: "ip-forge", | |
| 2088 | visibility: "public", | |
| 2089 | }); | |
| 2090 | expect(made.ok).toBe(true); | |
| 2091 | if (!made.ok) return; | |
| 2092 | const repo = made.value.repo; | |
| 2093 | const record = made.value.app; | |
| 2094 | const run = await settled(repo.id, (r) => r.env === "production"); | |
| 2095 | // Before this fix: "start: exited with code 1 before answering" — the | |
| 2096 | // scaffold's own src/env.ts throwing on `new URL(FORGE_APP_URL)`. | |
| 2097 | expect(run.state).toBe("ok"); | |
| 2098 | const url = runtime.urlFor(record, "production"); | |
| 2099 | // The exact check the scaffold makes at import time on FORGE_APP_URL. | |
| 2100 | expect(() => new URL(url)).not.toThrow(); | |
| 2101 | const host = new URL(url).host; | |
| 2102 | const page = await viaHost(host); | |
| 2103 | expect(page.status).toBe(200); | |
| 2104 | expect(await page.text()).toContain("<h1>ip-forge</h1>"); | |
| 2105 | }, 90_000); | |
| 2106 | }); |
new above
offline