Public: anyone can read this; the crew can change it.
The connect command is being eaten by the proxy: print FORGE_APP_SSH so nothing can rewrite it #54
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/29-the-connect-command-is-being-eaten-by-th → main 1 commit fork
6 files
changed +93
−5
README.md +7
| @@ -138,6 +138,13 @@ the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on | ||
| 138 | 138 | a real pty over the same data, as the account at the keyboard: j/k move |
| 139 | 139 | through the timeline, q leaves. |
| 140 | 140 | |
| 141 | The footer prints that command verbatim, quoted, never a guessed form. | |
| 142 | The forge sits behind a proxy that rewrites anything shaped like an email | |
| 143 | address, so the command is wrapped in `<!--email_off-->` and its host sits | |
| 144 | in its own element — readable and copyable without JavaScript, even | |
| 145 | behind the rewrite. With `FORGE_APP_SSH` unset the footer says the | |
| 146 | terminal is off. | |
| 147 | ||
| 141 | 148 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 142 | 149 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
| 143 | 150 | `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how. |
src/page.ts +23 −4
| @@ -10,7 +10,6 @@ import { | ||
| 10 | 10 | description, |
| 11 | 11 | env, |
| 12 | 12 | publicOrigin, |
| 13 | ssh, | |
| 14 | 13 | title, |
| 15 | 14 | where, |
| 16 | 15 | } from "./env.ts"; |
| @@ -18,7 +17,25 @@ import { asset, escape } from "./http.ts"; | ||
| 18 | 17 | import { ago } from "./when.ts"; |
| 19 | 18 | import type { Who } from "./who.ts"; |
| 20 | 19 | |
| 21 | export function layout(body: string, who: Who): string { | |
| 20 | /** | |
| 21 | * The proxy in front of this forge rewrites anything shaped like an email | |
| 22 | * address — including `user@host` in an ssh command — into a decoder | |
| 23 | * script a browser must run before a person can read it. `<!--email_off-->` | |
| 24 | * is its documented opt-out; splitting the host into its own element is | |
| 25 | * belt and braces, so no single text node is left looking like an address | |
| 26 | * even if the comment is ever stripped. | |
| 27 | */ | |
| 28 | const terminal = (ssh: string | undefined): string => { | |
| 29 | if (ssh === undefined) return `<p>Terminal: off.</p>`; | |
| 30 | const at = ssh.lastIndexOf("@"); | |
| 31 | const command = | |
| 32 | at === -1 | |
| 33 | ? escape(ssh) | |
| 34 | : `${escape(ssh.slice(0, at + 1))}<span class="ssh-host">${escape(ssh.slice(at + 1))}</span>`; | |
| 35 | return `<p>Terminal: <!--email_off--><code class="ssh">${command}</code><!--/email_off--></p>`; | |
| 36 | }; | |
| 37 | ||
| 38 | export function layout(body: string, who: Who, ssh: string | undefined): string { | |
| 22 | 39 | const door = |
| 23 | 40 | who.role === "anon" |
| 24 | 41 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` |
| @@ -47,7 +64,7 @@ export function layout(body: string, who: Who): string { | ||
| 47 | 64 | </header> |
| 48 | 65 | <main>${body}</main> |
| 49 | 66 | <footer class="muted"> |
| 50 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 67 | ${terminal(ssh)} | |
| 51 | 68 | <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p> |
| 52 | 69 | </footer> |
| 53 | 70 | </div> |
| @@ -62,8 +79,9 @@ export function home(input: { | ||
| 62 | 79 | source: string | null; |
| 63 | 80 | kind: string | null; |
| 64 | 81 | error?: string; |
| 82 | ssh: string | undefined; | |
| 65 | 83 | }): string { |
| 66 | const { who, events, source, kind, error } = input; | |
| 84 | const { who, events, source, kind, error, ssh } = input; | |
| 67 | 85 | const filtered = source !== null || kind !== null; |
| 68 | 86 | const body = |
| 69 | 87 | events.length === 0 ? empty(filtered) : days(events, source, kind); |
| @@ -73,6 +91,7 @@ export function home(input: { | ||
| 73 | 91 | ${body} |
| 74 | 92 | </section>`, |
| 75 | 93 | who, |
| 94 | ssh, | |
| 76 | 95 | ); |
| 77 | 96 | } |
| 78 | 97 |
src/routes.ts +3
| @@ -32,6 +32,8 @@ export interface Context { | ||
| 32 | 32 | readonly db: Database; |
| 33 | 33 | readonly who: Who; |
| 34 | 34 | readonly params: Record<string, string>; |
| 35 | /** The terminal command, printed verbatim, or `undefined` when there isn't one. */ | |
| 36 | readonly ssh: string | undefined; | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | export interface Route { |
| @@ -75,6 +77,7 @@ export const routes: readonly Route[] = [ | ||
| 75 | 77 | source, |
| 76 | 78 | kind, |
| 77 | 79 | error: listed.ok ? undefined : listed.message, |
| 80 | ssh: c.ssh, | |
| 78 | 81 | }), |
| 79 | 82 | ); |
| 80 | 83 | }, |
src/server.test.ts +48
| @@ -390,6 +390,54 @@ test("refuses a cross-site post", async () => { | ||
| 390 | 390 | expect(posted.status).toBe(403); |
| 391 | 391 | }); |
| 392 | 392 | |
| 393 | test("the terminal command survives the proxy's email-address rewrite", async () => { | |
| 394 | const ssh = "ssh -p 2222 [email protected]"; | |
| 395 | const withSsh = serve({ | |
| 396 | port: 0, | |
| 397 | dataDir: mkdtempSync(join(tmpdir(), "app-")), | |
| 398 | identityKey: KEY, | |
| 399 | ssh, | |
| 400 | }); | |
| 401 | try { | |
| 402 | const page = await ( | |
| 403 | await fetch(`http://${withSsh.hostname}:${withSsh.port}/`) | |
| 404 | ).text(); | |
| 405 | // As text — tags stripped — the exact value is there to read and copy. | |
| 406 | const asText = page | |
| 407 | .replace(/<!--[\s\S]*?-->/g, "") | |
| 408 | .replace(/<[^>]+>/g, ""); | |
| 409 | expect(asText).toContain(ssh); | |
| 410 | // The opt-out the proxy documents, actually present. | |
| 411 | expect(page).toContain("<!--email_off-->"); | |
| 412 | // Nothing the rewrite (or a stripped comment) could still catch. | |
| 413 | expect(page).not.toContain("__cf_email__"); | |
| 414 | expect(page).not.toContain("email-protection"); | |
| 415 | expect(page).not.toContain("mailto:"); | |
| 416 | expect(page).not.toMatch(/<a[^>]*>[^<]*@[^<]*<\/a>/); | |
| 417 | } finally { | |
| 418 | withSsh.stop(true); | |
| 419 | } | |
| 420 | }); | |
| 421 | ||
| 422 | test("with no terminal configured, the footer says so plainly and guesses nothing", async () => { | |
| 423 | const off = serve({ | |
| 424 | port: 0, | |
| 425 | dataDir: mkdtempSync(join(tmpdir(), "app-")), | |
| 426 | identityKey: KEY, | |
| 427 | ssh: undefined, | |
| 428 | }); | |
| 429 | try { | |
| 430 | const page = await ( | |
| 431 | await fetch(`http://${off.hostname}:${off.port}/`) | |
| 432 | ).text(); | |
| 433 | expect(page).toContain("Terminal: off."); | |
| 434 | // No `[email protected]` or any other invented address-shaped text. | |
| 435 | expect(page).not.toMatch(/[\w.-]+@[\w.-]+/); | |
| 436 | } finally { | |
| 437 | off.stop(true); | |
| 438 | } | |
| 439 | }); | |
| 440 | ||
| 393 | 441 | test("static files are cached for a year when linked by content", async () => { |
| 394 | 442 | const page = await (await fetch(base + "/")).text(); |
| 395 | 443 | const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1]; |
src/server.ts +5 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { openDb } from "./db.ts"; |
| 11 | import { dataDir, identityKey, title, where } from "./env.ts"; | |
| 11 | import { dataDir, identityKey, ssh as envSsh, title, where } from "./env.ts"; | |
| 12 | 12 | import { html, json, sameOrigin, text } from "./http.ts"; |
| 13 | 13 | import { PERSON_WRITES, take } from "./ration.ts"; |
| 14 | 14 | import { match } from "./routes.ts"; |
| @@ -21,6 +21,8 @@ export interface ServeOptions { | ||
| 21 | 21 | readonly identityKey: string; |
| 22 | 22 | /** The clock the ration is measured against. A test brings its own. */ |
| 23 | 23 | readonly now?: (() => number) | undefined; |
| 24 | /** The terminal command, printed verbatim. Defaults to `FORGE_APP_SSH`; a test brings its own to cover both branches. */ | |
| 25 | readonly ssh?: string | undefined; | |
| 24 | 26 | } |
| 25 | 27 | |
| 26 | 28 | /** |
| @@ -54,6 +56,7 @@ function tooManyRequests(pathname: string, retryAfterMs: number): Response { | ||
| 54 | 56 | export function serve(options: ServeOptions) { |
| 55 | 57 | const db = openDb(options.dataDir); |
| 56 | 58 | const clock = options.now ?? Date.now; |
| 59 | const ssh = options.ssh ?? envSsh; | |
| 57 | 60 | return Bun.serve({ |
| 58 | 61 | port: options.port, |
| 59 | 62 | hostname: options.hostname ?? "127.0.0.1", |
| @@ -87,6 +90,7 @@ export function serve(options: ServeOptions) { | ||
| 87 | 90 | db, |
| 88 | 91 | who: me, |
| 89 | 92 | params: found.params, |
| 93 | ssh, | |
| 90 | 94 | }); |
| 91 | 95 | // HEAD is GET without the body: a health check sees the app up. |
| 92 | 96 | return request.method === "HEAD" |
static/app.css +7
| @@ -197,6 +197,13 @@ footer { | ||
| 197 | 197 | footer code { |
| 198 | 198 | overflow-wrap: anywhere; |
| 199 | 199 | } |
| 200 | /* A tap selects the whole command; the padded box is the touch target. */ | |
| 201 | .ssh { | |
| 202 | display: inline-flex; | |
| 203 | align-items: center; | |
| 204 | min-height: var(); | |
| 205 | user-select: all; | |
| 206 | } | |
| 200 | 207 | |
| 201 | 208 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ |
| 202 | 209 | .button { |