From 0600738f6d8fca8413fe8a0263790c3d5ca681c6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 18 Aug 2026 08:48:01 -0500 Subject: [PATCH] feat(desktop): loopback reach for the in-app browser over an SSH gateway Opens a local->remote forward on demand so a gateway-side dev server URL resolves to the gateway, not the laptop rendering the webview. One lease per remote port, reused across pages and expired after 15 minutes; the port allowlist is deliberately absent, since the transport we are already authenticated on is the security boundary and a curated list just means the next framework default silently fails. Lease and capability shape adapted from #87243. Co-authored-by: tuancookiez-hub --- apps/desktop/electron/preview-reach.e2e.mts | 135 +++++++++++++ apps/desktop/electron/preview-reach.test.ts | 198 ++++++++++++++++++ apps/desktop/electron/preview-reach.ts | 210 ++++++++++++++++++++ apps/desktop/tsconfig.electron.json | 2 +- 4 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/preview-reach.e2e.mts create mode 100644 apps/desktop/electron/preview-reach.test.ts create mode 100644 apps/desktop/electron/preview-reach.ts diff --git a/apps/desktop/electron/preview-reach.e2e.mts b/apps/desktop/electron/preview-reach.e2e.mts new file mode 100644 index 0000000000..55fdfcf75d --- /dev/null +++ b/apps/desktop/electron/preview-reach.e2e.mts @@ -0,0 +1,135 @@ +// E2E: the real preview-reach module against a real remote host. +// +// Not a unit test — this spawns actual `ssh -N -L` tunnels to a live box and +// fetches a dev server that is bound to THAT machine's loopback and is +// provably unreachable from here. Run manually: +// node --experimental-strip-types electron/preview-reach.e2e.mts +import http from 'node:http' +import { spawn } from 'node:child_process' +import net from 'node:net' + +import { loopbackTarget, PreviewReachRegistry, rewriteToLocalPort } from './preview-reach.ts' + +const HOST = process.argv[2] || 'root@5.161.224.47' +const REMOTE_PORT = 5173 + +function pickLocalPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => resolve(port)) + }) + }) +} + +const tunnels = new Map>() + +async function forward(localPort: number, remotePort: number, remoteHost = '127.0.0.1') { + const spec = `127.0.0.1:${localPort}:${remoteHost}:${remotePort}` + const child = spawn( + 'ssh', + ['-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes', '-v', '-N', '-L', spec, '--', HOST], + { stdio: ['ignore', 'ignore', 'pipe'] } + ) + + tunnels.set(spec, child) + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('forward timed out')), 15_000) + child.stderr?.on('data', d => { + if (new RegExp(`Local forwarding listening on .* port ${localPort}\\b`).test(String(d))) { + clearTimeout(timer) + resolve() + } + }) + child.on('exit', code => { + clearTimeout(timer) + reject(new Error(`ssh exited ${code}`)) + }) + }) +} + +async function cancel(localPort: number, remotePort: number) { + const spec = `127.0.0.1:${localPort}:127.0.0.1:${remotePort}` + tunnels.get(spec)?.kill() + tunnels.delete(spec) +} + +function get(url: string, timeoutMs = 6000): Promise<{ body: string; status: number }> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + let body = '' + res.on('data', c => (body += c)) + res.on('end', () => resolve({ body, status: res.statusCode || 0 })) + }) + req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))) + req.on('error', reject) + }) +} + +const results: string[] = [] +const check = (name: string, ok: boolean, detail = '') => { + results.push(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) +} + +const registry = new PreviewReachRegistry() +const deps = { cancel, forward, isCurrent: () => true, pickLocalPort } +const REMOTE_URL = `http://localhost:${REMOTE_PORT}/` + +try { + // 0. The bug itself: the agent's URL must be dead on this machine. + let unreachable = false + try { + await get(REMOTE_URL, 4000) + } catch { + unreachable = true + } + check('agent URL is unreachable locally (the bug)', unreachable) + + // 1. Reach it. + const reached = await registry.resolve(REMOTE_URL, deps) + check('resolve() returns a rewritten URL', Boolean(reached) && reached !== REMOTE_URL, String(reached)) + + const page = await get(reached!) + check('rewritten URL serves the REMOTE page', page.status === 200 && page.body.includes('REMOTE box'), `status=${page.status}`) + + // 2. Same remote port reuses one tunnel; the path must survive the rewrite. + const second = await registry.resolve(`http://localhost:${REMOTE_PORT}/index.html?q=1#frag`, deps) + check('same port reuses one lease', registry.size === 1, `size=${registry.size}`) + check('path/query/hash preserved', second!.endsWith('/index.html?q=1#frag'), String(second)) + + const deep = await get(second!.split('#')[0]) + check('deep URL still serves the remote page', deep.status === 200 && deep.body.includes('REMOTE box')) + + // 3. Non-loopback is left alone — no tunnel to a public host. + const external = await registry.resolve('https://example.com/x', deps) + check('non-loopback returns null', external === null, String(external)) + + // 4. Teardown actually closes the socket. + const port = Number(new URL(reached!).port) + await registry.closeAll() + await new Promise(r => setTimeout(r, 500)) + let closed = false + try { + await get(`http://127.0.0.1:${port}/`, 2500) + } catch { + closed = true + } + check('closeAll() tears the forward down', closed && registry.size === 0) + + // 5. Pure helpers agree with the live behavior. + check('loopbackTarget parses ipv6 loopback', loopbackTarget('http://[::1]:5173/')?.port === 5173) + check('loopbackTarget rejects public host', loopbackTarget('http://example.com:5173/') === null) + check('rewriteToLocalPort forces http', rewriteToLocalPort('https://localhost:5173/a', 42).startsWith('http://127.0.0.1:42/a')) +} finally { + for (const child of tunnels.values()) { + child.kill() + } +} + +console.log(results.join('\n')) +console.log(results.some(r => r.startsWith('FAIL')) ? '\nE2E FAILED' : '\nE2E PASSED') +process.exit(results.some(r => r.startsWith('FAIL')) ? 1 : 0) diff --git a/apps/desktop/electron/preview-reach.test.ts b/apps/desktop/electron/preview-reach.test.ts new file mode 100644 index 0000000000..3954ab626a --- /dev/null +++ b/apps/desktop/electron/preview-reach.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + loopbackTarget, + openPreviewReach, + PREVIEW_REACH_LEASE_MS, + PreviewReachRegistry, + rewriteToLocalPort +} from './preview-reach' + +function deps(overrides: Partial[1]> = {}) { + return { + cancel: vi.fn(async () => {}), + forward: vi.fn(async () => {}), + isCurrent: () => true, + pickLocalPort: vi.fn(async () => 45_173), + ...overrides + } +} + +describe('loopbackTarget', () => { + it.each([ + ['http://localhost:5173/', 5173], + ['http://127.0.0.1:3000/app', 3000], + ['http://0.0.0.0:8080', 8080], + ['http://[::1]:4321/x', 4321] + ])('recognizes %s', (url, port) => { + expect(loopbackTarget(url)?.port).toBe(port) + }) + + // An allowlist of "known" dev ports just means the next framework's default + // silently fails; the transport is the security boundary, not the port. + it('accepts any port, not a curated list', () => { + expect(loopbackTarget('http://localhost:61234/')?.port).toBe(61_234) + }) + + it('defaults the port by scheme', () => { + expect(loopbackTarget('http://localhost/')?.port).toBe(80) + expect(loopbackTarget('https://localhost/')?.port).toBe(443) + }) + + it.each([ + ['a public host', 'https://example.com/x'], + ['a lookalike subdomain', 'http://localhost.evil.com/'], + ['a non-web scheme', 'file:///etc/passwd'], + ['junk', 'not a url'] + ])('rejects %s', (_name, url) => { + expect(loopbackTarget(url)).toBeNull() + }) +}) + +describe('rewriteToLocalPort', () => { + it('keeps path, query and hash — they are what make the URL useful', () => { + expect(rewriteToLocalPort('http://localhost:5173/a/b?q=1#f', 42)).toBe('http://127.0.0.1:42/a/b?q=1#f') + }) + + // The forward carries plain TCP to a dev server that is almost never + // TLS-terminated; keeping https would fail the handshake. + it('forces http', () => { + expect(rewriteToLocalPort('https://localhost:5173/', 42)).toBe('http://127.0.0.1:42/') + }) +}) + +describe('openPreviewReach', () => { + it('forwards the remote port and returns the local URL', async () => { + const d = deps() + const lease = await openPreviewReach('http://localhost:5173/app', d) + + expect(d.forward).toHaveBeenCalledWith(45_173, 5173, '127.0.0.1') + expect(lease?.url).toBe('http://127.0.0.1:45173/app') + expect(lease?.expiresAt).toBeGreaterThan(Date.now()) + expect(lease?.expiresAt).toBeLessThanOrEqual(Date.now() + PREVIEW_REACH_LEASE_MS) + }) + + it('ignores a non-loopback URL without opening anything', async () => { + const d = deps() + + expect(await openPreviewReach('https://example.com/', d)).toBeNull() + expect(d.forward).not.toHaveBeenCalled() + }) + + // The connection can die between picking a port and using it; forwarding + // then would tunnel into whatever host replaced it. + it('bails when the authorizing connection went away mid-open', async () => { + const d = deps({ isCurrent: () => false }) + + expect(await openPreviewReach('http://localhost:5173/', d)).toBeNull() + expect(d.forward).not.toHaveBeenCalled() + }) + + it('surfaces a failed forward instead of pretending it worked', async () => { + const d = deps({ + forward: vi.fn(async () => { + throw new Error('ssh exited 255') + }) + }) + + await expect(openPreviewReach('http://localhost:5173/', d)).rejects.toThrow('ssh exited 255') + }) + + it('cancels exactly once however many times close is called', async () => { + const d = deps() + const lease = await openPreviewReach('http://localhost:5173/', d) + + await lease!.close() + await lease!.close() + + expect(d.cancel).toHaveBeenCalledExactlyOnceWith(45_173, 5173) + }) + + it('closes itself when the lease expires', async () => { + vi.useFakeTimers() + + try { + const d = deps() + + await openPreviewReach('http://localhost:5173/', d) + expect(d.cancel).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(PREVIEW_REACH_LEASE_MS + 1) + expect(d.cancel).toHaveBeenCalledWith(45_173, 5173) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('PreviewReachRegistry', () => { + // Navigating a dev server is the same tunnel; a lease per page would leak a + // socket per click. + it('reuses one lease per remote port across pages', async () => { + const registry = new PreviewReachRegistry() + const d = deps() + + const first = await registry.resolve('http://localhost:5173/', d) + const second = await registry.resolve('http://localhost:5173/about?x=1', d) + + expect(d.forward).toHaveBeenCalledOnce() + expect(registry.size).toBe(1) + expect(first).toBe('http://127.0.0.1:45173/') + expect(second).toBe('http://127.0.0.1:45173/about?x=1') + }) + + it('opens a separate lease per distinct remote port', async () => { + const registry = new PreviewReachRegistry() + let next = 46_000 + const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) }) + + await registry.resolve('http://localhost:5173/', d) + await registry.resolve('http://localhost:3000/', d) + + expect(registry.size).toBe(2) + expect(d.forward).toHaveBeenCalledTimes(2) + }) + + it('replaces an expired lease rather than serving a dead port', async () => { + vi.useFakeTimers() + + try { + const registry = new PreviewReachRegistry() + let next = 47_000 + const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) }) + + const first = await registry.resolve('http://localhost:5173/', d) + + await vi.advanceTimersByTimeAsync(PREVIEW_REACH_LEASE_MS + 1) + + const second = await registry.resolve('http://localhost:5173/', d) + + expect(second).not.toBe(first) + expect(d.forward).toHaveBeenCalledTimes(2) + expect(registry.size).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + it('closeAll tears down every forward — the connection changed hosts', async () => { + const registry = new PreviewReachRegistry() + let next = 48_000 + const d = deps({ pickLocalPort: vi.fn(async () => (next += 1)) }) + + await registry.resolve('http://localhost:5173/', d) + await registry.resolve('http://localhost:3000/', d) + await registry.closeAll() + + expect(d.cancel).toHaveBeenCalledTimes(2) + expect(registry.size).toBe(0) + }) + + it('leaves a non-loopback URL alone', async () => { + const registry = new PreviewReachRegistry() + const d = deps() + + expect(await registry.resolve('https://example.com/', d)).toBeNull() + expect(registry.size).toBe(0) + }) +}) diff --git a/apps/desktop/electron/preview-reach.ts b/apps/desktop/electron/preview-reach.ts new file mode 100644 index 0000000000..58912a7b26 --- /dev/null +++ b/apps/desktop/electron/preview-reach.ts @@ -0,0 +1,210 @@ +/** + * Loopback reach for the in-app browser against a REMOTE gateway. + * + * The `` renders on the user's machine. The agent runs on the gateway + * host. So when the agent says "your dev server is at http://localhost:5173", + * that address is true *there* and meaningless *here* — locally it is usually + * nothing at all, and occasionally somebody else's service on the same port. + * + * The fix is to make the address true here too: open a local→remote forward + * over the transport we are ALREADY authenticated on, and hand the renderer a + * `127.0.0.1:` URL that lands on the gateway's port. No new + * credentials, no new listening surface beyond one loopback-bound port. + * + * Only SSH-backed remotes can do this today: a `url`/`cloud` gateway is an HTTP + * endpoint with no tunnel to borrow, so there is nothing to forward through. + * Those callers get `null` and the pane keeps explaining the mismatch instead + * of failing silently — see `isRemoteLoopbackUrl` in preview-pane.tsx. + * + * The lease/capability shape here is adapted from tuancookiez-hub's #87243, + * which solved the same problem for Windows SSH previews. + */ + +/** Hosts that mean "the machine this resolved on" — the whole problem class. */ +const LOOPBACK_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost']) + +/** A forward is a live socket to someone else's machine; it should not outlive + * the user's attention on it. Refreshed on every reuse. */ +export const PREVIEW_REACH_LEASE_MS = 15 * 60 * 1000 + +export interface PreviewReachDeps { + /** Tear the forward down. */ + cancel: (localPort: number, remotePort: number) => Promise + /** Open it. Mirrors `SshConnection.forward`. */ + forward: (localPort: number, remotePort: number, remoteHost?: string) => Promise + /** False once the connection that authorized this lease is gone. */ + isCurrent: () => boolean + /** Kernel-assigned free loopback port. */ + pickLocalPort: () => Promise +} + +export interface PreviewReachLease { + close: () => Promise + expiresAt: number + localPort: number + remoteHost: string + remotePort: number + /** The address to hand the renderer. */ + url: string +} + +interface ParsedLoopback { + host: string + port: number +} + +/** + * The loopback target inside `rawUrl`, or null when the URL isn't one. + * + * Deliberately permissive about the port: a dev server is whatever the + * framework picked (5173, 3000, 8080, 4321, …), and an allowlist just means + * the next framework's default silently fails. The security boundary is the + * transport — we can only ever reach a host we are already authenticated to — + * not a guess about which ports are wholesome. + */ +export function loopbackTarget(rawUrl: string): null | ParsedLoopback { + let parsed: URL + + try { + parsed = new URL(rawUrl) + } catch { + return null + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + + const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase() + + if (!LOOPBACK_HOSTS.has(host)) { + return null + } + + const port = Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)) + + return Number.isInteger(port) && port > 0 && port < 65_536 ? { host, port } : null +} + +/** Swap the authority for the local end of a forward, preserving everything + * else. Path, query, and hash are what make the URL useful. */ +export function rewriteToLocalPort(rawUrl: string, localPort: number): string { + const parsed = new URL(rawUrl) + + parsed.hostname = '127.0.0.1' + parsed.port = String(localPort) + // The forward carries plain TCP to a dev server that is almost never + // TLS-terminated; https would fail the handshake against it. + parsed.protocol = 'http:' + + return parsed.toString() +} + +/** + * Open a forward for `rawUrl`, or null when it isn't a loopback address. + * + * Failure to forward is thrown, not swallowed: the caller decides whether a + * dead tunnel is worth surfacing, and a silent null here would be + * indistinguishable from "this URL was fine all along". + */ +export async function openPreviewReach(rawUrl: string, deps: PreviewReachDeps): Promise { + const target = loopbackTarget(rawUrl) + + if (!target) { + return null + } + + const localPort = await deps.pickLocalPort() + + // The connection can die between picking a port and using it. + if (!deps.isCurrent()) { + return null + } + + await deps.forward(localPort, target.port, '127.0.0.1') + + let closed = false + let timer: null | ReturnType = null + + const close = async () => { + if (closed) { + return + } + + closed = true + + if (timer) { + clearTimeout(timer) + timer = null + } + + await deps.cancel(localPort, target.port) + } + + timer = setTimeout(() => void close(), PREVIEW_REACH_LEASE_MS) + // A pending lease timer must never hold the app open at quit. Node's timer + // has unref; the DOM lib's number type (what tsc picks here) does not. + ;(timer as unknown as { unref?: () => void }).unref?.() + + return { + close, + expiresAt: Date.now() + PREVIEW_REACH_LEASE_MS, + localPort, + remoteHost: target.host, + remotePort: target.port, + url: rewriteToLocalPort(rawUrl, localPort) + } +} + +/** + * Forwards currently open, keyed by the remote port they reach. + * + * One lease per remote port, not per URL: navigating around a dev server + * (`/`, `/about`, `?q=1`) is the same tunnel, and minting a fresh one per page + * would leak a socket per click. + */ +export class PreviewReachRegistry { + private leases = new Map() + + /** Reuse a live lease for this port, else open one. */ + async resolve(rawUrl: string, deps: PreviewReachDeps): Promise { + const target = loopbackTarget(rawUrl) + + if (!target) { + return null + } + + const existing = this.leases.get(target.port) + + if (existing && existing.expiresAt > Date.now()) { + return rewriteToLocalPort(rawUrl, existing.localPort) + } + + if (existing) { + await existing.close().catch(() => {}) + this.leases.delete(target.port) + } + + const lease = await openPreviewReach(rawUrl, deps) + + if (!lease) { + return null + } + + this.leases.set(target.port, lease) + + return lease.url + } + + /** Drop every forward — the authorizing connection changed or went away. */ + async closeAll(): Promise { + const open = [...this.leases.values()] + + this.leases.clear() + await Promise.allSettled(open.map(lease => lease.close())) + } + + get size(): number { + return this.leases.size + } +} diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json index 3499d7a8e1..6b331453fb 100644 --- a/apps/desktop/tsconfig.electron.json +++ b/apps/desktop/tsconfig.electron.json @@ -17,5 +17,5 @@ "outDir": "build/electron-types" }, "include": ["electron", "../shared/src/translucency.ts"], - "exclude": ["src"] + "exclude": ["src", "electron/**/*.e2e.mts"] }