Public: anyone can read this; the crew can change it.
The host tunnel config lists each app's own domain #11873
Check · Review · Land · Live — Its check is queued.
scout/forge:scout/11871-the-host-tunnel-config-lists-each-app-s → main 1 commit fork
4 files
changed +91
−0
infra/forge-box/cloudflared-forge.yml added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | # Generated by `bun scripts/tunnel-config.ts`; do not edit by hand. | |
| 2 | # The tunnel name and credentials-file are set on the box, not here. | |
| 3 | ingress: | |
| 4 | - hostname: "forge.open-platform.sh" | |
| 5 | service: http://127.0.0.1:3000 | |
| 6 | - hostname: "*--forge.open-platform.sh" | |
| 7 | service: http://127.0.0.1:3000 | |
| 8 | - hostname: "*--forge-trevato-apps.open-platform.sh" | |
| 9 | service: http://127.0.0.1:3000 | |
| 10 | # one rule per app's own domain (app_domain) | |
| 11 | - hostname: "game.cheffryjames.com" | |
| 12 | service: http://127.0.0.1:3000 | |
| 13 | - hostname: "www.cheffryjames.com" | |
| 14 | service: http://127.0.0.1:3000 | |
| 15 | - service: http_status:404 |
scripts/tunnel-config.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/tunnel-config.ts [--db <forge.db>] [--write]` — renders the host | |
| 3 | * tunnel's ingress from the apps' own domains. Prints it, or with `--write` | |
| 4 | * replaces infra/forge-box/cloudflared-forge.yml. Reads the database only. | |
| 5 | */ | |
| 6 | import { Database } from "bun:sqlite"; | |
| 7 | import { writeFileSync } from "node:fs"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { appDomainHosts, renderTunnelConfig } from "../src/system/tunnel_config.ts"; | |
| 10 | ||
| 11 | const args = process.argv.slice(2); | |
| 12 | const i = args.indexOf("--db"); | |
| 13 | const dbPath = i >= 0 ? args[i + 1] : join(process.env.FORGE_DATA ?? "", "forge.db"); | |
| 14 | if (!dbPath || dbPath === "forge.db" && !process.env.FORGE_DATA) { | |
| 15 | console.error("usage: bun scripts/tunnel-config.ts --db <forge.db> [--write]"); | |
| 16 | process.exit(2); | |
| 17 | } | |
| 18 | const text = renderTunnelConfig(appDomainHosts(new Database(dbPath, { readonly: true }))); | |
| 19 | if (args.includes("--write")) writeFileSync(join(import.meta.dir, "../infra/forge-box/cloudflared-forge.yml"), text); | |
| 20 | else process.stdout.write(text); |
src/system/tunnel_config.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * The host tunnel's ingress: the forge's three hostnames, then one rule per | |
| 3 | * app domain in the `app_domain` table, then a 404 catch-all. Plain data in | |
| 4 | * (hostnames), plain text out (cloudflared's yml). `scripts/tunnel-config.ts` | |
| 5 | * reads the table and writes `infra/forge-box/cloudflared-forge.yml`. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | export const FORGE_ORIGIN = "http://127.0.0.1:3000"; | |
| 10 | export const FORGE_HOSTNAMES = [ | |
| 11 | "forge.open-platform.sh", | |
| 12 | "*--forge.open-platform.sh", | |
| 13 | "*--forge-trevato-apps.open-platform.sh", | |
| 14 | ] as const; | |
| 15 | ||
| 16 | /** Every app's own domain, verified or not (the forge refuses what it cannot prove), sorted. */ | |
| 17 | export function appDomainHosts(db: Database): string[] { | |
| 18 | return db | |
| 19 | .query<{ host: string }, []>("SELECT host FROM app_domain ORDER BY host") | |
| 20 | .all() | |
| 21 | .map((r) => r.host); | |
| 22 | } | |
| 23 | ||
| 24 | const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; | |
| 25 | ||
| 26 | export function renderTunnelConfig(appHosts: readonly string[]): string { | |
| 27 | const hosts = [...new Set(appHosts)].sort(); | |
| 28 | for (const h of hosts) if (!HOST.test(h)) throw new Error(`not a hostname: ${h}`); | |
| 29 | const rule = (h: string) => ` - hostname: "${h}"\n service: ${FORGE_ORIGIN}`; | |
| 30 | return [ | |
| 31 | "# Generated by `bun scripts/tunnel-config.ts`; do not edit by hand.", | |
| 32 | "# The tunnel name and credentials-file are set on the box, not here.", | |
| 33 | "ingress:", | |
| 34 | ...FORGE_HOSTNAMES.map(rule), | |
| 35 | " # one rule per app's own domain (app_domain)", | |
| 36 | ...hosts.map(rule), | |
| 37 | " - service: http_status:404", | |
| 38 | "", | |
| 39 | ].join("\n"); | |
| 40 | } |
test/forge-box-unit.test.ts +16
| @@ -49,3 +49,19 @@ test("the install script places the unit, keeps no secret, and is executable", ( | ||
| 49 | 49 | expect(sh).not.toMatch(/PASSPHRASE=\S/); |
| 50 | 50 | expect(require("node:fs").statSync(path).mode & 0o111).toBeGreaterThan(0); |
| 51 | 51 | }); |
| 52 | ||
| 53 | test("the tunnel config has a rule for every app domain, and the file is what the generator renders", async () => { | |
| 54 | const { Database } = await import("bun:sqlite"); | |
| 55 | const { appDomainHosts, renderTunnelConfig, FORGE_HOSTNAMES } = await import("../src/system/tunnel_config.ts"); | |
| 56 | const db = new Database(":memory:"); | |
| 57 | db.run("CREATE TABLE app_domain (host TEXT PRIMARY KEY, verified_at TEXT)"); | |
| 58 | for (const h of ["www.cheffryjames.com", "game.cheffryjames.com", "pending.example.org"]) | |
| 59 | db.run("INSERT INTO app_domain (host) VALUES (?)", [h]); | |
| 60 | const text = renderTunnelConfig(appDomainHosts(db)); | |
| 61 | for (const h of [...FORGE_HOSTNAMES, ...appDomainHosts(db)]) expect(text).toContain(`- hostname: "${h}"\n service: http://127.0.0.1:3000`); | |
| 62 | expect(text.trimEnd().endsWith("- service: http_status:404")).toBe(true); | |
| 63 | expect(() => renderTunnelConfig(["bad host\nservice: x"])).toThrow(); | |
| 64 | // the committed file carries the domains the forge serves today | |
| 65 | const committed = readFileSync(join(root, "infra/forge-box/cloudflared-forge.yml"), "utf8"); | |
| 66 | expect(committed).toBe(renderTunnelConfig(["game.cheffryjames.com", "www.cheffryjames.com"])); | |
| 67 | }); |
new above
offline