Public: anyone can read this; the crew can change it.
The forge serves its front door even when the terminal's port is taken #470
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/77-the-forge-serves-its-front-door-even-whe → main 1 commit fork
4 files
changed +100
−4
README.md +1 −1
| @@ -31,7 +31,7 @@ feeling on a foundation sized for one person. | ||
| 31 | 31 | |
| 32 | 32 | sh |
| 33 | 33 | bun install # dev-only: types + tsc, no runtime deps |
| 34 | bun run src/main.ts # web on :3000, ssh on :2222 | |
| 34 | bun run src/main.ts # web on :3000, ssh on :2222 (FORGE_SSH_PORT=0 turns it off; a taken port turns it off with one warning) | |
| 35 | 35 | |
| 36 | 36 | |
| 37 | 37 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
src/main.ts +10 −3
| @@ -11,7 +11,7 @@ import { runDelivery } from "./core/webhooks.ts"; | ||
| 11 | 11 | import { isHookName, runHookClient } from "./git/hooks.ts"; |
| 12 | 12 | import { serve } from "./http/server.ts"; |
| 13 | 13 | import { runSync } from "./origins/sync.ts"; |
| 14 | import { serveForgeSsh } from "./tui/ssh.ts"; | |
| 14 | import { startForgeSsh } from "./tui/ssh.ts"; | |
| 15 | 15 | import { AppsRuntime } from "./apps/runtime.ts"; |
| 16 | 16 | import { resolveAppDomain } from "./apps/host.ts"; |
| 17 | 17 | import { startChecks } from "./checks/service.ts"; |
| @@ -101,8 +101,15 @@ const app = createApp({ | ||
| 101 | 101 | |
| 102 | 102 | await reconcile(app); |
| 103 | 103 | const servers = await serve(app); |
| 104 | const ssh = | |
| 105 | sshPort > 0 ? await serveForgeSsh(app, { port: sshPort }) : undefined; | |
| 104 | const sshStarted = | |
| 105 | sshPort > 0 ? await startForgeSsh(app, { port: sshPort }) : undefined; | |
| 106 | if (sshStarted && !sshStarted.ok) | |
| 107 | app.log.warn("ssh is off: the terminal could not start", { | |
| 108 | port: sshPort, | |
| 109 | reason: sshStarted.error.message, | |
| 110 | code: (sshStarted.error as { code?: string }).code, | |
| 111 | }); | |
| 112 | const ssh = sshStarted?.ok ? sshStarted.value : undefined; | |
| 106 | 113 | |
| 107 | 114 | // Apps and the crew (docs/APPS.md). Both are on unless FORGE_APPS=0: an app |
| 108 | 115 | // is served by Host on the same port, and the crew runs agents as child |
src/tui/ssh.ts +17
| @@ -20,6 +20,7 @@ import { hookEnv } from "../git/hooks.ts"; | ||
| 20 | 20 | import { repoDir } from "../git/repos.ts"; |
| 21 | 21 | import { parseGitCommand, runGitService } from "../git/smart-ssh.ts"; |
| 22 | 22 | import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; |
| 23 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 23 | 24 | import { createForgeApp } from "./forge.ts"; |
| 24 | 25 | import { appForLogin, attachAppTty } from "../apps/tty.ts"; |
| 25 | 26 | import type { Terminal } from "./term/terminal.ts"; |
| @@ -278,3 +279,19 @@ export async function serveForgeSsh( | ||
| 278 | 279 | forge.config.sshHostKey = server.hostKey.fingerprint; |
| 279 | 280 | return server; |
| 280 | 281 | } |
| 282 | ||
| 283 | /** | |
| 284 | * `serveForgeSsh` as a value: the terminal is optional, the forge is not. A | |
| 285 | * port that will not bind (or any other failure starting the listener) comes | |
| 286 | * back as an Err the boot logs once, and the web keeps serving. | |
| 287 | */ | |
| 288 | export async function startForgeSsh( | |
| 289 | forge: Forge, | |
| 290 | options: ForgeSshOptions, | |
| 291 | ): Promise<Result<SshServer, Error>> { | |
| 292 | try { | |
| 293 | return ok(await serveForgeSsh(forge, options)); | |
| 294 | } catch (cause) { | |
| 295 | return err(cause instanceof Error ? cause : new Error(String(cause))); | |
| 296 | } | |
| 297 | } |
test/ssh-port-taken.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** | |
| 2 | * The terminal is optional, the forge is not: a taken SSH port turns the | |
| 3 | * terminal off with one warning and the web keeps answering. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp } from "../src/app.ts"; | |
| 11 | import { startForgeSsh } from "../src/tui/ssh.ts"; | |
| 12 | ||
| 13 | test("startForgeSsh returns an Err instead of throwing when the port is taken", async () => { | |
| 14 | const dataDir = await mkdtemp(join(tmpdir(), "forge-sshtaken-")); | |
| 15 | const squatter = Bun.listen({ | |
| 16 | hostname: "127.0.0.1", | |
| 17 | port: 0, | |
| 18 | socket: { data() {} }, | |
| 19 | }); | |
| 20 | const forge = createApp({ dataDir, port: 0 }); | |
| 21 | try { | |
| 22 | const r = await startForgeSsh(forge, { | |
| 23 | port: squatter.port, | |
| 24 | hostname: "127.0.0.1", | |
| 25 | }); | |
| 26 | expect(r.ok).toBe(false); | |
| 27 | expect(forge.config.sshPort).toBeUndefined(); | |
| 28 | } finally { | |
| 29 | squatter.stop(true); | |
| 30 | forge.close(); | |
| 31 | await rm(dataDir, { recursive: true, force: true }); | |
| 32 | } | |
| 33 | }); | |
| 34 | ||
| 35 | test("main.ts keeps serving / when the SSH port is taken", async () => { | |
| 36 | const dataDir = await mkdtemp(join(tmpdir(), "forge-sshboot-")); | |
| 37 | const squatter = Bun.listen({ | |
| 38 | hostname: "0.0.0.0", | |
| 39 | port: 0, | |
| 40 | socket: { data() {} }, | |
| 41 | }); | |
| 42 | const web = 20000 + Math.floor(Math.random() * 20000); | |
| 43 | const env: Record<string, string> = { | |
| 44 | ...(process.env as Record<string, string>), | |
| 45 | FORGE_DATA: dataDir, | |
| 46 | PORT: String(web), | |
| 47 | FORGE_SSH_PORT: String(squatter.port), | |
| 48 | FORGE_APPS: "0", | |
| 49 | }; | |
| 50 | delete env.FORGE_URL; | |
| 51 | const proc = Bun.spawn(["bun", "run", "src/main.ts"], { | |
| 52 | env, | |
| 53 | stdout: "pipe", | |
| 54 | stderr: "pipe", | |
| 55 | }); | |
| 56 | try { | |
| 57 | let status = 0; | |
| 58 | for (let i = 0; i < 100 && status === 0; i++) { | |
| 59 | try { | |
| 60 | status = (await fetch(`http://127.0.0.1:${web}/`)).status; | |
| 61 | } catch { | |
| 62 | await Bun.sleep(100); | |
| 63 | } | |
| 64 | } | |
| 65 | expect(status).toBe(200); | |
| 66 | } finally { | |
| 67 | proc.kill(); | |
| 68 | await proc.exited; | |
| 69 | squatter.stop(true); | |
| 70 | await rm(dataDir, { recursive: true, force: true }); | |
| 71 | } | |
| 72 | }); |
new above
offline