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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/269-a-forge-addressed-by-ip-hands-every-app → main 1 commit fork

  • Merged. scout/269-a-forge-addressed-by-ip-hands-every-app was merged into main as daeb327.

Review

Sign in to review this change.
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
349349 loopback, which is what makes a laptop forge work with no configuration at
350350 all: `http://hello.localhost:3000`.
351351
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
352364 The proxy is a `fetch` to the deployment's loopback port, streamed back.
353365 Headers are forwarded with the usual `X-Forwarded-*` additions. An
354366 environment that is not running gets a page from the forge that says so and
src/apps/host.ts +67
@@ -8,3 +8,70 @@
88 export function appHostSuffix(domain: string): string {
99 return /^[a-z0-9]/i.test(domain) ? `.${domain}` : domain;
1010 }
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";
1313 import { runSync } from "./origins/sync.ts";
1414 import { serveForgeSsh } from "./tui/ssh.ts";
1515 import { AppsRuntime } from "./apps/runtime.ts";
16 import { resolveAppDomain } from "./apps/host.ts";
1617 import { startChecks } from "./checks/service.ts";
1718 import { startLooks } from "./looks/service.ts";
1819 import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts";
@@ -96,11 +97,23 @@ const forgeUrl =
9697 `http://${host}:${servers.port}`;
9798 const publicUrl = new URL(forgeUrl);
9899 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
100115 ? new AppsRuntime(app, {
101 domain:
102 process.env.FORGE_APP_DOMAIN ??
103 (hosted ? `--${publicUrl.hostname}` : publicUrl.hostname),
116 domain: appDomain.domain,
104117 scheme: publicUrl.protocol === "https:" ? "https" : "http",
105118 publicPort:
106119 publicUrl.port === ""
test/apps-adversarial.test.ts +191 −1
@@ -7,7 +7,11 @@
77 * edges: stale state, races, forged input, and the boundary of a bound.
88 */
99
10 import { appHostSuffix } from "../src/apps/host.ts";
10 import {
11 appHostSuffix,
12 domainHoldsAppHosts,
13 resolveAppDomain,
14 } from "../src/apps/host.ts";
1115 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1216 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1317 import { createServer } from "node:net";
@@ -140,6 +144,88 @@ describe("host routing: a single-label attachment", () => {
140144 });
141145 });
142146
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
143229 describe("host routing: parseAppHost", () => {
144230 const DOM = "apps.test";
145231
@@ -1914,3 +2000,107 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", ()
19142000 ).toBe(true);
19152001 }, 30_000);
19162002 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs