From dc8ef41421c93b939aa699512d1c24509558a175 Mon Sep 17 00:00:00 2001 From: Foad Kesheh Date: Thu, 17 Sep 2026 15:47:28 -0300 Subject: [PATCH 1/2] Add locally approved browser control independent of cloud --- bridge/package-lock.json | 4 +- bridge/package.json | 2 +- bridge/src/bridge-cli.test.ts | 13 +- bridge/src/browser-access.test.ts | 161 ++++++++++++ bridge/src/browser-access.ts | 247 ++++++++++++++++++ bridge/src/centrifugo-client.ts | 69 +++-- .../src/centrifugo-local-publications.test.ts | 92 +++++++ .../src/direct-transport/loopback-server.ts | 15 +- bridge/src/index.ts | 64 ++++- bridge/src/local-api-server.ts | 50 +++- bridge/src/local-browser-integration.test.ts | 187 +++++++++++++ bridge/src/local-pair-cli.test.ts | 25 ++ bridge/src/local-pair-cli.ts | 85 ++++++ bridge/src/session-wire.test.ts | 2 + docs/design/local-browser-control.md | 109 ++++++++ docs/local-browser.md | 67 +++++ ui/src/app/local/page.tsx | 168 +----------- ui/src/app/login/page.tsx | 2 + ui/src/app/providers.tsx | 3 + ui/src/components/Dashboard.tsx | 23 +- ui/src/components/LocalDashboard.test.ts | 70 +++++ ui/src/components/LocalDashboard.tsx | 154 +++++++++++ ui/src/components/local/SoloLocalPage.tsx | 167 ++++++++++++ ui/src/hooks/useAllSessionEvents.ts | 6 +- ui/src/hooks/useBridgeRpc.ts | 9 +- ui/src/hooks/useBridges.ts | 8 +- ui/src/hooks/useLoops.ts | 6 +- ui/src/hooks/useSessions.ts | 13 +- .../hybrid-terminal-transport.ts | 20 +- ui/src/lib/local-browser-client.test.ts | 147 +++++++++++ ui/src/lib/local-browser-client.ts | 192 ++++++++++++++ ui/src/lib/message-client.ts | 31 +++ 32 files changed, 1968 insertions(+), 243 deletions(-) create mode 100644 bridge/src/browser-access.test.ts create mode 100644 bridge/src/browser-access.ts create mode 100644 bridge/src/centrifugo-local-publications.test.ts create mode 100644 bridge/src/local-browser-integration.test.ts create mode 100644 bridge/src/local-pair-cli.test.ts create mode 100644 bridge/src/local-pair-cli.ts create mode 100644 docs/design/local-browser-control.md create mode 100644 docs/local-browser.md create mode 100644 ui/src/components/LocalDashboard.test.ts create mode 100644 ui/src/components/LocalDashboard.tsx create mode 100644 ui/src/components/local/SoloLocalPage.tsx create mode 100644 ui/src/lib/local-browser-client.test.ts create mode 100644 ui/src/lib/local-browser-client.ts create mode 100644 ui/src/lib/message-client.ts diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 2b154cf..40b84af 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "ftown-bridge", - "version": "0.19.27", + "version": "0.19.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ftown-bridge", - "version": "0.19.27", + "version": "0.19.28", "license": "MIT", "dependencies": { "@xterm/addon-serialize": "^0.14.0", diff --git a/bridge/package.json b/bridge/package.json index 9f27d83..0d4ece7 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "ftown-bridge", - "version": "0.19.27", + "version": "0.19.28", "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay", "type": "module", "main": "dist/index.js", diff --git a/bridge/src/bridge-cli.test.ts b/bridge/src/bridge-cli.test.ts index 20f91ba..f7e3a47 100644 --- a/bridge/src/bridge-cli.test.ts +++ b/bridge/src/bridge-cli.test.ts @@ -14,5 +14,16 @@ test('ftown-bridge uses the hosted API when --api-url is omitted', () => { assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /--api-url /); - assert.match(result.stdout, /default: "https:\/\/ftown\.ia\.br"/); + assert.match(result.stdout.replace(/\s+/g, ' '), /default: "https:\/\/ftown\.ia\.br"/); +}); + + +test('local browser commands are available before cloud onboarding', () => { + for (const args of [['pair', '--help'], ['devices', '--help'], ['revoke', '--help']]) { + const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/index.ts', ...args], + { cwd: bridgeRoot, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /--data-dir/); + assert.doesNotMatch(result.stdout, /Authenticating|device pairing/); + } }); diff --git a/bridge/src/browser-access.test.ts b/bridge/src/browser-access.test.ts new file mode 100644 index 0000000..1fa4606 --- /dev/null +++ b/bridge/src/browser-access.test.ts @@ -0,0 +1,161 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { BrowserAccess } from './browser-access.js'; +import type { Command, CommandResponse } from './types.js'; + +const origin = 'https://ftown.ia.br'; +const other = 'https://preview.example.com'; +const bootstrap = () => ({ version: 1 as const, userId: 'local', bridgeId: 'bridge-1', hostname: 'laptop', localPort: 1234, localNonce: 'admin-secret' }); +async function fixture(execute: (c: Command) => Promise = async c => ({ requestId: c.requestId, success: true })) { + const dataDir = mkdtempSync(join(tmpdir(), 'browser-access-')); + let revoked = 0; + const opts = { dataDir, allowedOrigins: [origin, other], bootstrap, execute, onRevoke: () => { revoked++; } }; + let access = new BrowserAccess(opts); + const server = createServer((req, res) => { void access.handle(req, res, req.headers.authorization === 'Bearer admin' && !req.headers.origin); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No address'); + async function call(path: string, method = 'GET', body?: unknown, token = '', requestOrigin = origin) { + const response = await fetch(`http://127.0.0.1:${address!.port}/api/browser/${path}`, { method, headers: { ...(requestOrigin ? { Origin: requestOrigin } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }); + return { status: response.status, body: await response.json(), headers: response.headers }; + } + const admin = (path: string, method = 'GET', body?: unknown) => call(path, method, body, 'admin', ''); + async function pair(remember = true) { + await admin('window', 'POST', {}); + const request = await call('pairings', 'POST', { remember }); + assert.equal(request.status, 201); + await admin(`pairings/${request.body.id}/decision`, 'POST', { approve: true }); + const result = await call(`pairings/${request.body.id}`, 'GET', undefined, request.body.pollToken); + return { ...request.body, token: result.body.credential as string }; + } + return { get access() { return access; }, dataDir, call, admin, pair, get revoked() { return revoked; }, restart() { access.close(); access = new BrowserAccess(opts); }, async close() { access.close(); server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); rmSync(dataDir, { recursive: true, force: true }); } }; +} + +test('HTTP consent gates access, binds origin, persists only hashes, revokes and survives restart', async () => { + const f = await fixture(); + try { + assert.equal((await f.call('pairings', 'POST', { remember: true })).status, 409); + assert.equal((await f.call('bootstrap', 'POST', {})).status, 401); + assert.equal((await f.call('window', 'POST', {}, 'admin')).status, 401); + assert.equal((await f.call('pairings', 'POST', { remember: true }, '', 'https://evil.test')).status, 403); + const p = await f.pair(); + const good = await f.call('bootstrap', 'POST', {}, p.token); + assert.equal(good.status, 200); assert.equal(good.body.localNonce, p.token); + assert.equal(f.access.authorize(p.token, origin), true); + assert.equal((await f.call('bootstrap', 'POST', {}, p.token, other)).status, 401); + assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.pollToken, other)).status, 401); + assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.token)).status, 401); + assert.equal((await f.call(`pairings/${p.id}`, 'GET', undefined, p.pollToken)).body.credential, p.token); + assert.equal((await f.admin(`pairings/${p.id}/decision`, 'POST', { approve: false })).status, 409); + const disk = readFileSync(join(f.dataDir, 'browser-devices.json'), 'utf8'); + assert.ok(!disk.includes(p.token)); assert.ok(!disk.includes(p.pollToken)); + f.restart(); assert.equal(f.access.authorize(p.token, origin), true); + const devices = await f.admin('devices'); + const id = devices.body.devices[0].id; + assert.equal((await f.admin(`devices/${id}`, 'DELETE')).status, 200); + assert.equal(f.revoked, 1); assert.equal(f.access.authorize(p.token, origin), false); + f.restart(); assert.equal(f.access.authorize(p.token, origin), false); + } finally { await f.close(); } +}); + +test('denial, expiry, replacement, session-only credentials and pairing caps', async () => { + const f = await fixture(); const now = Date.now; + try { + const session = await f.pair(false); f.restart(); assert.equal(f.access.authorize(session.token, origin), false); + await f.admin('window', 'POST', {}); + const p = await f.call('pairings', 'POST', { remember: false }); + await f.admin(`pairings/${p.body.id}/decision`, 'POST', { approve: false }); + assert.deepEqual((await f.call(`pairings/${p.body.id}`, 'GET', undefined, p.body.pollToken)).body, { status: 'denied' }); + await f.admin('window', 'POST', {}); + assert.equal((await f.call(`pairings/${p.body.id}`, 'GET', undefined, p.body.pollToken)).status, 401); + const expiring = await f.call('pairings', 'POST', { remember: false }); + const timestamp = now(); Date.now = () => timestamp + 120_001; + assert.equal((await f.admin(`pairings/${expiring.body.id}/decision`, 'POST', { approve: true })).status, 404); + assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 409); + Date.now = now; + await f.admin('window', 'POST', {}); + for (let i = 0; i < 5; i++) assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 201); + assert.equal((await f.call('pairings', 'POST', { remember: false })).status, 429); + } finally { Date.now = now; await f.close(); } +}); + +test('same request shares in-flight execution, conflicts reject, device namespaces differ', async () => { + let calls = 0; let release!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const f = await fixture(async c => { calls++; await pending; return { requestId: c.requestId, success: true, data: calls }; }); + try { + const p = await f.pair(); + const body = { type: 'list_sessions', payload: { bridgeId: 'bridge-1' }, requestId: 'one' }; + const a = f.call('commands', 'POST', body, p.token); + const b = f.call('commands', 'POST', body, p.token); + const conflict = await f.call('commands', 'POST', { ...body, type: 'list_loops' }, p.token); + assert.equal(conflict.status, 409); assert.equal(calls, 1); + release(); assert.deepEqual((await a).body, (await b).body); + assert.equal((await f.call('commands', 'POST', body, p.token)).status, 200); assert.equal(calls, 1); + assert.equal((await f.call('commands', 'POST', { ...body, payload: { bridgeId: 'other' } }, p.token)).status, 403); + assert.equal((await f.call('commands', 'POST', { ...body, type: 'nonsense' }, p.token)).status, 400); + const p2 = await f.pair(false); await f.call('commands', 'POST', body, p2.token); assert.equal(calls, 2); + } finally { release(); await f.close(); } +}); + +test('events preserve bounded history, wake longpoll, cap requests and terminate on revoke', async () => { + const f = await fixture(); + try { + const p = await f.pair(); + const waiter = f.call('events?cursor=0', 'GET', undefined, p.token); + const second = f.call('events?cursor=0', 'GET', undefined, p.token); + assert.equal((await f.call('events?cursor=0', 'GET', undefined, p.token)).status, 429); + f.access.publish('sessions', { sessionId: 'abc' }); + assert.deepEqual((await waiter).body.events, [{ channel: 'sessions', data: { sessionId: 'abc' } }]); await second; + for (let i = 0; i < 300; i++) f.access.publish('sessions', { i }); + const result = await f.call('events?cursor=1', 'GET', undefined, p.token); + assert.equal(result.body.reset, true); assert.equal(result.body.events.length, 256); + const waiting = f.call(`events?cursor=${result.body.cursor}`, 'GET', undefined, p.token); + const id = (await f.admin('devices')).body.devices[0].id; + await f.admin(`devices/${id}`, 'DELETE'); assert.equal((await waiting).status, 401); + } finally { await f.close(); } +}); + + +test('CORS preflights require exact origin, methods and headers including private network permission', async () => { + const f = await fixture(); + try { + // Exercise IncomingMessage/ServerResponse through a real HTTP listener. + const server = createServer((req, res) => { void f.access.handle(req, res, false); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + try { + const url = `http://127.0.0.1:${address.port}/api/browser/bootstrap`; + const headers = { Origin: origin, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization, content-type', 'Access-Control-Request-Private-Network': 'true' }; + const response = await fetch(url, { method: 'OPTIONS', headers }); + assert.equal(response.status, 204); assert.equal(response.headers.get('access-control-allow-origin'), origin); + assert.equal(response.headers.get('access-control-allow-private-network'), 'true'); + assert.equal(response.headers.get('access-control-allow-credentials'), null); + assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, Origin: 'null' } })).status, 403); + assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Headers': 'x-admin-token' } })).status, 403); + assert.equal((await fetch(url, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Method': 'PUT' } })).status, 403); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } + } finally { await f.close(); } +}); + +test('command capacity rejects before execution and completed cache entries expire', async () => { + let calls = 0; + const f = await fixture(async c => { calls++; return { requestId: c.requestId, success: true }; }); + const now = Date.now; + try { + const p = await f.pair(); + for (let i = 0; i < 1000; i++) { + const result = await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: String(i) }, p.token); + assert.equal(result.status, 200); + } + assert.equal((await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: 'overflow' }, p.token)).status, 429); + assert.equal(calls, 1000); + const timestamp = now(); Date.now = () => timestamp + 600_001; + assert.equal((await f.call('commands', 'POST', { type: 'list_sessions', payload: {}, requestId: 'overflow' }, p.token)).status, 200); + assert.equal(calls, 1001); + } finally { Date.now = now; await f.close(); } +}); diff --git a/bridge/src/browser-access.ts b/bridge/src/browser-access.ts new file mode 100644 index 0000000..f8327dc --- /dev/null +++ b/bridge/src/browser-access.ts @@ -0,0 +1,247 @@ +import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Command, CommandResponse } from './types.js'; + +export type BrowserBootstrap = { version: 1; userId: string; bridgeId: string; hostname: string; localPort: number; localNonce: string }; +type Options = { dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap; execute: (command: Command) => Promise; onRevoke?: () => void }; +type Device = { id: string; origin: string; createdAt: string; hash: string; bridgeId: string; remember: boolean }; +type Pairing = { id: string; code: string; origin: string; remember: boolean; expiresAt: string; pollHash: string; status: 'pending' | 'denied' | 'approved'; credential?: string }; +type Cached = { body: string; result: Promise; completedAt?: number }; +type Event = { sequence: number; channel: string; data: unknown; bytes: number }; +const WINDOW_MS = 120_000; +const hash = (value: string) => createHash('sha256').update(value).digest('hex'); +const secret = () => randomBytes(32).toString('base64url'); +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + return JSON.stringify(value); +} +class HttpError extends Error { constructor(public status: number, public code: string, message: string) { super(message); } } +const fail = (status: number, code: string, message: string): never => { throw new HttpError(status, code, message); }; +const commandTypes = new Set(['create_session', 'stop_session', 'list_sessions', 'get_history', 'retry_session', 'send_message', 'rename_session', 'remove_session', 'bridge_exec', 'clear_terminal', 'update_session_parent', 'get_session_usage', 'get_sessions_usage', 'create_loop', 'list_loops', 'update_loop', 'delete_loop', 'run_loop_now', 'get_loop_runs']); + +/** Loopback browser consent and control. No cloud dependency and no raw persisted tokens. */ +export class BrowserAccess { + private devices = new Map(); + private pairings = new Map(); + private rates = new Map(); + private commands = new Map(); + private events: Event[] = []; + private eventBytes = 0; + private sequence = 0; + private waiters = new Map<() => void, string>(); + private windowExpires = 0; + private closed = false; + private readonly file: string; + constructor(private readonly opts: Options) { + this.file = join(opts.dataDir, 'browser-devices.json'); + try { + const saved: unknown = JSON.parse(readFileSync(this.file, 'utf8')); + if (!Array.isArray(saved)) throw new Error('Invalid browser credential store'); + for (const d of saved) { + if (!object(d) || typeof d.id !== 'string' || typeof d.origin !== 'string' || typeof d.createdAt !== 'string' || typeof d.hash !== 'string' || !/^[a-f0-9]{64}$/.test(d.hash) || typeof d.bridgeId !== 'string') throw new Error('Invalid browser credential store'); + if (this.devices.size >= 100) throw new Error('Browser credential store exceeds limit'); + this.devices.set(d.id, { ...d, remember: true } as Device); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + private persist() { + mkdirSync(this.opts.dataDir, { recursive: true, mode: 0o700 }); + const temp = `${this.file}.${randomUUID()}.tmp`; + writeFileSync(temp, JSON.stringify([...this.devices.values()].filter(d => d.remember)), { mode: 0o600 }); + renameSync(temp, this.file); + } + private device(token: string, origin: string): Device | undefined { + if (this.closed || !this.opts.allowedOrigins.includes(origin) || !/^[A-Za-z0-9_-]{43}$/.test(token)) return; + const digest = hash(token); + return [...this.devices.values()].find(d => d.hash === digest && d.origin === origin && d.bridgeId === this.opts.bootstrap().bridgeId); + } + authorize(token: string, origin: string): boolean { return !!this.device(token, origin); } + private sweep() { + const now = Date.now(); + for (const [id, pair] of this.pairings) if (Date.parse(pair.expiresAt) <= now) this.pairings.delete(id); + for (const [id, entry] of this.commands) if (entry.completedAt !== undefined && now - entry.completedAt >= 600_000) this.commands.delete(id); + } + private send(res: ServerResponse, status: number, body: unknown) { + if (res.destroyed || res.writableEnded) return; + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + } + private async body(req: IncomingMessage): Promise> { + if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) fail(400, 'invalid_body', 'Expected application/json'); + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of req) { + const bytes = Buffer.from(chunk); size += bytes.length; + if (size > 65536) fail(400, 'invalid_body', 'Request body too large'); + chunks.push(bytes); + } + if (this.closed) fail(503, 'closed', 'Bridge is closing'); + let result: unknown; + try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { fail(400, 'invalid_body', 'Invalid JSON'); } + if (!object(result)) return fail(400, 'invalid_body', 'Expected an object'); + return result; + } + async handle(req: IncomingMessage, res: ServerResponse, admin: boolean): Promise { + let requestId = randomUUID() as string; + try { + if (this.closed) fail(503, 'closed', 'Bridge is closing'); + this.sweep(); + const url = new URL(req.url ?? '/', 'http://localhost'); + const path = url.pathname; + if (!path.startsWith('/api/browser/')) fail(404, 'not_found', 'Not found'); + const origin = req.headers.origin ?? ''; + admin = admin && !origin; + if (!admin) { + if (!this.opts.allowedOrigins.includes(origin)) fail(403, 'origin_forbidden', 'Origin is not allowed'); + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + if (req.method === 'OPTIONS') { + if (!['GET', 'POST', 'DELETE'].includes(req.headers['access-control-request-method'] ?? '')) fail(403, 'preflight_forbidden', 'Method is not allowed'); + const headers = String(req.headers['access-control-request-headers'] ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean); + if (headers.some(h => !['authorization', 'content-type'].includes(h))) fail(403, 'preflight_forbidden', 'Headers are not allowed'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + if (req.headers['access-control-request-private-network'] === 'true') res.setHeader('Access-Control-Allow-Private-Network', 'true'); + res.writeHead(204); res.end(); return; + } + } + const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(req.headers.authorization ?? '')?.[1] ?? ''; + if (admin) { + if (path === '/api/browser/window' && req.method === 'POST') { + await this.body(req); this.windowExpires = Date.now() + WINDOW_MS; this.pairings.clear(); + this.send(res, 200, { expiresAt: new Date(this.windowExpires).toISOString() }); return; + } + if (path === '/api/browser/pairings' && req.method === 'GET') { + this.send(res, 200, { pairings: [...this.pairings.values()].filter(p => p.status === 'pending').map(({ id, code, origin, remember, expiresAt }) => ({ id, code, origin, remember, expiresAt })) }); return; + } + const decision = /^\/api\/browser\/pairings\/([^/]+)\/decision$/.exec(path); + if (decision && req.method === 'POST') { + const body = await this.body(req); + if (typeof body.approve !== 'boolean') fail(400, 'invalid_decision', 'approve must be boolean'); + this.sweep(); + const pair = this.pairings.get(decision[1]); + if (!pair) fail(404, 'not_found', 'Pairing expired or missing'); + const state = body.approve ? 'approved' : 'denied'; + if (pair!.status !== 'pending' && pair!.status !== state) fail(409, 'decision_conflict', 'Pairing already decided'); + if (pair!.status === 'pending' && body.approve) { + if (this.devices.size >= 100) fail(429, 'device_limit', 'Too many paired browsers'); + const credential = secret(); + const device: Device = { id: randomUUID(), origin: pair!.origin, createdAt: new Date().toISOString(), hash: hash(credential), bridgeId: this.opts.bootstrap().bridgeId, remember: pair!.remember }; + this.devices.set(device.id, device); + try { if (device.remember) this.persist(); } catch (error) { this.devices.delete(device.id); throw error; } + pair!.credential = credential; + } + pair!.status = state; this.send(res, 200, { ok: true }); return; + } + if (path === '/api/browser/devices' && req.method === 'GET') { + this.send(res, 200, { devices: [...this.devices.values()].map(({ id, origin, createdAt }) => ({ id, origin, createdAt })) }); return; + } + const deletion = /^\/api\/browser\/devices\/([^/]+)$/.exec(path); + if (deletion && req.method === 'DELETE') { + const previous = this.devices.get(deletion[1]); + this.devices.delete(deletion[1]); + try { if (previous?.remember) this.persist(); } catch (error) { this.devices.set(previous!.id, previous!); throw error; } + for (const wake of [...this.waiters.keys()]) wake(); + this.opts.onRevoke?.(); this.send(res, 200, { ok: true }); return; + } + fail(404, 'not_found', 'Unknown admin route'); + } + if (path === '/api/browser/pairings' && req.method === 'POST') { + const recent = (this.rates.get(origin) ?? []).filter(t => Date.now() - t < 60_000); + this.rates.set(origin, recent); + if (recent.length >= 10) fail(429, 'pairing_rate_limit', 'Too many pairing requests'); + recent.push(Date.now()); + if (Date.now() >= this.windowExpires) fail(409, 'window_closed', 'Run ftown-bridge pair to open approval'); + const body = await this.body(req); + if (Date.now() >= this.windowExpires) fail(409, 'window_closed', 'Pairing window expired'); + if (typeof body.remember !== 'boolean') fail(400, 'invalid_pairing', 'remember must be boolean'); + if (this.pairings.size >= 5) fail(429, 'pairing_limit', 'Too many pairing requests'); + const pollToken = secret(); + let code: string; + do { code = String(randomInt(1_000_000)).padStart(6, '0'); } while ([...this.pairings.values()].some(p => p.code === code)); + const pair: Pairing = { id: randomUUID(), code, origin, remember: body.remember as boolean, expiresAt: new Date(Math.min(this.windowExpires, Date.now() + WINDOW_MS)).toISOString(), pollHash: hash(pollToken), status: 'pending' }; + this.pairings.set(pair.id, pair); + this.send(res, 201, { id: pair.id, code: pair.code, pollToken, expiresAt: pair.expiresAt }); return; + } + const poll = /^\/api\/browser\/pairings\/([^/]+)$/.exec(path); + if (poll && req.method === 'GET') { + const pair = this.pairings.get(poll[1]); + if (!pair || pair.origin !== origin || !bearer || pair.pollHash !== hash(bearer)) fail(401, 'unauthorized', 'Invalid pairing credential'); + this.send(res, 200, { status: pair!.status, ...(pair!.credential ? { credential: pair!.credential } : {}) }); return; + } + const device = this.device(bearer, origin); + if (!device) fail(401, 'unauthorized', 'Pair this browser first'); + if (path === '/api/browser/bootstrap' && req.method === 'POST') { + await this.body(req); + if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked'); + this.send(res, 200, { ...this.opts.bootstrap(), localNonce: bearer }); return; + } + if (path === '/api/browser/commands' && req.method === 'POST') { + const body = await this.body(req); + if (typeof body.requestId !== 'string' || !body.requestId || body.requestId.length > 128 || typeof body.type !== 'string' || !commandTypes.has(body.type) || !object(body.payload)) fail(400, 'invalid_command', 'Invalid command envelope'); + if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked'); + requestId = body.requestId as string; + const payload = body.payload as Record; + if (payload.bridgeId !== undefined && payload.bridgeId !== this.opts.bootstrap().bridgeId) fail(403, 'wrong_bridge', 'Command targets another bridge'); + const key = `${device!.id}:${requestId}`; + const serialized = canonical(body); + let cached = this.commands.get(key); + if (cached && cached.body !== serialized) fail(409, 'request_conflict', 'Request ID was used for a different command'); + if (!cached) { + if (this.commands.size >= 1000) fail(429, 'command_limit', 'Command cache is full; wait before issuing new commands'); + const entry: Cached = { body: serialized, result: Promise.resolve(undefined as unknown as CommandResponse) }; + entry.result = Promise.resolve().then(() => this.opts.execute(body as unknown as Command)).catch(() => ({ requestId, success: false, error: 'Local command failed' })).finally(() => { entry.completedAt = Date.now(); }); + this.commands.set(key, entry); cached = entry; + } + this.send(res, 200, await cached!.result); return; + } + if (path === '/api/browser/events' && req.method === 'GET') { + const raw = url.searchParams.get('cursor') ?? '0'; + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) fail(400, 'invalid_cursor', 'Invalid cursor'); + const cursor = Number(raw); + const reply = () => { + const reset = cursor > this.sequence || cursor < (this.events[0]?.sequence ?? this.sequence + 1) - 1; + return { cursor: this.sequence, reset, events: this.events.filter(e => reset || e.sequence > cursor).map(({ channel, data }) => ({ channel, data })) }; + }; + if (cursor !== this.sequence) { this.send(res, 200, reply()); return; } + if ([...this.waiters.values()].filter(id => id === device!.id).length >= 2) fail(429, 'poll_limit', 'Too many pending event requests'); + await new Promise(resolve => { + const wake = () => { clearTimeout(timer); this.waiters.delete(wake); res.off('close', wake); resolve(); }; + const timer = setTimeout(wake, 20_000); timer.unref(); + this.waiters.set(wake, device!.id); res.once('close', wake); + }); + if (!this.authorize(bearer, origin)) fail(401, 'unauthorized', 'Browser credential revoked'); + this.send(res, 200, reply()); return; + } + fail(404, 'not_found', 'Not found'); + } catch (error) { + const known = error instanceof HttpError; + this.send(res, known ? error.status : 503, { error: known ? error.message : 'Local bridge unavailable', code: known ? error.code : 'unavailable', requestId }); + } + } + publish(channel: string, data: unknown): void { + if (this.closed) return; + // Clone publications so later caller mutations cannot alter history or byte accounting. + const serialized = JSON.stringify({ channel, data }); + const bytes = Buffer.byteLength(serialized); + const clone = JSON.parse(serialized) as { channel: string; data: unknown }; + this.sequence++; + if (bytes > 2 * 1024 * 1024) { this.events = []; this.eventBytes = 0; } + else { + this.events.push({ sequence: this.sequence, ...clone, bytes }); this.eventBytes += bytes; + while (this.events.length > 256 || this.eventBytes > 2 * 1024 * 1024) this.eventBytes -= this.events.shift()!.bytes; + } + for (const wake of [...this.waiters.keys()]) wake(); + } + close(): void { + this.closed = true; this.windowExpires = 0; this.pairings.clear(); + for (const wake of [...this.waiters.keys()]) wake(); + this.devices.clear(); this.events = []; this.commands.clear(); this.rates.clear(); + } +} diff --git a/bridge/src/centrifugo-client.ts b/bridge/src/centrifugo-client.ts index e93a787..9fff27f 100644 --- a/bridge/src/centrifugo-client.ts +++ b/bridge/src/centrifugo-client.ts @@ -78,14 +78,17 @@ export class CentrifugoClient { private readonly subscriptions: Map = new Map(); private readonly onReconnect?: () => void | Promise; private hasConnected = false; + private readonly disabled: boolean; + private readonly localListeners = new Set<(channel: string, data: unknown) => void>(); constructor( url: string, token: string, getToken: () => Promise, - opts?: { onReconnect?: () => void | Promise }, + opts?: { onReconnect?: () => void | Promise; disabled?: boolean }, ) { this.onReconnect = opts?.onReconnect; + this.disabled = opts?.disabled ?? false; this.client = new Centrifuge(url, { token, getToken, @@ -117,9 +120,9 @@ export class CentrifugoClient { this.client.on('disconnected', (ctx) => { console.log(`[Centrifugo] Disconnected: code=${ctx.code} reason=${ctx.reason}`); - if (ctx.code === 3) { + if (!this.disabled && ctx.code === 3) { console.log(`[Centrifugo] Reconnecting after message size limit disconnect...`); - setTimeout(() => this.client.connect(), 1000); + setTimeout(() => this.connect(), 1000); } }); @@ -128,6 +131,35 @@ export class CentrifugoClient { }); } + /** Observe bridge publications without depending on the cloud transport. */ + onLocalPublication(listener: (channel: string, data: unknown) => void): () => void { + this.localListeners.add(listener); + return () => { this.localListeners.delete(listener); }; + } + + private publish(channel: string, data: Record): Promise { + for (const listener of this.localListeners) { + try { + listener(channel, data); + } catch (err) { + console.error('[Centrifugo] Local publication listener failed:', err); + } + } + // Mutations have already committed to the local store. Cloud delivery must + // not delay their acknowledgement or make a successful mutation look failed. + // Disconnected updates are reconciled by the existing reconnect snapshot. + if (!this.disabled && this.client.state === 'connected') { + try { + void this.client.publish(channel, data).catch((err: unknown) => { + console.error(`[Centrifugo] Failed to publish to ${channel}:`, err); + }); + } catch (err) { + console.error(`[Centrifugo] Failed to publish to ${channel}:`, err); + } + } + return Promise.resolve(); + } + private async runOnReconnect(): Promise { if (!this.onReconnect) return; try { @@ -138,6 +170,7 @@ export class CentrifugoClient { } connect(): void { + if (this.disabled) return; this.client.connect(); } @@ -166,7 +199,7 @@ export class CentrifugoClient { async publishSessionUpdate(userId: string, session: Session): Promise { const channel = `sessions:updates#${userId}`; try { - await this.client.publish(channel, { + await this.publish(channel, { type: 'session_update', session: toWireSession(session), timestamp: new Date().toISOString(), @@ -178,6 +211,7 @@ export class CentrifugoClient { } subscribeToLoops(userId: string): void { + if (this.disabled) return; const channel = `loops:updates#${userId}`; const sub = this.client.newSubscription(channel); sub.subscribe(); @@ -188,7 +222,7 @@ export class CentrifugoClient { const channel = `loops:updates#${userId}`; // Loop carries no secret env-like field, so — unlike sessions — nothing is stripped. try { - await this.client.publish(channel, { type: 'loop_update', loop, timestamp: new Date().toISOString() }); + await this.publish(channel, { type: 'loop_update', loop, timestamp: new Date().toISOString() }); } catch (err) { console.error(`[Centrifugo] Failed to publish loop update to ${channel}:`, err); throw err; @@ -198,7 +232,7 @@ export class CentrifugoClient { async publishLoopRemoved(userId: string, loopId: string): Promise { const channel = `loops:updates#${userId}`; try { - await this.client.publish(channel, { type: 'loop_removed', loopId, timestamp: new Date().toISOString() }); + await this.publish(channel, { type: 'loop_removed', loopId, timestamp: new Date().toISOString() }); } catch (err) { console.error(`[Centrifugo] Failed to publish loop removed to ${channel}:`, err); throw err; @@ -218,7 +252,7 @@ export class CentrifugoClient { // with a 10000-entry history, so each reconnect replayed that backlog and // starved the app-level ping. Removed — we publish directly. try { - await this.client.publish(channel, truncateData({ type: 'output', data })); + await this.publish(channel, truncateData({ type: 'output', data })); } catch (err) { console.error(`[Centrifugo] Failed to publish terminal data to ${channel}:`, err); } @@ -231,6 +265,7 @@ export class CentrifugoClient { onResize: TerminalResizeHandler, onInit?: TerminalInitHandler, ): void { + if (this.disabled) return; const channel = `terminal-input:${sessionId}#${userId}`; if (this.subscriptions.has(channel)) { return; @@ -265,6 +300,7 @@ export class CentrifugoClient { } subscribeToCommands(userId: string, handler: CommandHandler, onDirectCommand?: DirectCommandHandler): void { + if (this.disabled) return; const channel = `commands:rpc#${userId}`; const existingSub = this.subscriptions.get(channel); @@ -331,6 +367,7 @@ export class CentrifugoClient { // `info` claim, not subscription data — Centrifugo ignores subscribe data // without a subscribe proxy; presence exposes conn_info only. joinBridgesChannel(userId: string, bridgeId: string): void { + if (this.disabled) return; const channel = `bridges:presence#${userId}`; const presenceInfo: BridgePresenceInfo = { @@ -358,7 +395,7 @@ export class CentrifugoClient { async publishTerminalScreen(userId: string, sessionId: string, raw: string): Promise { const channel = `terminal:${sessionId}#${userId}`; try { - await this.client.publish(channel, { + await this.publish(channel, { type: 'screen_dump', raw, timestamp: new Date().toISOString(), @@ -370,16 +407,10 @@ export class CentrifugoClient { async publishHookEvent(userId: string, sessionId: string, event: Record): Promise { const channel = `events:${sessionId}#${userId}`; - if (!this.subscriptions.has(channel)) { - const sub = this.client.newSubscription(channel); - this.subscriptions.set(channel, sub); - await new Promise((resolve) => { - sub.on('subscribed', () => resolve()); - sub.subscribe(); - }); - } + // The events namespace permits client publishing without subscribing. + // Waiting for a subscription here deadlocks local hooks during cloud loss. try { - await this.client.publish(channel, truncateData(event)); + await this.publish(channel, truncateData(event)); } catch (err) { console.error(`[Centrifugo] Failed to publish hook event to ${channel}:`, err); } @@ -389,7 +420,7 @@ export class CentrifugoClient { async publishSignal(userId: string, msg: SignalMessage): Promise { const channel = `commands:rpc#${userId}`; try { - await this.client.publish(channel, msg as unknown as Record); + await this.publish(channel, msg as unknown as Record); } catch (err) { console.error(`[Centrifugo] Failed to publish signal to ${channel}:`, err); } @@ -398,7 +429,7 @@ export class CentrifugoClient { async publishCommandResponse(userId: string, response: CommandResponse): Promise { const channel = `commands:rpc#${userId}`; try { - await this.client.publish(channel, truncateData({ + await this.publish(channel, truncateData({ type: 'command_response', response: response as unknown as Record, timestamp: new Date().toISOString(), diff --git a/bridge/src/centrifugo-local-publications.test.ts b/bridge/src/centrifugo-local-publications.test.ts new file mode 100644 index 0000000..60d0685 --- /dev/null +++ b/bridge/src/centrifugo-local-publications.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CentrifugoClient } from './centrifugo-client.js'; +import type { Session, Loop } from './types.js'; + +function fixture(disabled = false) { + const client = new CentrifugoClient('ws://127.0.0.1:1/connection/websocket', '', async () => '', { disabled }); + const transport = (client as unknown as { client: { + state: string; + publish: (channel: string, data: unknown) => Promise; + connect: () => void; + newSubscription: () => never; + } }).client; + return { client, transport }; +} + +test('disabled local mode never connects or subscribes, and publishes local events with session secrets removed', async () => { + const { client, transport } = fixture(true); + transport.connect = () => { throw new Error('unexpected cloud connect'); }; + transport.newSubscription = () => { throw new Error('unexpected cloud subscription'); }; + transport.publish = () => { throw new Error('unexpected cloud publish'); }; + client.connect(); + client.subscribeToSessions('local'); + client.subscribeToLoops('local'); + client.subscribeToCommands('local', () => {}); + client.subscribeToTerminalInput('local', 's', () => {}, () => {}); + client.joinBridgesChannel('local', 'b'); + const events: Array<{ channel: string; data: unknown }> = []; + const unsubscribe = client.onLocalPublication((channel, data) => events.push({ channel, data })); + const session = { id: 's', env: { TOKEN: 'secret' } } as unknown as Session; + await client.publishSessionUpdate('local', session); + await client.publishLoopUpdate('local', { id: 'l' } as Loop); + await client.publishLoopRemoved('local', 'l'); + await client.publishTerminalData('local', 's', 'hello'); + await client.publishTerminalScreen('local', 's', 'screen'); + await client.publishHookEvent('local', 's', { type: 'hook', data: 'event' }); + await client.publishCommandResponse('local', { requestId: 'r', success: true }); + assert.deepEqual(events.map((event) => event.channel), [ + 'sessions:updates#local', 'loops:updates#local', 'loops:updates#local', + 'terminal:s#local', 'terminal:s#local', 'events:s#local', 'commands:rpc#local', + ]); + assert.equal((events[0].data as { session: Session }).session.env, undefined); + assert.equal(session.env?.TOKEN, 'secret'); + assert.equal((events[6].data as { response: { requestId: string } }).response.requestId, 'r'); + unsubscribe(); + await client.publishLoopRemoved('local', 'another'); + assert.equal(events.length, 7); +}); + +test('disconnected cloud does not queue or delay local hook and command acknowledgements', async () => { + const { client, transport } = fixture(); + transport.publish = () => { throw new Error('unexpected disconnected publish'); }; + transport.newSubscription = () => { throw new Error('unexpected hook subscription'); }; + const events: unknown[] = []; + client.onLocalPublication((_channel, data) => events.push(data)); + await client.publishHookEvent('user', 's', { type: 'hook' }); + await client.publishCommandResponse('user', { requestId: 'r', success: true }); + assert.equal(events.length, 2); +}); + +test('cloud publish which never acknowledges cannot stall an already committed local mutation', async () => { + const { client, transport } = fixture(); + transport.state = 'connected'; + const remote: unknown[] = []; + transport.publish = (channel, data) => { + remote.push({ channel, data }); + return new Promise(() => {}); + }; + const local: unknown[] = []; + client.onLocalPublication((channel, data) => local.push({ channel, data })); + let timer: ReturnType | undefined; + try { + await Promise.race([ + client.publishLoopRemoved('user', 'l'), + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('publication stalled')), 100); }), + ]); + } finally { + clearTimeout(timer); + } + assert.deepEqual(remote, local); + assert.equal(local.length, 1); +}); + +test('rejected cloud delivery does not reject a committed local mutation', async () => { + const { client, transport } = fixture(); + transport.state = 'connected'; + transport.publish = async () => { throw new Error('cloud disconnected during publish'); }; + const local: unknown[] = []; + client.onLocalPublication((_channel, data) => local.push(data)); + await assert.doesNotReject(client.publishLoopRemoved('user', 'l')); + assert.equal(local.length, 1); +}); diff --git a/bridge/src/direct-transport/loopback-server.ts b/bridge/src/direct-transport/loopback-server.ts index f3747e2..96184e2 100644 --- a/bridge/src/direct-transport/loopback-server.ts +++ b/bridge/src/direct-transport/loopback-server.ts @@ -39,8 +39,7 @@ function constantTimeEq(a: string, b: string): boolean { /** Same loopback host guard the HTTP handler applies (defense in depth). */ function isLoopbackHost(hostHeader: string | undefined): boolean { if (!hostHeader) return false; - const host = hostHeader.split(':')[0]; - return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; + return /^(127\.0\.0\.1|localhost|\[::1\])(?::[0-9]{1,5})?$/.test(hostHeader); } function isLocalhostOrigin(origin: string): boolean { @@ -52,6 +51,8 @@ export interface LoopbackPeerServerOptions { bridgeId: string; /** Per-process nonce; upgrade requires `?nonce=` to match this exactly. */ nonce: string; + /** Additional origin-bound browser credentials issued by local approval. */ + authorize?: (token: string, origin: string) => boolean; /** Exact non-localhost origins allowed to upgrade (typically the api-url origin). */ allowedOrigins: string[]; /** `input` frames feed here (same sink as terminal-input / DataChannel). */ @@ -216,6 +217,7 @@ class LoopbackPeer { export class LoopbackPeerServer { readonly bridgeId: string; private readonly nonce: string; + private readonly authorize?: (token: string, origin: string) => boolean; private readonly allowedOrigins: string[]; private readonly onInputCb: (sessionId: string, data: string) => void; private readonly onResizeCb: (sessionId: string, cols: number, rows: number) => void; @@ -228,6 +230,7 @@ export class LoopbackPeerServer { constructor(options: LoopbackPeerServerOptions) { this.bridgeId = options.bridgeId; this.nonce = options.nonce; + this.authorize = options.authorize; this.allowedOrigins = options.allowedOrigins; this.onInputCb = options.onInput; this.onResizeCb = options.onResize; @@ -257,6 +260,11 @@ export class LoopbackPeerServer { return false; } + disconnectPeers(): void { + for (const peer of this.peers) peer.close(); + this.peers.clear(); + } + closeAll(): void { if (this.httpServer && this.upgradeHandler) { this.httpServer.removeListener('upgrade', this.upgradeHandler); @@ -308,7 +316,8 @@ export class LoopbackPeerServer { this.reject(socket, 403, 'Forbidden'); return; } - if (!this.nonceMatches(url.searchParams.get('nonce'))) { + const token = url.searchParams.get('nonce'); + if (!this.nonceMatches(token) && !(token && req.headers.origin && this.authorize?.(token, req.headers.origin))) { this.reject(socket, 403, 'Forbidden'); return; } diff --git a/bridge/src/index.ts b/bridge/src/index.ts index d882083..45405c9 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -19,6 +19,9 @@ import { ProcessRunner } from './claude-runner.js'; import { SessionStore } from './session-store.js'; import { MailStore } from './mail-store.js'; import { LocalApiServer } from './local-api-server.js'; +import { BrowserAccess } from './browser-access.js'; +import { registerLocalBrowserCommands } from './local-pair-cli.js'; +import type { CommandResponse } from './types.js'; import { TerminalManager } from './terminal-manager.js'; import { installClaudeHooks } from './hook-installer.js'; import { installCursorHooks } from './cursor-hook-installer.js'; @@ -99,6 +102,7 @@ const program = new Commander(); program .name('ftown-bridge') .description('ftown orchestrator bridge for Centrifugo') + .option('--local', 'Local browser control only: no cloud account or connection') .option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth') .option('--port ', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)') .option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)') @@ -106,7 +110,8 @@ program .option('--api-url ', 'ftown UI API URL', DEFAULT_API_URL) .option('--data-dir ', 'Directory for session data (default: ~/.ftown/data)') .option('--bridge-id ', 'Bridge instance ID (default: persisted per data dir)') - .action(async (opts: { solo?: boolean; port?: string; rotateKey?: boolean; token?: string; apiUrl: string; dataDir?: string; bridgeId?: string }) => { + .action(async (opts: { local?: boolean; solo?: boolean; port?: string; rotateKey?: boolean; token?: string; apiUrl: string; dataDir?: string; bridgeId?: string }) => { + if (opts.local && opts.solo) program.error('--local and --solo cannot be combined'); const apiUrl = new URL(opts.apiUrl); const isLocalHost = apiUrl.hostname === 'localhost' || @@ -179,7 +184,16 @@ program const localApiServer = new LocalApiServer(); const apiToken = randomBytes(32).toString('hex'); localApiServer.setAuthToken(apiToken); - const hookPort = await localApiServer.start(); + // Reuse the local port so remembered browsers survive restarts. If another + // process took it, the server chooses an available port and pair prints it. + const localPortPath = join(dataDir, 'local-api-port'); + let preferredLocalPort = 0; + try { + const saved = Number(readFileSync(localPortPath, 'utf8').trim()); + if (Number.isInteger(saved) && saved > 0 && saved <= 65535) preferredLocalPort = saved; + } catch { /* first start */ } + const hookPort = await localApiServer.start(preferredLocalPort); + writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 }); console.log(`[Bridge] Local API server started on port ${hookPort}`); const localNonce = randomBytes(16).toString('hex'); @@ -387,7 +401,9 @@ program }; let auth: BridgeAuthResponse; - if (solo) { + if (opts.local) { + auth = { userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' }; + } else if (solo) { // Solo mode: identity is synthesized locally (contract S2/S10). No // refresh token exists; getToken() mints a fresh hub JWT on demand. // The bridge's own connection token carries `info` (matching the cloud @@ -418,9 +434,9 @@ program auth = await onboard(); } const currentRefreshToken = auth.refreshToken; - if (!solo) persistRefreshToken(currentRefreshToken); + if (!solo && !opts.local) persistRefreshToken(currentRefreshToken); - const tokenRefresher = solo + const tokenRefresher = solo || opts.local ? null : new RotatingTokenRefresher({ initialRefreshToken: currentRefreshToken, @@ -477,6 +493,7 @@ program const runner = new ProcessRunner(); const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, { + disabled: opts.local, // On a transport reconnect, re-publish the session snapshot. The UI does // not re-request its list on reconnect, so without this its session list // goes stale/empty after a Centrifugo blip until a page reload. @@ -535,11 +552,13 @@ program // over TCP on the existing 127.0.0.1 local API server. Bypasses VPN/endpoint // filters that kill UDP hairpin. Input/resize/attach feed the SAME sinks as // the WebRTC peer manager; upgrades are gated on the per-process nonce (L1/L2). + let browserAccess: BrowserAccess | undefined; let apiOrigin = ''; try { apiOrigin = new URL(opts.apiUrl).origin; } catch { /* leave empty; only localhost origins accepted */ } const loopbackServer = new LoopbackPeerServer({ bridgeId, nonce: localNonce, + authorize: (token, origin) => browserAccess?.authorize(token, origin) ?? false, allowedOrigins: apiOrigin ? [apiOrigin] : [], onInput: (sid, data) => { runner.write(sid, data); }, onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); }, @@ -802,6 +821,7 @@ program bridgePointerPath, JSON.stringify({ port: hookPort, + apiUrl: opts.apiUrl, token: apiToken, bridgeId, pid: process.pid, @@ -817,11 +837,12 @@ program } const cleanupPointer = (): void => { - try { unlinkSync(bridgePointerPath); } catch { /* already gone */ } + try { + const pointer = JSON.parse(readFileSync(bridgePointerPath, 'utf8')); + if (pointer.pid === process.pid && pointer.token === apiToken) unlinkSync(bridgePointerPath); + } catch { /* already gone or owned by a newer bridge */ } }; process.on('exit', cleanupPointer); - process.on('SIGINT', () => { cleanupPointer(); process.exit(0); }); - process.on('SIGTERM', () => { cleanupPointer(); process.exit(0); }); function publishScreenDump(sid: string): void { // Phase 1: viewport-only dump (~rows*cols bytes) for instant render. @@ -866,6 +887,26 @@ program }); }); + let ready = false; + browserAccess = new BrowserAccess({ + dataDir, + allowedOrigins: apiOrigin ? [apiOrigin] : [], + bootstrap: () => ({ version: 1, userId, bridgeId, hostname: osHostname(), localPort: hookPort, localNonce: '' }), + execute: async (command) => { + if (!ready) return { requestId: command.requestId, success: false, error: 'Bridge is starting; try again shortly' }; + let result: CommandResponse | undefined; + await createCommandHandler({ + bridgeId, sessionController, loopController, + publishCommandResponse: async (response) => { result = response; }, + })(command); + return result ?? { requestId: command.requestId, success: false, error: 'Command was not handled' }; + }, + onRevoke: () => loopbackServer.disconnectPeers(), + }); + localApiServer.setBrowserAccess(browserAccess); + centrifugo.onLocalPublication((channel, data) => browserAccess?.publish(channel, data)); + console.log(`[Bridge] Local browser access: run ftown-bridge pair${opts.dataDir ? ` --data-dir ${JSON.stringify(dataDir)}` : ''}`); + const handleCommand = createCommandHandler({ bridgeId, sessionController, @@ -890,7 +931,6 @@ program centrifugo.subscribeToSessions(userId); centrifugo.subscribeToLoops(userId); - let ready = false; centrifugo.subscribeToCommands(userId, (command) => { if (!ready) return; handleCommand(command).catch((err) => { @@ -950,4 +990,8 @@ program process.on('SIGTERM', shutdown); }); -program.parse(); +registerLocalBrowserCommands(program); +program.parseAsync().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/bridge/src/local-api-server.ts b/bridge/src/local-api-server.ts index 5476550..6d2bdef 100644 --- a/bridge/src/local-api-server.ts +++ b/bridge/src/local-api-server.ts @@ -1,6 +1,7 @@ +import type { BrowserAccess } from './browser-access.js'; import { createServer } from 'node:http'; import { EventEmitter } from 'node:events'; -import { timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import type { Server, IncomingMessage, ServerResponse } from 'node:http'; @@ -146,9 +147,8 @@ function constantTimeEq(a: string, b: string): boolean { function isLoopbackHost(hostHeader: string | undefined, expectedPort: number): boolean { if (!hostHeader) return false; - const [host, port] = hostHeader.split(':'); - if (port && parseInt(port, 10) !== expectedPort) return false; - return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; + const match = /^(127\.0\.0\.1|localhost|\[::1\])(?::([0-9]{1,5}))?$/.exec(hostHeader); + return !!match && (!match[2] || Number(match[2]) === expectedPort); } function extractBearer(req: IncomingMessage): string | null { @@ -171,6 +171,7 @@ function getQueryInt(url: URL, name: string, defaultValue: number): number { export class LocalApiServer extends EventEmitter { private server: Server | null = null; + private browserAccess: BrowserAccess | null = null; private store: SessionStore | null = null; private runner: ProcessRunner | null = null; private centrifugo: CentrifugoClient | null = null; @@ -184,6 +185,10 @@ export class LocalApiServer extends EventEmitter { private loopController: LoopController | null = null; private sessionController: SessionController | null = null; + setBrowserAccess(access: BrowserAccess): void { + this.browserAccess = access; + } + setAuthToken(token: string): void { this.authToken = token; } @@ -264,17 +269,22 @@ export class LocalApiServer extends EventEmitter { return this.sessionController; } - async start(): Promise { + async start(preferredPort = 0): Promise { return new Promise((resolve, reject) => { const server = createServer((req: IncomingMessage, res: ServerResponse) => { this.handleRequest(req, res); }); - server.on('error', (err: Error) => { - console.error('[LocalApiServer] Server error:', err.message); + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' && preferredPort !== 0 && !this.server) { + preferredPort = 0; + server.listen(0, '127.0.0.1'); + return; + } + reject(err); }); - server.listen(0, '127.0.0.1', () => { + server.once('listening', () => { const address = server.address(); if (!address || typeof address === 'string') { reject(new Error('Failed to get server address')); @@ -285,6 +295,7 @@ export class LocalApiServer extends EventEmitter { console.log(`[LocalApiServer] Listening on port ${address.port}`); resolve(address.port); }); + server.listen(preferredPort, '127.0.0.1'); }); } @@ -298,6 +309,7 @@ export class LocalApiServer extends EventEmitter { } stop(): void { + this.browserAccess?.close(); this.mail.stop(); if (this.server) { this.server.close(); @@ -311,15 +323,31 @@ export class LocalApiServer extends EventEmitter { return; } + let url: URL; + try { url = new URL(req.url ?? '/', `http://${req.headers.host}`); } + catch { jsonResponse(res, 400, { error: 'Invalid request URL' }); return; } + const path = url.pathname; + if (path.startsWith('/api/browser/')) { + if (!this.browserAccess) { + jsonResponse(res, 503, { error: 'Local browser access is starting', code: 'NOT_READY', requestId: randomUUID() }); + return; + } + const presented = extractBearer(req); + const admin = req.headers.origin === undefined && !!this.authToken && !!presented && + constantTimeEq(presented, this.authToken); + void this.browserAccess.handle(req, res, admin).catch(() => { + if (!res.headersSent) jsonResponse(res, 500, { error: 'Internal server error', code: 'INTERNAL_ERROR', requestId: randomUUID() }); + else res.end(); + }); + return; + } + const origin = req.headers.origin; if (typeof origin === 'string' && origin && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin)) { jsonResponse(res, 403, { error: 'Forbidden origin' }); return; } - const url = new URL(req.url ?? '/', `http://${req.headers.host}`); - const path = url.pathname; - if (this.authToken) { const presented = extractBearer(req); if (!presented || !constantTimeEq(presented, this.authToken)) { diff --git a/bridge/src/local-browser-integration.test.ts b/bridge/src/local-browser-integration.test.ts new file mode 100644 index 0000000..0390c0e --- /dev/null +++ b/bridge/src/local-browser-integration.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { request as httpRequest } from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import WebSocket from 'ws'; +import { BrowserAccess } from './browser-access.js'; +import { LocalApiServer } from './local-api-server.js'; +import { LoopbackPeerServer } from './direct-transport/loopback-server.js'; +import { DIRECT_PROTOCOL_VERSION } from './direct-transport/contract.js'; +import { SessionStore } from './session-store.js'; +import { SessionController } from './session-controller.js'; +import { LoopController } from './loop-controller.js'; +import { CentrifugoClient } from './centrifugo-client.js'; +import { createCommandHandler } from './command-rpc.js'; +import { removeFtownSession } from './remove-ftown-session.js'; +import type { ProcessRunner } from './claude-runner.js'; +import type { Command, CommandResponse } from './types.js'; + +const ORIGIN = 'https://ftown.ia.br'; +const BRIDGE = 'integration-bridge'; +const ADMIN = 'integration-admin-token'; +async function harness() { + const dataDir = mkdtempSync(join(tmpdir(), 'local-browser-integration-')); + const store = new SessionStore(dataDir); + const launched: Array<{ id: string; command: string }> = []; + const stopped: string[] = []; + const inputs: Array<[string, string]> = []; + const runner = { + run: (id: string, command: string) => { launched.push({ id, command }); }, + stop: (id: string) => { stopped.push(id); return true; }, + getPreferredRuntime: () => 'pty', + isRunning: (id: string) => launched.some(s => s.id === id) && !stopped.includes(id), + } as unknown as ProcessRunner; + // Real client stays disconnected throughout; never call connect or rely on Fly. + const centrifugo = new CentrifugoClient('ws://127.0.0.1:1/connection/websocket', '', async () => ''); + const localApi = new LocalApiServer(); + localApi.setAuthToken(ADMIN); + localApi.setDependencies(store, runner, centrifugo, 'local-user'); + const port = await localApi.start(); + const factory = { store, runner, centrifugo, userId: 'local-user', bridgeId: BRIDGE, hookPort: port, hookToken: ADMIN, notifyScriptPath: '', wireTerminalInput: () => {} }; + localApi.setSessionFactory(factory); + const sessionController = new SessionController({ store, runner, sessionFactory: factory, + publishSessionUpdate: session => centrifugo.publishSessionUpdate('local-user', session), + removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId: 'local-user' }, id, options), + }); + const loopController = new LoopController({ bridgeId: BRIDGE, scheduler: { kick() {}, onLoopDeleted() {} }, isSessionRunning: id => runner.isRunning(id), publishLoopUpdate: loop => centrifugo.publishLoopUpdate('local-user', loop), publishLoopRemoved: id => centrifugo.publishLoopRemoved('local-user', id), listWireSessions: () => store.listSessions(), loadTerminalLog: id => store.loadTerminalLog(id) }); + let loopback: LoopbackPeerServer; + const access = new BrowserAccess({ dataDir, allowedOrigins: [ORIGIN], + bootstrap: () => ({ version: 1, userId: 'local-user', bridgeId: BRIDGE, hostname: 'integration', localPort: port, localNonce: 'legacy-nonce' }), + execute: async command => { + let response: CommandResponse | undefined; + await createCommandHandler({ bridgeId: BRIDGE, sessionController, loopController, publishCommandResponse: async value => { response = value; } })(command); + if (!response) throw new Error('RPC did not respond'); + return response; + }, + onRevoke: () => loopback.disconnectPeers(), + }); + localApi.setBrowserAccess(access); + const unpublish = centrifugo.onLocalPublication((channel, data) => access.publish(channel, data)); + loopback = new LoopbackPeerServer({ nonce: 'legacy-nonce', allowedOrigins: [ORIGIN], bridgeId: BRIDGE, authorize: (token, origin) => access.authorize(token, origin), onInput: (id, input) => inputs.push([id, input]), onResize() {}, onAttach: () => 'local screen' }); + loopback.attach(localApi.getHttpServer()!); + async function request(path: string, method = 'GET', body?: unknown, token = '', origin = ORIGIN, extraHeaders: Record = {}) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { ...(origin ? { Origin: origin } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...extraHeaders }, body: body === undefined ? undefined : JSON.stringify(body) }); + return { status: response.status, body: await response.json() }; + } + async function pair() { + assert.equal((await request('/api/browser/window', 'POST', {}, ADMIN, '')).status, 200); + const pending = await request('/api/browser/pairings', 'POST', { remember: true }); + assert.equal(pending.status, 201); + const listed = await request('/api/browser/pairings', 'GET', undefined, ADMIN, ''); + assert.equal(listed.body.pairings[0].code, pending.body.code); + assert.equal((await request(`/api/browser/pairings/${pending.body.id}/decision`, 'POST', { approve: true }, ADMIN, '')).status, 200); + const approved = await request(`/api/browser/pairings/${pending.body.id}`, 'GET', undefined, pending.body.pollToken); + return approved.body.credential as string; + } + const rpc = (token: string, type: Command['type'], payload: Command['payload'] = {}) => request('/api/browser/commands', 'POST', { type, payload, requestId: randomUUID() }, token); + return { port, dataDir, store, access, localApi, loopback, inputs, launched, stopped, request, pair, rpc, + close() { unpublish(); access.close(); loopback.closeAll(); localApi.getHttpServer()?.closeAllConnections(); localApi.stop(); rmSync(dataDir, { recursive: true, force: true }); }, + }; +} + +function connect(port: number, nonce: string, origin = ORIGIN): Promise<{ socket?: WebSocket; status?: number }> { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws?nonce=${encodeURIComponent(nonce)}`, { origin }); + socket.once('open', () => resolve({ socket })); + socket.once('error', reject); + socket.once('unexpected-response', (_request, response) => { response.resume(); socket.removeListener('error', reject); socket.on('error', () => {}); socket.terminate(); resolve({ status: response.statusCode }); }); + }); +} + +test('local consent controls real session store/controllers and streams updates while cloud is disconnected', { timeout: 10_000 }, async () => { + const h = await harness(); + try { + const token = await h.pair(); + const bootstrap = await h.request('/api/browser/bootstrap', 'POST', {}, token); + assert.equal(bootstrap.body.bridgeId, BRIDGE); assert.equal(bootstrap.body.localNonce, token); + assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions.length, 0); + const events = h.request('/api/browser/events?cursor=0', 'GET', undefined, token); + const created = await h.rpc(token, 'create_session', { shellType: 'shell', command: 'printf local', name: 'Local integration session', workingDir: h.dataDir, bridgeId: BRIDGE }); + assert.equal(created.status, 200); assert.equal(created.body.success, true); + const id = created.body.data.session.id as string; + assert.equal(h.launched[0].id, id); assert.equal(h.launched[0].command, 'printf local'); + assert.equal((await h.store.loadSession(id))?.name, 'Local integration session'); + const update = await events; + assert.equal(update.body.events[0].channel, 'sessions:updates#local-user'); + assert.equal(update.body.events[0].data.session.id, id); + assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions[0].id, id); + // Same controller/store behavior remains exposed to CLI admin endpoints. + const cliList = await h.request('/api/sessions', 'GET', undefined, ADMIN, ''); + assert.equal(cliList.status, 200); assert.equal(cliList.body.sessions[0].id, id); + const removalEvents = h.request(`/api/browser/events?cursor=${update.body.cursor}`, 'GET', undefined, token); + assert.equal((await h.rpc(token, 'remove_session', { sessionId: id })).body.success, true); + assert.equal(await h.store.loadSession(id), null); assert.ok(h.stopped.includes(id)); + assert.equal((await removalEvents).body.events[0].data.session.status, 'removed'); + assert.equal((await h.rpc(token, 'list_sessions')).body.data.sessions.length, 0); + } finally { h.close(); } +}); + +test('approved terminal upgrades work, revocation closes peers and prevents reconnection', { timeout: 10_000 }, async () => { + const h = await harness(); let socket: WebSocket | undefined; + try { + const token = await h.pair(); + assert.equal((await connect(h.port, 'wrong-token')).status, 403); + assert.equal((await connect(h.port, token, 'https://unrelated.example')).status, 403); + socket = (await connect(h.port, token)).socket; + assert.ok(socket); + const hello = once(socket, 'message'); + socket.send(JSON.stringify({ kind: 'hello', clientId: 'integration-browser', protocolVersion: DIRECT_PROTOCOL_VERSION })); + assert.equal(JSON.parse(String((await hello)[0])).kind, 'hello_ack'); + const screen = once(socket, 'message'); socket.send(JSON.stringify({ kind: 'attach', sessionId: 's' })); + assert.equal(JSON.parse(String((await screen)[0])).kind, 'screen'); + socket.send(JSON.stringify({ kind: 'input', sessionId: 's', data: 'echo test\n' })); + // A later ordered frame response proves the preceding input was processed. + const ordered = once(socket, 'message'); socket.send(JSON.stringify({ kind: 'attach', sessionId: 's' })); await ordered; + assert.deepEqual(h.inputs, [['s', 'echo test\n']]); + const devices = await h.request('/api/browser/devices', 'GET', undefined, ADMIN, ''); + const closed = once(socket, 'close'); + assert.equal((await h.request(`/api/browser/devices/${devices.body.devices[0].id}`, 'DELETE', undefined, ADMIN, '')).status, 200); + await closed; + assert.equal((await connect(h.port, token)).status, 403); + assert.equal((await h.rpc(token, 'list_sessions')).status, 401); + const replacementToken = await h.pair(); + socket = (await connect(h.port, replacementToken)).socket; + assert.ok(socket, 'revocation must preserve the upgrade listener for newly approved browsers'); + const replacementHello = once(socket, 'message'); + socket.send(JSON.stringify({ kind: 'hello', clientId: 'replacement-browser', protocolVersion: DIRECT_PROTOCOL_VERSION })); + assert.equal(JSON.parse(String((await replacementHello)[0])).kind, 'hello_ack'); + } finally { socket?.terminate(); h.close(); } +}); + +test('hosted origins cannot access legacy admin routes and spoofed Host cannot reach browser routes', async () => { + const h = await harness(); + try { + const token = await h.pair(); + assert.equal((await h.request('/api/sessions', 'GET', undefined, ADMIN)).status, 403); + assert.equal((await h.request('/api/sessions', 'POST', { command: 'printf should-not-run', shellType: 'shell' }, token)).status, 403); + assert.equal((await h.request('/api/browser/window', 'POST', {}, ADMIN)).status, 401); + const spoofedStatus = await new Promise((resolve, reject) => { + const req = httpRequest({ hostname: '127.0.0.1', port: h.port, path: '/api/browser/bootstrap', method: 'POST', headers: { Host: `evil.example:${h.port}`, Origin: ORIGIN, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }, response => { response.resume(); resolve(response.statusCode); }); + req.on('error', reject); req.end('{}'); + }); + assert.equal(spoofedStatus, 421); + const malformedStatus = await new Promise((resolve, reject) => { + const req = httpRequest({ hostname: '127.0.0.1', port: h.port, path: '/api/browser/bootstrap', method: 'POST', headers: { Host: `127.0.0.1:${h.port}abc`, Origin: ORIGIN, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }, response => { response.resume(); resolve(response.statusCode); }); + req.on('error', reject); req.end('{}'); + }); + assert.equal(malformedStatus, 421); + assert.equal((await h.request('/api/browser/bootstrap', 'POST', {}, token)).status, 200, 'malformed Host must not crash the listener'); + assert.equal(h.launched.length, 0); + } finally { h.close(); } +}); + +test('local API uses remembered port after restart and falls back when occupied', async () => { + const first = new LocalApiServer(); const second = new LocalApiServer(); const restarted = new LocalApiServer(); + try { + const preferred = await first.start(); + const fallback = await second.start(preferred); + assert.notEqual(fallback, preferred); + const http = first.getHttpServer()!; + const closed = once(http, 'close'); first.stop(); await closed; + assert.equal(await restarted.start(preferred), preferred); + } finally { first.stop(); second.stop(); restarted.stop(); } +}); diff --git a/bridge/src/local-pair-cli.test.ts b/bridge/src/local-pair-cli.test.ts new file mode 100644 index 0000000..f29a3a4 --- /dev/null +++ b/bridge/src/local-pair-cli.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'node:http'; +import { localAdminRequest } from './local-pair-cli.js'; + +test('local admin client uses loopback bearer without Origin and rejects redirects', async (t) => { + let count = 0; + const server = createServer((req, res) => { + count++; + assert.equal(req.headers.origin, undefined); + assert.equal(req.headers.authorization, 'Bearer test-admin-secret'); + if (req.url?.endsWith('/redirect')) { + res.writeHead(302, { Location: '/api/browser/stolen' }); res.end(); return; + } + assert.equal(req.url, '/api/browser/window'); + assert.equal(req.method, 'POST'); + res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const port = (server.address() as { port: number }).port; + assert.deepEqual(await localAdminRequest({ port, token: 'test-admin-secret' }, 'window', 'POST', {}), { ok: true }); + await assert.rejects(localAdminRequest({ port, token: 'test-admin-secret' }, 'redirect')); + assert.equal(count, 2); +}); diff --git a/bridge/src/local-pair-cli.ts b/bridge/src/local-pair-cli.ts new file mode 100644 index 0000000..f60833d --- /dev/null +++ b/bridge/src/local-pair-cli.ts @@ -0,0 +1,85 @@ +import type { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { setTimeout as delay } from 'node:timers/promises'; +import { resolveDefaultDataDir, resolveFtownHome } from './ftown-home.js'; + +type Pointer = { port: number; token: string; apiUrl?: string }; +type Pairing = { id: string; code: string; origin: string; remember: boolean; expiresAt: string }; + +export function readLocalPointer(dataDir?: string): Pointer { + const defaultDir = resolveDefaultDataDir(); + const home = resolveFtownHome(dataDir ? resolve(dataDir) : defaultDir, defaultDir); + let pointer: Pointer; + try { pointer = JSON.parse(readFileSync(join(home, 'bridge.json'), 'utf8')); } + catch { throw new Error('No running bridge found. Start ftown-bridge --local (or your cloud bridge) first.'); } + if (!Number.isInteger(pointer.port) || pointer.port < 1 || pointer.port > 65535 || typeof pointer.token !== 'string' || !pointer.token) { + throw new Error('Invalid bridge pointer. Restart the bridge and try again.'); + } + return pointer; +} + +export async function localAdminRequest(pointer: Pointer, path: string, method = 'GET', body?: unknown): Promise { + const response = await fetch(`http://127.0.0.1:${pointer.port}/api/browser/${path}`, { + method, + headers: { Authorization: `Bearer ${pointer.token}`, 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(5000), + redirect: 'error', + }); + const result = await response.json() as T & { error?: string }; + if (!response.ok) throw new Error(result.error ?? `Local bridge returned ${response.status}`); + return result; +} + +export async function pairLocalBrowser(pointer: Pointer): Promise { + if (!process.stdin.isTTY) throw new Error('Browser approval requires an interactive terminal. Run ftown-bridge pair in your terminal.'); + const window = await localAdminRequest<{ expiresAt: string }>(pointer, 'window', 'POST', {}); + const url = new URL('/local', pointer.apiUrl ?? 'https://ftown.ia.br'); + url.searchParams.set('port', String(pointer.port)); + console.log(`Open ${url.href}`); + console.log(`Local port: ${pointer.port}. Waiting for a browser request (two minutes).`); + const input = createInterface({ input: process.stdin, output: process.stdout }); + try { + while (Date.now() < Date.parse(window.expiresAt)) { + const { pairings } = await localAdminRequest<{ pairings: Pairing[] }>(pointer, 'pairings'); + const pairing = pairings[0]; + if (!pairing) { await delay(500); continue; } + console.log(`\nBrowser origin: ${pairing.origin}\nMatching code: ${pairing.code}`); + console.log(`Access: create and remove sessions, run commands, and control terminals on this computer.`); + console.log(pairing.remember ? 'This browser requested remembered access.' : 'This browser requested access for this session.'); + const remaining = Date.parse(pairing.expiresAt) - Date.now(); + if (remaining <= 0) continue; + let answer: string; + try { + answer = await input.question('Does the code match your browser? Approve [y/N]: ', { signal: AbortSignal.timeout(remaining) }); + } catch { console.log('\nApproval expired. Run ftown-bridge pair again.'); return; } + const approve = /^y(es)?$/i.test(answer.trim()); + await localAdminRequest(pointer, `pairings/${encodeURIComponent(pairing.id)}/decision`, 'POST', { approve }); + console.log(approve ? 'Browser approved. Local access is ready.' : 'Browser request denied.'); + return; + } + console.log('No browser approved before the window expired. Run ftown-bridge pair to try again.'); + } finally { input.close(); } +} + +export function registerLocalBrowserCommands(program: Command): void { + const pointerFor = (command: Command) => readLocalPointer(command.optsWithGlobals().dataDir as string | undefined); + program.command('pair').description('Approve a browser for direct local control (no cloud login)') + .option('--data-dir ', 'Data directory of the running bridge') + .action(async (_opts, command: Command) => { await pairLocalBrowser(pointerFor(command)); }); + program.command('devices').description('List browsers approved for local control') + .option('--data-dir ', 'Data directory of the running bridge') + .action(async (_opts, command: Command) => { + const { devices } = await localAdminRequest<{ devices: { id: string; origin: string; createdAt: string }[] }>(pointerFor(command), 'devices'); + if (!devices.length) console.log('No browsers are approved.'); + for (const device of devices) console.log(`${device.id} ${device.origin} ${device.createdAt}`); + }); + program.command('revoke ').description('Revoke an approved local browser and disconnect local terminals') + .option('--data-dir ', 'Data directory of the running bridge') + .action(async (id: string, _opts, command: Command) => { + await localAdminRequest(pointerFor(command), `devices/${encodeURIComponent(id)}`, 'DELETE'); + console.log('Browser access revoked.'); + }); +} diff --git a/bridge/src/session-wire.test.ts b/bridge/src/session-wire.test.ts index 555406b..05a3a92 100644 --- a/bridge/src/session-wire.test.ts +++ b/bridge/src/session-wire.test.ts @@ -69,6 +69,7 @@ describe('toWireSession', () => { }); interface PublishCapture { + state: 'connected'; publish(channel: string, data: Record): Promise; } @@ -78,6 +79,7 @@ describe('CentrifugoClient.publishSessionUpdate — token never crosses the wire const captured: { channel?: string; data?: Record } = {}; (client as unknown as { client: PublishCapture }).client = { + state: 'connected', publish: async (channel: string, data: Record) => { captured.channel = channel; captured.data = data; diff --git a/docs/design/local-browser-control.md b/docs/design/local-browser-control.md new file mode 100644 index 0000000..4ae501d --- /dev/null +++ b/docs/design/local-browser-control.md @@ -0,0 +1,109 @@ +# Local browser approval and management — implementation contract + +## Module map / API style inventory +Existing LocalApiServer binds ephemeral 127.0.0.1, validates Host and bearer admin token, +uses plural /api/sessions resources, UUID IDs, camelCase properties, raw named envelopes +({sessions}, {session}), {error: string} HTTP errors, no version prefix. Existing command-rpc +uses Command {type,payload,requestId}, CommandResponse {requestId,success,data?,error?}; +session/loop controllers own all mutations. Existing direct loopback terminal uses port/nonce. +UI Dashboard/hooks consume subscription-style events and command responses. + +New browser API uses /api/browser/*, existing envelopes with additive error code/requestId. +Local admin routes (no Origin + existing process admin bearer) open a 2 minute pairing window +and approve/revoke; browser routes require exact allowed Origin and per-device bearer tokens. +Unpaired request/poll is the sole public exception during a CLI-opened window. No wildcard CORS. +Credentials are bound to exact Origin and bridge. Persist only hashes for remembered devices; +session devices stay memory-only. UI credentials go only to 127.0.0.1, never Vercel APIs. + +## Frozen API and module seam +Parent owns LocalApiServer wiring, index.ts and loopback-server auth wiring. +Backend module: bridge/src/browser-access.ts exports class BrowserAccess. +Constructor opts: { dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap; + execute: (command: Command) => Promise; onRevoke?: () => void }. +Export BrowserBootstrap = { version: 1; userId: string; bridgeId: string; hostname: string; + localPort: number; localNonce: string } (module overrides localNonce with presented browser token). +Methods: +- handle(req: IncomingMessage, res: ServerResponse, admin: boolean): Promise + handles ONLY /api/browser/* (parent enforces loopback Host; admin=true ONLY valid existing + admin bearer with absent Origin). Module owns browser CORS/preflight, parsing and route auth. +- authorize(token: string, origin: string): boolean (used for terminal websocket auth) +- publish(channel: string, data: unknown): void (bounded local event history, live publication) +- close(): void (cancel timers/longpoll, clear ephemeral pairing state). +Backend owns browser-access.ts and browser-access.test.ts only. New local-pair-cli.ts owned +by parent, uses endpoints below; no other backend files owned by backend worker. + +Admin operations, all require admin=true, never browser-accessible: +POST /api/browser/window {} -> { expiresAt: ISO }; replaces pending window. +GET /api/browser/pairings -> { pairings: [{id,code,origin,remember,expiresAt}] }; max 5 pending. +POST /api/browser/pairings/:id/decision {approve:boolean} -> {ok:true}; repeat same decision safe. +GET /api/browser/devices -> {devices:[{id,origin,createdAt}]}; cap 100 remembered devices. +DELETE /api/browser/devices/:id -> {ok:true}; idempotent, invokes onRevoke. + +Browser operations require exact allowlisted Origin; OPTIONS permits only GET,POST,DELETE +and Authorization,Content-Type; no cookies; private-network preflight supported when requested. +POST /api/browser/pairings {remember:boolean} -> 201 {id,code,pollToken,expiresAt}; +window must be active. Code random 6 digits, pollToken random 32 bytes, max 5 outstanding; +rate-limit unpaired requests 10/minute per origin; 2 minute expiry. No command access yet. +GET /api/browser/pairings/:id Authorization: Bearer pollToken -> +{status:'pending'|'denied'|'approved', credential?:string}; approved delivers random 32-byte +credential to this polling token only, repeat polls safe until expiry. Origin must match. +POST /api/browser/bootstrap {} bearer device credential -> BrowserBootstrap; +localNonce is that credential (terminal auth validates through authorize). +POST /api/browser/commands Command -> CommandResponse; validate shape, same bridge only; +requestId idempotency scoped device+id, duplicate same body shares result; different body 409. +Cache max 1000 results /10 min; do not evict pending operations. Overflow 429 before execution. +Never retry commands automatically across cloud/local routes after uncertain delivery. +GET /api/browser/events?cursor=N bearer credential -> +{cursor:number,reset:boolean,events:[{channel:string,data:unknown}]}; max256 events/2MiB history, +longpoll up to20s, reset=true when cursor fell behind. Per-device max2 pending polls. +Data is existing typed publication payload; generic unknown because multiple existing channel +contracts share this transport. Consumer refreshes authoritative session/loop snapshots on reset. +Errors use {error:string,code:string,requestId:string}, 400 invalid,401 unauthorized, +403 forbidden,404 not found,409 conflict/window closed,429 capped,503 not ready. +No new version machinery: additive fields ignored; incompatible semantics require new routes. + +UI implementation: existing Dashboard is reused in /local without NextAuth. Entry points on +login and hosted dashboard link to /local. Ask for port printed by ftown-bridge pair; no scanning. +Use http://127.0.0.1: only. Pairing polls, matching code, approval pending message, +remember-browser default true, session-only option, forget button. Store credential keyed port +and use only after authenticated bootstrap returns same bridge id. Restore on reload independent +of cloud. Local adapter exposes existing subscription/RPC semantics to shared hooks, backed by +commands HTTP and events longpoll. Presence is current local bootstrap bridge+local advert. +Use existing HybridTerminalTransport with loopback terminal. No fake cloud login/JWT. +Connection indicator must identify local mode accurately; do not probe Fly in local mode. +Existing cloud dashboard should offer a paired-local entry even while cloud presence is empty. +Full local management includes session list/create/remove/stop/retry/rename and loop commands +through existing Command types. Correct results/events must reach existing hooks. + +## Ownership and sequencing +T1 Backend auth/API: browser-access.ts, browser-access.test.ts. +T2 UI: ui/src/lib/local-browser-client.ts, its tests; ui/src/app/local/page.tsx; +ui/src/components/LocalDashboard.tsx; UI wiring/hooks/type interfaces necessary to reuse Dashboard. +T3 Parent integration: bridge/src/index.ts, local-api-server.ts, centrifugo-client.ts, +direct-transport/loopback-server.ts, local-pair-cli.ts plus tests, docs. +T1/T2 needs shared contract (this document frozen); independent after freeze. +T1/T2 before T3 runtime smoke test. Graph acyclic. Disjoint ownership (T2 ui only,T1 exact files). +T4 independent non-author security review after implementation; read-only review task. + +## Walkthrough / acceptance +CLI opens window -> UI requests pairing -> both show matching code -> CLI approval -> +UI polls credential -> bootstrap -> subscribe events -> list/create/remove -> terminal IO. +Reject/expiry/revocation disallow all access. Unrelated origins cannot pair, poll, or control. +Fly unreachable must not block local controller mutation completion or live updates. +Bridge --local starts without cloud onboarding (parent implementation), shares data/controllers. +Test actual HTTP pairing gate, one-shot consent, remembered reload/revocation, request dedup; +assembled smoke creates/removes a shell session while cloud is unavailable. No deployment. + +## Integration verification + +- Bridge suite: 796 tests pass; production TypeScript build passes. +- UI suite: 267 tests pass; production Next build, lint and types pass using + dummy build-only authentication secrets (no production credentials needed). +- Assembled local integration uses real HTTP, WebSocket, approval, RPC, + controllers, SessionStore and disconnected publication transport; only process + execution is replaced by a runner fake. It covers session create/list/remove, + event delivery, terminal input, revocation, subsequent new-browser reconnect, + hostile origins/Host headers and local port reuse/fallback. +- Independent non-author backend and UI review completed. Fixed early local RPC + timeout and bootstrap refresh after a bridge changes identity mode on restart. +- No live bridge restart, deployment or PWA caching is part of this validation. diff --git a/docs/local-browser.md b/docs/local-browser.md new file mode 100644 index 0000000..ab9b63c --- /dev/null +++ b/docs/local-browser.md @@ -0,0 +1,67 @@ +# Direct local browser control + +Choose **This computer** on the hosted login page or cloud dashboard to manage a +bridge on the same computer without cloud login. Session and loop operations use +the same bridge controllers as cloud mode; terminals connect over loopback. + +## Start and approve + +For a bridge that never contacts the cloud: + +```sh +ftown-bridge --local +``` + +An ordinary cloud-connected bridge also exposes local approval. In a second +terminal, run: + +```sh +ftown-bridge pair +``` + +Open the `/local?port=...` link printed by the command, click **Connect locally**, +and compare its six-digit code with the terminal. Approve only the matching +request. Approval grants command execution and terminal/session control on that +computer. A browser reaching the port alone does not authorize it. + +For a bridge with a custom data directory, pass the same `--data-dir` to `pair`, +`devices`, and `revoke`. The allowed browser origin is the bridge's `--api-url` +(default `https://ftown.ia.br`); for development, start with +`--api-url http://localhost:3000` and use that exact origin. + +**Remember this browser** stores the credential in that origin's local storage. +Unchecked, it uses tab session storage and the bridge keeps its credential only +until the bridge exits. Credentials are bound to the bridge identity and exact +browser origin; only hashes are persisted on the bridge. + +```sh +ftown-bridge devices +ftown-bridge revoke +``` + +Revocation removes that credential and disconnects existing loopback terminals. +Other approved browsers may reconnect. **Forget saved access** removes the +browser's saved credential; use `revoke` to invalidate it on the bridge too. + +## Outages and restarts + +While the local bridge runs, the local dashboard supports creating, removing, +renaming, stopping, and retrying sessions, managing loops, and using terminals +without Fly. The cloud dashboard's **This computer** link switches to this local +route. It does not automatically replay a cloud command whose delivery is +uncertain, or switch the dashboard back to cloud mode. + +The local dashboard retries its event connection and terminal connection after a +local transport interruption. A missed event history triggers a fresh snapshot. +Mutation requests are not automatically retried; the bridge deduplicates repeated +request IDs for ten minutes after completion (up to 1,000 retained commands). + +The bridge reuses its saved loopback port on restart. If another process occupies +that port, it selects a new one; run `pair` and use the new link. A changed bridge +identity or revoked credential requires approval again. + +The hosted UI still needs to load initially from its host. This change does not +add a PWA or offline UI cache. Browsers may request local-network permission; +that permission and local bridge approval are both necessary. `--local` binds +only to loopback, so it controls this computer, not another LAN machine. Existing +`--solo` remains the bundled LAN panel/hub deployment described in [solo.md](solo.md). diff --git a/ui/src/app/local/page.tsx b/ui/src/app/local/page.tsx index 3f78d45..923437c 100644 --- a/ui/src/app/local/page.tsx +++ b/ui/src/app/local/page.tsx @@ -1,167 +1,5 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { DashboardClient } from "@/components/DashboardClient"; -import { KeyEntry } from "@/components/local/KeyEntry"; -import { StartingState } from "@/components/local/StartingState"; -import { - SoloAuthError, - bootstrap, - captureKeyFromHash, - clearKey, - getHealth, - getStoredKey, - mintToken, - storeKey, -} from "@/lib/solo-client"; - -/** - * Solo first-run experience (bridge contract: ui/src/app/local/page.tsx). - * - * mount → consume #k= fragment (or stored key) → GET /api/solo/bootstrap - * ├─ no key → KeyEntry (inline 401 validation) - * ├─ key accepted → DashboardClient wired to solo token refresh - * └─ children booting → StartingState polling /healthz every 2s - */ - -type Phase = "connecting" | "needs-key" | "starting" | "ready"; - -interface BootstrapInfo { - userId: string; - centrifugoUrl: string; - token: string; -} - -const HEALTH_POLL_INTERVAL_MS = 2000; - +import { LocalDashboard } from "@/components/LocalDashboard"; +import SoloLocalPage from "@/components/local/SoloLocalPage"; export default function LocalPage() { - const [phase, setPhase] = useState("connecting"); - const [boot, setBoot] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [keyError, setKeyError] = useState(null); - const [startDetail, setStartDetail] = useState(null); - const keyRef = useRef(null); - - const runBootstrap = useCallback(async (key: string): Promise => { - try { - const result = await bootstrap(key); - setBoot(result); - setStartDetail(null); - setPhase("ready"); - } catch (err) { - if (err instanceof SoloAuthError) { - // Rejected cached or mistyped key: forget it and re-prompt. - clearKey(); - keyRef.current = null; - setKeyError("That key was rejected. Copy it again from the ftown-bridge --solo banner."); - setPhase("needs-key"); - } else { - // Network failure or 502 — the hub/panel children may still be - // booting; let the healthz poller drive the retry. - setPhase("starting"); - } - } - }, []); - - useEffect(() => { - const captured = captureKeyFromHash(); - const key = captured ?? getStoredKey(); - if (!key) { - setPhase("needs-key"); - return; - } - keyRef.current = key; - void runBootstrap(key); - }, [runBootstrap]); - - const handleSubmit = useCallback( - (key: string) => { - storeKey(key); - keyRef.current = key; - setSubmitting(true); - setKeyError(null); - void runBootstrap(key).finally(() => setSubmitting(false)); - }, - [runBootstrap] - ); - - useEffect(() => { - if (phase !== "starting") return undefined; - let disposed = false; - let triggered = false; - - const tick = async (): Promise => { - try { - const health = await getHealth(); - if (disposed || triggered) return; - if (health.hub === "down") { - setStartDetail("Waiting for the realtime hub…"); - return; - } - if (health.panel === "down") { - setStartDetail("Waiting for the web panel…"); - return; - } - triggered = true; - const key = keyRef.current; - if (!key) { - setPhase("needs-key"); - return; - } - await runBootstrap(key); - } catch { - if (!disposed && !triggered) setStartDetail("Reaching ftown Solo…"); - } - }; - - void tick(); - const interval = window.setInterval(() => void tick(), HEALTH_POLL_INTERVAL_MS); - return () => { - disposed = true; - window.clearInterval(interval); - }; - }, [phase, runBootstrap]); - - const refreshHubToken = useCallback((): Promise => { - const key = keyRef.current; - if (!key) return Promise.reject(new Error("Solo access key is not available.")); - return mintToken(key).then((minted) => minted.token); - }, []); - - const handleUnauthorized = useCallback(() => { - // The bridge rejected the stored key mid-session: wipe it and fall back - // to the entry form. Unmounting DashboardClient disconnects the socket. - clearKey(); - keyRef.current = null; - setBoot(null); - setKeyError("Your access key stopped working. Enter it again to reconnect."); - setPhase("needs-key"); - }, []); - - if (phase === "ready" && boot) { - return ( - - ); - } - - return ( -
- {phase === "connecting" && ( -

- Connecting to ftown Solo… -

- )} - {phase === "needs-key" && ( - - )} - {phase === "starting" && } -
- ); + return process.env.NEXT_PUBLIC_SOLO === "1" ? : ; } diff --git a/ui/src/app/login/page.tsx b/ui/src/app/login/page.tsx index 24653af..1969b49 100644 --- a/ui/src/app/login/page.tsx +++ b/ui/src/app/login/page.tsx @@ -50,6 +50,8 @@ export default function LoginPage() { Sign in to your account

+ This computer — no account needed +
)} @@ -1009,7 +1014,7 @@ PY`; {/* ── CLI Token bar ── */} - {showToken && ( + {showToken && !localMode && (
({ Dashboard: (props: { localMode: boolean; onDisconnect: () => void }) => + React.createElement('div', null, 'Local dashboard ' + String(props.localMode), React.createElement('button', { onClick: props.onDisconnect }, 'Forget')) })); +vi.mock('@/lib/direct-transport/hybrid-terminal-transport', () => ({ + HybridTerminalTransport: class { dispose() {} }, +})); +const credential = 'local-secret'; +const boot = { version: 1, userId: 'local', bridgeId: 'bridge', hostname: 'laptop', localPort: 40124, localNonce: credential }; +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +beforeEach(() => { localStorage.clear(); sessionStorage.clear(); window.history.replaceState(null, '', '/local?port=40123'); }); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('shows matching approval code then opens the shared dashboard with session-only credentials', async () => { + let approved = false; + const fetcher = vi.fn().mockImplementation((url: string) => { + if (url.endsWith('/pairings')) return Promise.resolve(json({ id: 'pair', code: '482193', pollToken: 'poll', expiresAt: new Date(Date.now() + 120000).toISOString() })); + if (url.endsWith('/pairings/pair')) return Promise.resolve(json(approved ? { status: 'approved', credential } : { status: 'pending' })); + if (url.endsWith('/bootstrap')) return Promise.resolve(json(boot)); + return new Promise(() => {}); + }); + vi.stubGlobal('fetch', fetcher); + render(React.createElement(LocalDashboard)); + fireEvent.click(screen.getByLabelText('Remember this browser')); + fireEvent.click(screen.getByRole('button', { name: 'Connect locally' })); + await screen.findByText('482193'); + expect(screen.queryByText('Local dashboard true')).toBeNull(); + approved = true; + await screen.findByText('Local dashboard true', {}, { timeout: 3000 }); + expect(localStorage.getItem('ftown:local:device:40123')).toBeNull(); + expect(readDevice()).toEqual({ port: 40123, bridgeId: 'bridge', credential }); + expect(fetcher.mock.calls.every(([url]) => String(url).startsWith('http://127.0.0.1:40123/api/browser/'))).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Forget' })); + expect(readDevice()).toBeNull(); +}); + +it('restores remembered approval without any login or pairing call', async () => { + rememberDevice({ port: 40123, bridgeId: 'bridge', credential }, true); + const fetcher = vi.fn().mockImplementation((url: string) => url.endsWith('/bootstrap') + ? Promise.resolve(json(boot)) : new Promise(() => {})); + vi.stubGlobal('fetch', fetcher); + render(React.createElement(LocalDashboard)); + await screen.findByText('Local dashboard true'); + expect(fetcher.mock.calls.every(([url]) => String(url).startsWith('http://127.0.0.1:40123/api/browser/'))).toBe(true); + expect(fetcher.mock.calls.some(([url]) => String(url).includes('pairing'))).toBe(false); +}); + +it('forgets rejected saved credentials', async () => { + rememberDevice({ port: 40123, bridgeId: 'bridge', credential }, true); + const fetcher = vi.fn().mockResolvedValue(json({ error: 'revoked' }, 401)); + vi.stubGlobal('fetch', fetcher); + render(React.createElement(LocalDashboard)); + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('revoked')); + expect(readDevice()).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0][0]).toBe('http://127.0.0.1:40123/api/browser/bootstrap'); +}); + +it('keeps remembered credentials on a temporary connection failure so retry does not require approval', async () => { + rememberDevice({ port: 40123, bridgeId: 'bridge', credential }, true); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Bridge unreachable'))); + render(React.createElement(LocalDashboard)); + await screen.findByText('Bridge unreachable'); + expect(readDevice()?.bridgeId).toBe('bridge'); +}); diff --git a/ui/src/components/LocalDashboard.tsx b/ui/src/components/LocalDashboard.tsx new file mode 100644 index 0000000..573d687 --- /dev/null +++ b/ui/src/components/LocalDashboard.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Dashboard } from './Dashboard'; +import { HybridTerminalTransport } from '@/lib/direct-transport/hybrid-terminal-transport'; +import { LocalAccessError, LocalBrowserClient, bootstrapLocal, forgetDevice, localOrigin, + localRequest, readDevice, rememberDevice, type LocalDevice, type Pairing } from '@/lib/local-browser-client'; + +type Runtime = { client: LocalBrowserClient; transport: HybridTerminalTransport; device: LocalDevice }; + +export function LocalDashboard() { + const [port, setPort] = useState(''); + const [remember, setRemember] = useState(true); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [runtime, setRuntime] = useState(null); + const [connected, setConnected] = useState(true); + const active = useRef(null); + const attempt = useRef(null); + const cached = useRef(null); + + const disconnect = useCallback(() => { + attempt.current?.abort(); + if (cached.current) forgetDevice(cached.current.port); + cached.current = null; + active.current?.transport.dispose(); active.current?.client.close(); active.current = null; + setRuntime(null); setPending(null); setBusy(false); + }, []); + + const connect = useCallback(async (device: LocalDevice, signal: AbortSignal, save?: boolean): Promise => { + const boot = await bootstrapLocal(device, signal); + if (signal.aborted) return; + const verified = { ...device, bridgeId: boot.bridgeId }; + if (save !== undefined) rememberDevice(verified, save); + cached.current = verified; + const client = new LocalBrowserClient(verified, boot, (ok, message) => { + setConnected(ok); setError(message ?? null); + }, () => { disconnect(); setError('Local access was revoked or expired. Run ftown-bridge pair to approve this browser again.'); }, () => { + // A restarted bridge may change its terminal port or cloud identity. + // Rebuild subscriptions and transport using authenticated bootstrap only. + active.current?.transport.dispose(); active.current = null; setRuntime(null); + setBusy(true); + const controller = new AbortController(); attempt.current = controller; + void connect(verified, controller.signal).catch((err) => { + if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Local reconnect failed'); + }).finally(() => { if (!controller.signal.aborted) setBusy(false); }); + }); + const transport = new HybridTerminalTransport({ + centrifuge: client, userId: boot.userId, clientId: crypto.randomUUID(), localOnly: true, + publishCommand: () => {}, + getLocalAdvert: async (id) => id === boot.bridgeId ? { localPort: boot.localPort, localNonce: boot.localNonce } : null, + upgradeBackoffMs: [2000, 5000, 10000], + }); + active.current?.transport.dispose(); active.current?.client.close(); + active.current = { client, transport, device: verified }; + setConnected(true); setRuntime(active.current); setPending(null); setError(null); + client.start(); + }, [disconnect]); + + useEffect(() => { + const requested = new URLSearchParams(window.location.search).get('port'); + let requestedPort: number | null = null; + if (requested) { + try { localOrigin(requested); requestedPort = Number(requested); setPort(requested); } + catch { setError('Invalid local port in this link.'); } + } + const stored = readDevice(); + const device = stored && (requestedPort === null || requestedPort === stored.port) ? stored : null; + cached.current = device; + if (device) { + setPort(String(device.port)); setBusy(true); + const controller = new AbortController(); attempt.current = controller; + void connect(device, controller.signal).catch((err) => { + if (controller.signal.aborted) return; + if (err instanceof LocalAccessError && (err.status === 401 || err.status === 403)) { + forgetDevice(device.port); cached.current = null; + } + setError(err instanceof Error ? err.message : 'Unable to reach this computer.'); + }).finally(() => { if (!controller.signal.aborted) setBusy(false); }); + } + return () => { attempt.current?.abort(); active.current?.transport.dispose(); active.current?.client.close(); active.current = null; }; + }, [connect]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + attempt.current?.abort(); + const controller = new AbortController(); attempt.current = controller; + setError(null); setBusy(true); setPending(null); + try { + localOrigin(port); + const saved = cached.current; + if (saved?.port === Number(port)) { + await connect(saved, controller.signal); + return; + } + const pair = await localRequest(Number(port), '/api/browser/pairings', undefined, { remember }, controller.signal); + if (controller.signal.aborted) return; + setPending(pair); + while (!controller.signal.aborted && Date.now() < Date.parse(pair.expiresAt)) { + const decision = await localRequest<{ status: string; credential?: string }>(Number(port), + `/api/browser/pairings/${encodeURIComponent(pair.id)}`, pair.pollToken, undefined, controller.signal); + if (decision.status === 'denied') throw new Error('Request declined in the bridge terminal.'); + if (decision.status === 'approved' && decision.credential) { + await connect({ port: Number(port), bridgeId: '', credential: decision.credential }, controller.signal, remember); + return; + } + await new Promise((resolve) => { + const finish = () => { clearTimeout(timer); controller.signal.removeEventListener('abort', finish); resolve(); }; + const timer = setTimeout(finish, 1000); controller.signal.addEventListener('abort', finish, { once: true }); + }); + } + if (!controller.signal.aborted) throw new Error('Approval expired. Run ftown-bridge pair again.'); + } catch (err) { + if (!controller.signal.aborted) { + if (err instanceof LocalAccessError && (err.status === 401 || err.status === 403) && cached.current) { + forgetDevice(cached.current.port); cached.current = null; + } + setError(err instanceof Error ? err.message : 'Unable to connect locally.'); + setPending(null); + } + } finally { if (!controller.signal.aborted) setBusy(false); } + } + + if (runtime) return ; + + return
+
+

This computer

+

Connect directly to your local bridge. No cloud account needed.

+

Run ftown-bridge pair in a terminal, then enter the port it prints.

+ + + +

Unchecked: access lasts for this bridge process and browser tab.

+ {pending &&
+

Match this code in your terminal:

{pending.code}

+

Approve there to grant terminal and session control. Waiting for local approval…

+
} + {error &&

{error}

} + + + {(busy || cached.current) && } + Use Cloud instead +
+
; +} diff --git a/ui/src/components/local/SoloLocalPage.tsx b/ui/src/components/local/SoloLocalPage.tsx new file mode 100644 index 0000000..ad8ba05 --- /dev/null +++ b/ui/src/components/local/SoloLocalPage.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { DashboardClient } from "@/components/DashboardClient"; +import { KeyEntry } from "@/components/local/KeyEntry"; +import { StartingState } from "@/components/local/StartingState"; +import { + SoloAuthError, + bootstrap, + captureKeyFromHash, + clearKey, + getHealth, + getStoredKey, + mintToken, + storeKey, +} from "@/lib/solo-client"; + +/** + * Solo first-run experience (bridge contract: ui/src/app/local/page.tsx). + * + * mount → consume #k= fragment (or stored key) → GET /api/solo/bootstrap + * ├─ no key → KeyEntry (inline 401 validation) + * ├─ key accepted → DashboardClient wired to solo token refresh + * └─ children booting → StartingState polling /healthz every 2s + */ + +type Phase = "connecting" | "needs-key" | "starting" | "ready"; + +interface BootstrapInfo { + userId: string; + centrifugoUrl: string; + token: string; +} + +const HEALTH_POLL_INTERVAL_MS = 2000; + +export default function SoloLocalPage() { + const [phase, setPhase] = useState("connecting"); + const [boot, setBoot] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [keyError, setKeyError] = useState(null); + const [startDetail, setStartDetail] = useState(null); + const keyRef = useRef(null); + + const runBootstrap = useCallback(async (key: string): Promise => { + try { + const result = await bootstrap(key); + setBoot(result); + setStartDetail(null); + setPhase("ready"); + } catch (err) { + if (err instanceof SoloAuthError) { + // Rejected cached or mistyped key: forget it and re-prompt. + clearKey(); + keyRef.current = null; + setKeyError("That key was rejected. Copy it again from the ftown-bridge --solo banner."); + setPhase("needs-key"); + } else { + // Network failure or 502 — the hub/panel children may still be + // booting; let the healthz poller drive the retry. + setPhase("starting"); + } + } + }, []); + + useEffect(() => { + const captured = captureKeyFromHash(); + const key = captured ?? getStoredKey(); + if (!key) { + setPhase("needs-key"); + return; + } + keyRef.current = key; + void runBootstrap(key); + }, [runBootstrap]); + + const handleSubmit = useCallback( + (key: string) => { + storeKey(key); + keyRef.current = key; + setSubmitting(true); + setKeyError(null); + void runBootstrap(key).finally(() => setSubmitting(false)); + }, + [runBootstrap] + ); + + useEffect(() => { + if (phase !== "starting") return undefined; + let disposed = false; + let triggered = false; + + const tick = async (): Promise => { + try { + const health = await getHealth(); + if (disposed || triggered) return; + if (health.hub === "down") { + setStartDetail("Waiting for the realtime hub…"); + return; + } + if (health.panel === "down") { + setStartDetail("Waiting for the web panel…"); + return; + } + triggered = true; + const key = keyRef.current; + if (!key) { + setPhase("needs-key"); + return; + } + await runBootstrap(key); + } catch { + if (!disposed && !triggered) setStartDetail("Reaching ftown Solo…"); + } + }; + + void tick(); + const interval = window.setInterval(() => void tick(), HEALTH_POLL_INTERVAL_MS); + return () => { + disposed = true; + window.clearInterval(interval); + }; + }, [phase, runBootstrap]); + + const refreshHubToken = useCallback((): Promise => { + const key = keyRef.current; + if (!key) return Promise.reject(new Error("Solo access key is not available.")); + return mintToken(key).then((minted) => minted.token); + }, []); + + const handleUnauthorized = useCallback(() => { + // The bridge rejected the stored key mid-session: wipe it and fall back + // to the entry form. Unmounting DashboardClient disconnects the socket. + clearKey(); + keyRef.current = null; + setBoot(null); + setKeyError("Your access key stopped working. Enter it again to reconnect."); + setPhase("needs-key"); + }, []); + + if (phase === "ready" && boot) { + return ( + + ); + } + + return ( +
+ {phase === "connecting" && ( +

+ Connecting to ftown Solo… +

+ )} + {phase === "needs-key" && ( + + )} + {phase === "starting" && } +
+ ); +} diff --git a/ui/src/hooks/useAllSessionEvents.ts b/ui/src/hooks/useAllSessionEvents.ts index c8eaf40..d265d89 100644 --- a/ui/src/hooks/useAllSessionEvents.ts +++ b/ui/src/hooks/useAllSessionEvents.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { Centrifuge, Subscription } from "centrifuge"; +import type { MessageClient, MessageSubscription } from "@/lib/message-client"; import { Session } from "@/types"; import { clearsManualInputNotice, hookEventToActivity, extractManualInputNotice, extractToolLabel, type ManualInputNotice } from "@/lib/hook-events"; import { TokenUsage } from "./useSessionEvents"; @@ -32,12 +32,12 @@ export interface AllSessionEvents { } export function useAllSessionEvents( - client: Centrifuge | null, + client: MessageClient | null, sessions: Session[], userId: string ): AllSessionEvents { const [activityMap, setActivityMap] = useState>(new Map()); - const subsRef = useRef>(new Map()); + const subsRef = useRef>(new Map()); const clientRef = useRef(client); const userIdRef = useRef(userId); clientRef.current = client; diff --git a/ui/src/hooks/useBridgeRpc.ts b/ui/src/hooks/useBridgeRpc.ts index 6763b7d..b0b940e 100644 --- a/ui/src/hooks/useBridgeRpc.ts +++ b/ui/src/hooks/useBridgeRpc.ts @@ -40,6 +40,7 @@ export interface BridgeRpcSubscriptionLike { /** Minimal structural view of the Centrifuge client — only what this hook uses. */ export interface CentrifugoClientLike { + readonly commandTimeoutMs?: number; newSubscription(channel: string): BridgeRpcSubscriptionLike; getSubscription(channel: string): BridgeRpcSubscriptionLike | null; } @@ -64,7 +65,7 @@ export interface RpcCore { bridgeExec(command: string, workingDir: string, bridgeId: string): Promise; } -export function createRpcCore(publish: (command: Command) => void): RpcCore { +export function createRpcCore(publish: (command: Command) => void, timeoutMs = RPC_TIMEOUT_MS): RpcCore { // First-response callbacks: resolve once, then self-delete. const pendingCallbacks = new Map void>(); // Broadcast-collect callbacks: unlike pendingCallbacks (which resolves on @@ -92,7 +93,7 @@ export function createRpcCore(publish: (command: Command) => void): RpcCore { const timeout = setTimeout(() => { pendingCallbacks.delete(command.requestId); reject(new Error(`${command.type} timed out`)); - }, RPC_TIMEOUT_MS); + }, timeoutMs); pendingCallbacks.set(command.requestId, (resp) => { clearTimeout(timeout); @@ -130,7 +131,7 @@ export function createRpcCore(publish: (command: Command) => void): RpcCore { const timeout = setTimeout(() => { pendingCallbacks.delete(requestId); reject(new Error("bridge_exec timed out")); - }, RPC_TIMEOUT_MS); + }, timeoutMs); pendingCallbacks.set(requestId, (resp) => { clearTimeout(timeout); @@ -190,7 +191,7 @@ export function useBridgeRpc(client: CentrifugoClientLike | null, userId: string if (coreRef.current === null) { coreRef.current = createRpcCore((command) => { commandsSubRef.current?.publish(command); - }); + }, client?.commandTimeoutMs); } const core = coreRef.current; diff --git a/ui/src/hooks/useBridges.ts b/ui/src/hooks/useBridges.ts index d1ccf24..195fb25 100644 --- a/ui/src/hooks/useBridges.ts +++ b/ui/src/hooks/useBridges.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef, useCallback } from "react"; -import { Centrifuge, Subscription } from "centrifuge"; +import type { MessageClient, MessageSubscription } from "@/lib/message-client"; export interface BridgeInfo { clientId: string; @@ -81,16 +81,16 @@ export function applyBridgeLeave(allClients: BridgeInfo[], clientId: string): Br return allClients.filter((b) => b.clientId !== clientId); } -export function useBridges(client: Centrifuge | null, userId: string | null): UseBridgesResult { +export function useBridges(client: MessageClient | null, userId: string | null): UseBridgesResult { const [bridges, setBridges] = useState([]); - const subRef = useRef(null); + const subRef = useRef(null); // Every known client per bridgeId (not deduped) — the source of truth fed // to dedupeBridges to produce the exposed `bridges` list. Kept in a ref // (not state) since it's an internal accumulator; only the derived, // deduped projection needs to trigger a re-render. const allClientsRef = useRef([]); - const fetchPresence = useCallback(async (sub: Subscription) => { + const fetchPresence = useCallback(async (sub: MessageSubscription) => { try { const result = await sub.presence(); console.log("[bridges] presence result:", JSON.stringify(result.clients, null, 2)); diff --git a/ui/src/hooks/useLoops.ts b/ui/src/hooks/useLoops.ts index 22bb0a4..7321bc7 100644 --- a/ui/src/hooks/useLoops.ts +++ b/ui/src/hooks/useLoops.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Centrifuge, Subscription } from "centrifuge"; +import type { MessageClient, MessageSubscription } from "@/lib/message-client"; import { v4 as uuidv4 } from "uuid"; import { Loop, @@ -56,12 +56,12 @@ interface UseLoopsResult { * newSubscription(channel) call). */ export function useLoops( - client: Centrifuge | null, + client: MessageClient | null, userId: string | null, rpc: Pick ): UseLoopsResult { const [loops, setLoops] = useState([]); - const loopsSubRef = useRef(null); + const loopsSubRef = useRef(null); const { sendCommand, sendCommandCollect } = rpc; // Secondary/CLI-parity path (§2c): loop state is delivered push-first over diff --git a/ui/src/hooks/useSessions.ts b/ui/src/hooks/useSessions.ts index 7e22982..6631023 100644 --- a/ui/src/hooks/useSessions.ts +++ b/ui/src/hooks/useSessions.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Centrifuge, Subscription } from "centrifuge"; +import type { MessageClient, MessageSubscription } from "@/lib/message-client"; import { v4 as uuidv4 } from "uuid"; import { Session, @@ -114,12 +114,12 @@ interface UseSessionsResult { * shared `commands:rpc#{userId}` channel — this hook never subscribes to it. */ export function useSessions( - client: Centrifuge | null, + client: MessageClient | null, userId: string | null, rpc: BridgeRpc ): UseSessionsResult { const [sessions, setSessions] = useState([]); - const sessionsSubRef = useRef(null); + const sessionsSubRef = useRef(null); const sessionsRef = useRef([]); const usageRequestsRef = useRef>(new Set()); const usageGenerationRef = useRef(0); @@ -200,7 +200,10 @@ export function useSessions( const responseData = response.data as { sessions?: Session[] }; if (Array.isArray(responseData.sessions)) { setSessions((prev) => { - const merged = new Map(prev.map((s) => [s.id, s])); + const incomingIds = new Set(responseData.sessions!.map((session) => session.id)); + const merged = new Map(prev.filter((session) => + !client?.sessionSnapshotBridgeId || session.bridgeId !== client.sessionSnapshotBridgeId || incomingIds.has(session.id) + ).map((session) => [session.id, session])); for (const s of responseData.sessions!) { if (isRecentlyRemoved(s.id)) continue; merged.set(s.id, mergeSessionSnapshot(merged.get(s.id), s)); @@ -226,7 +229,7 @@ export function useSessions( unregisterResponse(); unregisterSubscribed(); }; - }, [userId, onResponse, onSubscribed, publishCommand, isRecentlyRemoved]); + }, [client, userId, onResponse, onSubscribed, publishCommand, isRecentlyRemoved]); useEffect(() => { usageGenerationRef.current += 1; diff --git a/ui/src/lib/direct-transport/hybrid-terminal-transport.ts b/ui/src/lib/direct-transport/hybrid-terminal-transport.ts index 008f607..89049fd 100644 --- a/ui/src/lib/direct-transport/hybrid-terminal-transport.ts +++ b/ui/src/lib/direct-transport/hybrid-terminal-transport.ts @@ -62,6 +62,8 @@ export interface CentrifugoClientLike { export interface HybridTerminalTransportOptions { centrifuge: CentrifugoClientLike; + /** Local pairing mode: loopback only, never WebRTC or relay. */ + localOnly?: boolean; userId: string; clientId: string; publishCommand: (msg: DirectCommandMessage) => void; @@ -215,6 +217,7 @@ interface SessionEntry { */ export class HybridTerminalTransport implements TerminalTransportApi { private readonly client: CentrifugoClientLike; + private readonly localOnly: boolean; private readonly userId: string; private readonly clientId: string; private readonly publishCommand: (msg: DirectCommandMessage) => void; @@ -251,6 +254,7 @@ export class HybridTerminalTransport implements TerminalTransportApi { constructor(opts: HybridTerminalTransportOptions) { this.client = opts.centrifuge; + this.localOnly = opts.localOnly ?? false; this.userId = opts.userId; this.clientId = opts.clientId; this.publishCommand = opts.publishCommand; @@ -319,7 +323,7 @@ export class HybridTerminalTransport implements TerminalTransportApi { if (!entry || entry.disposed) return; if (this.hasActivePath(entry)) { this.dispatchInput(entry, data); - } else { + } else if (!this.localOnly) { this.bufferInput(entry, data); } } @@ -568,6 +572,7 @@ export class HybridTerminalTransport implements TerminalTransportApi { } private async attemptWebRtc(bridgeId: string, ctx: LadderCtx): Promise { + if (this.localOnly) return null; let peer: WebRtcPeerApi; try { peer = this.peerFactory({ @@ -663,7 +668,7 @@ export class HybridTerminalTransport implements TerminalTransportApi { private hasCentrifugoSessions(pe: PeerEntry): boolean { for (const sessionId of pe.sessions) { const entry = this.sessions.get(sessionId); - if (entry && !entry.disposed && entry.mode === 'centrifugo') return true; + if (entry && !entry.disposed && (entry.mode === 'centrifugo' || (this.localOnly && entry.mode === 'connecting'))) return true; } return false; } @@ -829,7 +834,9 @@ export class HybridTerminalTransport implements TerminalTransportApi { for (const sessionId of [...pe.sessions]) { const entry = this.sessions.get(sessionId); if (!entry || entry.disposed) continue; - if (entry.mode === 'centrifugo') { + if (this.localOnly && entry.mode === 'connecting') { + this.goDirect(entry, peer, this.modeForKind(kind)); + } else if (entry.mode === 'centrifugo') { switching.push(entry); } else if (!entry.direct) { // Edge: a session still in the connecting window when the upgrade landed — @@ -1041,6 +1048,13 @@ export class HybridTerminalTransport implements TerminalTransportApi { private goCentrifugo(entry: SessionEntry, reason: FallbackReason): void { entry.reason = reason; + if (this.localOnly) { + entry.direct = false; + entry.pendingInput = []; + entry.pendingInputChars = 0; + this.setMode(entry, 'connecting'); + return; + } // Abandon any in-flight upgrade switchover (the new peer is gone/superseded). entry.pendingSwitchPeer = null; if (entry.switchWatchdog) { diff --git a/ui/src/lib/local-browser-client.test.ts b/ui/src/lib/local-browser-client.test.ts new file mode 100644 index 0000000..f40ef30 --- /dev/null +++ b/ui/src/lib/local-browser-client.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { bootstrapLocal, localOrigin, localRequest, LocalBrowserClient, type BrowserBootstrap } from './local-browser-client'; +import { createRpcCore } from '@/hooks/useBridgeRpc'; +import { HybridTerminalTransport } from './direct-transport/hybrid-terminal-transport'; + +const device = { port: 43127, bridgeId: 'bridge-a', credential: 'private-device-token' }; +const boot: BrowserBootstrap = { version: 1, bridgeId: 'bridge-a', userId: 'local-owner', hostname: 'laptop', + localPort: 43128, localNonce: device.credential }; +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); + +describe('local browser control', () => { + it('accepts only numeric loopback ports and refuses redirects/cookies on credential requests', async () => { + for (const bad of ['0', '65536', 'localhost:123', '123/path', '1@evil.test', '-1', '1.5']) { + expect(() => localOrigin(bad)).toThrow(); + } + const fetcher = vi.fn().mockResolvedValue(json(boot)); vi.stubGlobal('fetch', fetcher); + await expect(bootstrapLocal(device)).resolves.toEqual(boot); + expect(fetcher).toHaveBeenCalledWith('http://127.0.0.1:43127/api/browser/bootstrap', expect.objectContaining({ + credentials: 'omit', redirect: 'error', cache: 'no-store', method: 'POST', + headers: { Authorization: 'Bearer private-device-token', 'Content-Type': 'application/json' }, + })); + }); + + it('rejects a different bridge at a remembered port before exposing it to the dashboard', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ ...boot, bridgeId: 'other' }))); + await expect(bootstrapLocal(device)).rejects.toThrow('different bridge'); + }); + + it('routes create/remove/loop controller RPC responses without cloud and never retries uncertain commands', async () => { + const fetcher = vi.fn().mockImplementation((_url, options) => { + const command = JSON.parse(options.body); + return Promise.resolve(json({ requestId: command.requestId, success: true, data: { ok: true } })); + }); + vi.stubGlobal('fetch', fetcher); + const client = new LocalBrowserClient(device, boot, vi.fn(), vi.fn()); + const sub = client.newSubscription(`commands:rpc#${boot.userId}`); sub.subscribe(); + const core = createRpcCore((command) => { void sub.publish(command); }); + sub.on('publication', ({ data, info }) => { + expect(info?.user).toBe(boot.userId); + core.handleResponse((data as { response: Parameters[0] }).response); + }); + for (const type of ['create_session', 'remove_session', 'create_loop'] as const) { + await expect(core.sendCommand({ type, requestId: type, payload: {} })).resolves.toMatchObject({ success: true }); + } + fetcher.mockRejectedValueOnce(new Error('connection lost')); + await expect(core.sendCommand({ type: 'create_session', requestId: 'lost', payload: {} })).resolves.toMatchObject({ + success: false, error: expect.stringContaining('Delivery may be uncertain'), + }); + expect(fetcher).toHaveBeenCalledTimes(4); + expect(fetcher.mock.calls.every(([url]) => url === 'http://127.0.0.1:43127/api/browser/commands')).toBe(true); + expect(JSON.stringify(client.presence())).not.toContain(device.credential); + client.close(); + }); + + it('delivers local events, refreshes snapshots after lost history, and stops on revocation', async () => { + const revoked = vi.fn(); const status = vi.fn(); + const client = new LocalBrowserClient(device, boot, status, revoked); + const sub = client.newSubscription('sessions:updates#local-owner'); + const event = vi.fn(); const refreshed = vi.fn(); + sub.on('publication', event); sub.on('subscribed', refreshed); sub.subscribe(); + await Promise.resolve(); refreshed.mockClear(); + const fetcher = vi.fn() + .mockResolvedValueOnce(json({ cursor: 17, reset: true, events: [{ channel: sub.channel, data: { type: 'session_update', session: { id: 's1' } } }] })) + .mockResolvedValueOnce(json(boot)) + .mockResolvedValueOnce(json({ error: 'revoked' }, 401)); + vi.stubGlobal('fetch', fetcher); + client.start(); + await vi.waitFor(() => expect(revoked).toHaveBeenCalledOnce()); + expect(refreshed).toHaveBeenCalledOnce(); + expect(event).toHaveBeenCalledWith(expect.objectContaining({ data: { type: 'session_update', session: { id: 's1' } } })); + expect(fetcher.mock.calls[2][0]).toContain('cursor=17'); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('does not send pairing poll credentials outside the local API', async () => { + const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher); + await expect(localRequest(43127, 'https://example.com/', 'poll-token')).rejects.toThrow('Invalid local API'); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('allows controller commands to finish beyond the bridge execution timeout', async () => { + const timeouts = vi.spyOn(AbortSignal, 'timeout'); + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(json({ success: true })))); + await localRequest(device.port, '/api/browser/commands', device.credential, {}); + expect(timeouts).toHaveBeenLastCalledWith(35_000); + await localRequest(device.port, '/api/browser/events?cursor=0', device.credential); + expect(timeouts).toHaveBeenLastCalledWith(25_000); + timeouts.mockRestore(); + }); + + it('revalidates bootstrap after reconnection and rebuilds subscriptions when bridge identity changes', async () => { + vi.useFakeTimers(); + const rebuild = vi.fn(); const status = vi.fn(); + const client = new LocalBrowserClient(device, boot, status, vi.fn(), rebuild); + const fetcher = vi.fn().mockRejectedValueOnce(new Error('restarted')) + .mockResolvedValueOnce(json({ cursor: 0, reset: true, events: [] })) + .mockResolvedValueOnce(json({ ...boot, userId: 'cloud-owner', localPort: 43129 })); + vi.stubGlobal('fetch', fetcher); + client.start(); + await vi.advanceTimersByTimeAsync(2100); + expect(rebuild).toHaveBeenCalledOnce(); + expect(fetcher.mock.calls[2][0]).toContain('/api/browser/bootstrap'); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(status).not.toHaveBeenCalledWith(true); + client.close(); + }); + + it('rebuilds on an event reset even when a fast restart did not produce a transport error', async () => { + const rebuild = vi.fn(); + const client = new LocalBrowserClient(device, boot, vi.fn(), vi.fn(), rebuild); + const fetcher = vi.fn() + .mockResolvedValueOnce(json({ cursor: 0, reset: true, events: [] })) + .mockResolvedValueOnce(json({ ...boot, localPort: 45000 })); + vi.stubGlobal('fetch', fetcher); + client.start(); + await vi.waitFor(() => expect(rebuild).toHaveBeenCalledOnce()); + expect(fetcher.mock.calls[1][0]).toContain('/api/browser/bootstrap'); + client.close(); + }); + + it('retries a failed local terminal without WebRTC/cloud and drops input while disconnected', async () => { + vi.useFakeTimers(); + const client = new LocalBrowserClient(device, boot, vi.fn(), vi.fn()); + const newSubscription = vi.spyOn(client, 'newSubscription'); + let closeListener = () => {}; + const peer = { connect: vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined), + attach: vi.fn(), detach: vi.fn(), close: vi.fn(), sendInput: vi.fn(), sendResize: vi.fn(), + onClose: (cb: () => void) => { closeListener = cb; } }; + const rtc = vi.fn(); const publish = vi.fn(); + const transport = new HybridTerminalTransport({ centrifuge: client, userId: boot.userId, clientId: 'browser', + localOnly: true, getLocalAdvert: async () => boot, loopbackPeerFactory: () => peer, peerFactory: rtc, + publishCommand: publish, upgradeBackoffMs: [50], upgradeJitter: 0 }); + transport.subscribeTerminal('s1', boot.bridgeId, { onOutput: vi.fn(), onScreen: vi.fn() }); + await vi.advanceTimersByTimeAsync(1); + expect(transport.getMode('s1')).toBe('connecting'); + transport.sendInput('s1', 'do not replay'); + await vi.advanceTimersByTimeAsync(100); + expect(transport.getMode('s1')).toBe('local'); + expect(peer.sendInput).not.toHaveBeenCalled(); + transport.sendInput('s1', 'ok'); expect(peer.sendInput).toHaveBeenCalledWith('s1', 'ok'); + closeListener(); await vi.advanceTimersByTimeAsync(100); + expect(transport.getMode('s1')).toBe('local'); + expect(newSubscription).not.toHaveBeenCalled(); expect(rtc).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); + transport.dispose(); client.close(); + }); +}); diff --git a/ui/src/lib/local-browser-client.ts b/ui/src/lib/local-browser-client.ts new file mode 100644 index 0000000..0f445cf --- /dev/null +++ b/ui/src/lib/local-browser-client.ts @@ -0,0 +1,192 @@ +import type { Command, CommandResponse } from '@/types'; +import type { MessageClient, MessageEvents, MessageSubscription } from './message-client'; + +export interface BrowserBootstrap { + version: 1; userId: string; bridgeId: string; hostname: string; + localPort: number; localNonce: string; +} +export interface LocalDevice { port: number; bridgeId: string; credential: string } +export interface Pairing { id: string; code: string; pollToken: string; expiresAt: string } +export class LocalAccessError extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +export function localOrigin(port: string | number): string { + if (!/^\d{1,5}$/.test(String(port)) || Number(port) < 1 || Number(port) > 65535) { + throw new Error('Enter the local port printed by ftown-bridge pair.'); + } + return `http://127.0.0.1:${Number(port)}`; +} + +/** Never follows redirects or sends browser cookies/credentials to hosted APIs. */ +export async function localRequest(port: number, path: string, credential?: string, + body?: unknown, signal?: AbortSignal): Promise { + if (!path.startsWith('/api/browser/')) throw new Error('Invalid local API path'); + const timeout = path === '/api/browser/commands' ? 35_000 : 25_000; + const response = await fetch(`${localOrigin(port)}${path}`, { + method: body === undefined ? 'GET' : 'POST', credentials: 'omit', redirect: 'error', cache: 'no-store', + headers: { ...(credential ? { Authorization: `Bearer ${credential}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout), + }); + const data = await response.json(); + if (!response.ok) throw new LocalAccessError(data.error ?? `Local bridge returned ${response.status}`, response.status); + return data as T; +} + +export async function bootstrapLocal(device: LocalDevice, signal?: AbortSignal): Promise { + const boot = await localRequest(device.port, '/api/browser/bootstrap', device.credential, {}, signal); + if (boot.version !== 1 || typeof boot.bridgeId !== 'string' || !boot.bridgeId || + typeof boot.userId !== 'string' || !boot.userId || typeof boot.hostname !== 'string' || + boot.localNonce !== device.credential) throw new Error('Invalid local bridge response.'); + localOrigin(boot.localPort); + if (device.bridgeId && device.bridgeId !== boot.bridgeId) { + throw new LocalAccessError('A different bridge is using this port. Pair again.', 401); + } + return boot; +} + +const SELECTED = 'ftown:local:port'; +const key = (port: number) => `ftown:local:device:${port}`; +export function rememberDevice(device: LocalDevice, remember: boolean): void { + localOrigin(device.port); + forgetDevice(device.port); + const storage = remember ? localStorage : sessionStorage; + storage.setItem(key(device.port), JSON.stringify(device)); + storage.setItem(SELECTED, String(device.port)); +} +export function readDevice(): LocalDevice | null { + for (const storage of [sessionStorage, localStorage]) { + try { + const port = storage.getItem(SELECTED); + if (!port) continue; + localOrigin(port); + const data = JSON.parse(storage.getItem(key(Number(port))) ?? 'null') as LocalDevice | null; + if (data && data.port === Number(port) && typeof data.credential === 'string' && data.credential && + typeof data.bridgeId === 'string' && data.bridgeId) return data; + } catch { /* Unavailable or malformed browser storage is not authorization. */ } + } + return null; +} +export function forgetDevice(port: number): void { + for (const storage of [sessionStorage, localStorage]) { + storage.removeItem(key(port)); + if (storage.getItem(SELECTED) === String(port)) storage.removeItem(SELECTED); + } +} + +class LocalSubscription implements MessageSubscription { + state = 'unsubscribed'; + private listeners = new Map void>>(); + constructor(readonly channel: string, private owner: LocalBrowserClient) {} + on(event: E, listener: (ctx: MessageEvents[E]) => void): this { + const set = this.listeners.get(event) ?? new Set(); + set.add(listener as (ctx: never) => void); this.listeners.set(event, set); return this; + } + off(event: E, listener: (ctx: MessageEvents[E]) => void): this { + this.listeners.get(event)?.delete(listener as (ctx: never) => void); return this; + } + emit(event: E, data: MessageEvents[E]): void { + for (const cb of this.listeners.get(event) ?? []) cb(data as never); + } + subscribe(): void { + if (this.state === 'subscribed') return; + this.state = 'subscribed'; queueMicrotask(() => { if (this.state === 'subscribed') this.emit('subscribed', undefined); }); + } + unsubscribe(): void { this.state = 'unsubscribed'; } + removeAllListeners(): void { this.listeners.clear(); } + presence() { return Promise.resolve(this.owner.presence()); } + async publish(data: unknown): Promise { + if (this.channel !== `commands:rpc#${this.owner.boot.userId}`) throw new Error('Local terminals require a direct connection.'); + const command = data as Command; + let response: CommandResponse; + try { + response = await this.owner.command(command); + } catch (error) { + // Resolve the shared RPC immediately with an explicit failure, never retry + // a mutation whose request might already have reached the controller. + response = { requestId: command.requestId, success: false, + error: `${error instanceof Error ? error.message : 'Local request failed'}. Delivery may be uncertain; refresh before retrying.` }; + } + this.owner.deliver(this.channel, { type: 'command_response', response }); + } +} + +/** Adapts authenticated local HTTP commands/events to existing dashboard hooks. */ +export class LocalBrowserClient implements MessageClient { + readonly commandTimeoutMs = 40_000; + private subscriptions = new Map(); + private abort = new AbortController(); + private cursor = 0; + private lastBootstrapCheck = Date.now(); + private online = true; + private running = false; + constructor(readonly device: LocalDevice, readonly boot: BrowserBootstrap, + private status: (connected: boolean, error?: string) => void, + private unauthorized: () => void, + private bootstrapChanged?: () => void) {} + get sessionSnapshotBridgeId(): string { return this.boot.bridgeId; } + newSubscription(channel: string): LocalSubscription { + if (this.subscriptions.has(channel)) throw new Error(`Duplicate subscription: ${channel}`); + const sub = new LocalSubscription(channel, this); this.subscriptions.set(channel, sub); return sub; + } + getSubscription(channel: string): LocalSubscription | null { return this.subscriptions.get(channel) ?? null; } + removeSubscription(sub: { subscribe(): void } | null): void { + for (const [channel, existing] of this.subscriptions) if (existing === sub) this.subscriptions.delete(channel); + } + presence() { + return { clients: this.online ? { [this.boot.bridgeId]: { connInfo: { bridgeId: this.boot.bridgeId, hostname: this.boot.hostname, connectedAt: '' } } } : {} }; + } + deliver(channel: string, data: unknown): void { + const sub = this.subscriptions.get(channel); + if (sub?.state === 'subscribed') sub.emit('publication', { data, info: { user: this.boot.userId } }); + } + async command(command: Command): Promise { + try { + return await localRequest(this.device.port, '/api/browser/commands', this.device.credential, command, this.abort.signal); + } catch (error) { this.handleError(error); throw error; } + } + private handleError(error: unknown): void { + if (this.abort.signal.aborted) return; + this.online = false; + this.status(false, error instanceof Error ? error.message : 'Local bridge unreachable'); + if (error instanceof LocalAccessError && (error.status === 401 || error.status === 403)) { + this.close(); this.unauthorized(); + } + } + start(): void { if (!this.running) { this.running = true; void this.poll(); } } + private async poll(): Promise { + while (!this.abort.signal.aborted) { + try { + const result = await localRequest<{ cursor: number; reset: boolean; events: { channel: string; data: unknown }[] }>( + this.device.port, `/api/browser/events?cursor=${this.cursor}`, this.device.credential, undefined, this.abort.signal); + if (this.abort.signal.aborted) return; + const recovered = !this.online; + if (recovered || result.reset || Date.now() - this.lastBootstrapCheck >= 10_000) { + const refreshed = await bootstrapLocal(this.device, this.abort.signal); + this.lastBootstrapCheck = Date.now(); + if (this.abort.signal.aborted) return; + if (refreshed.userId !== this.boot.userId || refreshed.localPort !== this.boot.localPort) { + this.close(); this.bootstrapChanged?.(); return; + } + } + this.online = true; this.status(true); + this.cursor = result.cursor; + for (const event of result.events) this.deliver(event.channel, event.data); + if (result.reset || recovered) { + // Existing hooks refresh authoritative sessions/loops on subscribe. + for (const sub of this.subscriptions.values()) if (sub.state === 'subscribed') sub.emit('subscribed', undefined); + } + } catch (error) { + this.handleError(error); + if (this.abort.signal.aborted) return; + await new Promise((resolve) => { + const done = () => { clearTimeout(timer); this.abort.signal.removeEventListener('abort', done); resolve(); }; + const timer = setTimeout(done, 2000); this.abort.signal.addEventListener('abort', done, { once: true }); + }); + } + } + } + close(): void { this.abort.abort(); this.subscriptions.clear(); } +} diff --git a/ui/src/lib/message-client.ts b/ui/src/lib/message-client.ts new file mode 100644 index 0000000..933ab85 --- /dev/null +++ b/ui/src/lib/message-client.ts @@ -0,0 +1,31 @@ +/** Shared authenticated message transport used by cloud and local dashboards. */ +export interface MessageEvents { + publication: { data: unknown; info?: { user?: string } }; + subscribed: unknown; + join: { info: { client: string; connInfo?: unknown } }; + leave: { info: { client: string } }; +} +export interface MessageSubscription { + state: string; + subscribe(): void; + unsubscribe(): void; + publish(data: unknown): Promise; + on(event: "publication", listener: (ctx: MessageEvents["publication"]) => void): unknown; + on(event: "subscribed", listener: (ctx: MessageEvents["subscribed"]) => void): unknown; + on(event: "join", listener: (ctx: MessageEvents["join"]) => void): unknown; + on(event: "leave", listener: (ctx: MessageEvents["leave"]) => void): unknown; + off(event: "publication", listener: (ctx: MessageEvents["publication"]) => void): unknown; + off(event: "subscribed", listener: (ctx: MessageEvents["subscribed"]) => void): unknown; + off(event: "join", listener: (ctx: MessageEvents["join"]) => void): unknown; + off(event: "leave", listener: (ctx: MessageEvents["leave"]) => void): unknown; + removeAllListeners(): unknown; + presence(): Promise<{ clients: Record }>; +} +export interface MessageClient { + readonly commandTimeoutMs?: number; + /** A local controller snapshot is authoritative for this single bridge. */ + readonly sessionSnapshotBridgeId?: string; + newSubscription(channel: string, options?: { since: { offset: number; epoch: string } }): MessageSubscription; + getSubscription(channel: string): MessageSubscription | null; + removeSubscription(sub: MessageSubscription | null): void; +} From e5968209ec5b59e7b7099e5954952bc259955f00 Mon Sep 17 00:00:00 2001 From: Foad Kesheh Date: Thu, 17 Sep 2026 22:00:46 -0300 Subject: [PATCH 2/2] Authorize local browser access from authenticated cloud presence --- bridge/src/browser-access.test.ts | 39 ++++++++++++++++- bridge/src/browser-access.ts | 26 ++++++++++- bridge/src/index.ts | 1 + docs/design/local-browser-control.md | 30 +++++++++++++ docs/local-browser.md | 17 +++++++- ui/src/components/Dashboard.tsx | 7 ++- ui/src/hooks/useBridges.ts | 27 +++++++++++- ui/src/lib/cloud-local-access.test.ts | 62 +++++++++++++++++++++++++++ ui/src/lib/cloud-local-access.ts | 49 +++++++++++++++++++++ ui/src/lib/local-browser-client.ts | 4 +- 10 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 ui/src/lib/cloud-local-access.test.ts create mode 100644 ui/src/lib/cloud-local-access.ts diff --git a/bridge/src/browser-access.test.ts b/bridge/src/browser-access.test.ts index 1fa4606..55c2524 100644 --- a/bridge/src/browser-access.test.ts +++ b/bridge/src/browser-access.test.ts @@ -10,10 +10,10 @@ import type { Command, CommandResponse } from './types.js'; const origin = 'https://ftown.ia.br'; const other = 'https://preview.example.com'; const bootstrap = () => ({ version: 1 as const, userId: 'local', bridgeId: 'bridge-1', hostname: 'laptop', localPort: 1234, localNonce: 'admin-secret' }); -async function fixture(execute: (c: Command) => Promise = async c => ({ requestId: c.requestId, success: true })) { +async function fixture(execute: (c: Command) => Promise = async c => ({ requestId: c.requestId, success: true }), cloudNonce?: string) { const dataDir = mkdtempSync(join(tmpdir(), 'browser-access-')); let revoked = 0; - const opts = { dataDir, allowedOrigins: [origin, other], bootstrap, execute, onRevoke: () => { revoked++; } }; + const opts = { cloudNonce, dataDir, allowedOrigins: [origin, other], bootstrap, execute, onRevoke: () => { revoked++; } }; let access = new BrowserAccess(opts); const server = createServer((req, res) => { void access.handle(req, res, req.headers.authorization === 'Bearer admin' && !req.headers.origin); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); @@ -159,3 +159,38 @@ test('command capacity rejects before execution and completed cache entries expi assert.equal(calls, 1001); } finally { Date.now = now; await f.close(); } }); + + +test('authenticated cloud capability grants idempotent remembered local access without pairing', async () => { + const nonce = 'a'.repeat(32); + const f = await fixture(undefined, nonce); + try { + const credential = 'b'.repeat(43); + const body = { bridgeId: 'bridge-1', credential }; + assert.equal((await f.call('cloud-devices', 'POST', body)).status, 401); + assert.equal((await f.call('cloud-devices', 'POST', body, 'wrong')).status, 401); + assert.equal((await f.call('cloud-devices', 'POST', body, nonce, 'https://evil.test')).status, 403); + assert.equal((await f.call('cloud-devices', 'POST', { ...body, bridgeId: 'other' }, nonce)).status, 403); + assert.equal((await f.call('cloud-devices', 'POST', { ...body, credential: 'weak' }, nonce)).status, 400); + assert.equal((await f.call('cloud-devices', 'POST', body, nonce)).status, 200); + assert.equal((await f.call('cloud-devices', 'POST', body, nonce)).status, 200); + assert.equal((await f.admin('devices')).body.devices.length, 1); + assert.equal((await f.call('cloud-devices', 'POST', body, nonce, other)).status, 409); + assert.equal((await f.call('bootstrap', 'POST', {}, credential)).status, 200); + const disk = readFileSync(join(f.dataDir, 'browser-devices.json'), 'utf8'); + assert.ok(!disk.includes(credential)); assert.ok(!disk.includes(nonce)); + f.restart(); + assert.equal((await f.call('bootstrap', 'POST', {}, credential)).status, 200); + const devices = await f.admin('devices'); + await f.admin(`devices/${devices.body.devices[0].id}`, 'DELETE'); + assert.equal((await f.call('bootstrap', 'POST', {}, credential)).status, 401); + } finally { await f.close(); } +}); + +test('standalone local mode has no cloud authorization bypass', async () => { + const f = await fixture(); + try { + assert.equal((await f.call('cloud-devices', 'POST', { bridgeId: 'bridge-1', credential: 'b'.repeat(43) }, 'a'.repeat(32))).status, 401); + assert.equal((await f.call('pairings', 'POST', { remember: true })).status, 409); + } finally { await f.close(); } +}); diff --git a/bridge/src/browser-access.ts b/bridge/src/browser-access.ts index f8327dc..d51684e 100644 --- a/bridge/src/browser-access.ts +++ b/bridge/src/browser-access.ts @@ -1,11 +1,11 @@ -import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto'; +import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'; import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Command, CommandResponse } from './types.js'; export type BrowserBootstrap = { version: 1; userId: string; bridgeId: string; hostname: string; localPort: number; localNonce: string }; -type Options = { dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap; execute: (command: Command) => Promise; onRevoke?: () => void }; +type Options = { cloudNonce?: string; dataDir: string; allowedOrigins: string[]; bootstrap: () => BrowserBootstrap; execute: (command: Command) => Promise; onRevoke?: () => void }; type Device = { id: string; origin: string; createdAt: string; hash: string; bridgeId: string; remember: boolean }; type Pairing = { id: string; code: string; origin: string; remember: boolean; expiresAt: string; pollHash: string; status: 'pending' | 'denied' | 'approved'; credential?: string }; type Cached = { body: string; result: Promise; completedAt?: number }; @@ -152,6 +152,28 @@ export class BrowserAccess { } fail(404, 'not_found', 'Unknown admin route'); } + // This capability comes from the bridge's signed connection info on the + // owner's authenticated cloud presence channel, not from a browser claim. + if (path === '/api/browser/cloud-devices' && req.method === 'POST') { + const proof = /^Bearer ([a-f0-9]{32})$/.exec(req.headers.authorization ?? '')?.[1] ?? ''; + if (!this.opts.cloudNonce || !timingSafeEqual(Buffer.from(hash(proof)), Buffer.from(hash(this.opts.cloudNonce)))) { + fail(401, 'unauthorized', 'Cloud bridge authorization required'); + } + const body = await this.body(req); + if (body.bridgeId !== this.opts.bootstrap().bridgeId) fail(403, 'wrong_bridge', 'Cloud authorization targets another bridge'); + if (typeof body.credential !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(body.credential)) fail(400, 'invalid_credential', 'Expected a random browser credential'); + const credential = body.credential as string; + const digest = hash(credential); + const existing = [...this.devices.values()].find(d => d.hash === digest); + if (existing && (existing.origin !== origin || existing.bridgeId !== body.bridgeId)) fail(409, 'credential_conflict', 'Credential is already bound'); + if (!existing) { + if (this.devices.size >= 100) fail(429, 'device_limit', 'Too many paired browsers'); + const device: Device = { id: randomUUID(), origin, createdAt: new Date().toISOString(), hash: digest, bridgeId: body.bridgeId as string, remember: true }; + this.devices.set(device.id, device); + try { this.persist(); } catch (error) { this.devices.delete(device.id); throw error; } + } + this.send(res, 200, { credential }); return; + } if (path === '/api/browser/pairings' && req.method === 'POST') { const recent = (this.rates.get(origin) ?? []).filter(t => Date.now() - t < 60_000); this.rates.set(origin, recent); diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 45405c9..e818a6b 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -890,6 +890,7 @@ program let ready = false; browserAccess = new BrowserAccess({ dataDir, + cloudNonce: opts.local || solo ? undefined : localNonce, allowedOrigins: apiOrigin ? [apiOrigin] : [], bootstrap: () => ({ version: 1, userId, bridgeId, hostname: osHostname(), localPort: hookPort, localNonce: '' }), execute: async (command) => { diff --git a/docs/design/local-browser-control.md b/docs/design/local-browser-control.md index 4ae501d..be119c5 100644 --- a/docs/design/local-browser-control.md +++ b/docs/design/local-browser-control.md @@ -107,3 +107,33 @@ assembled smoke creates/removes a shell session while cloud is unavailable. No d - Independent non-author backend and UI review completed. Fixed early local RPC timeout and bootstrap refresh after a bridge changes identity mode on restart. - No live bridge restart, deployment or PWA caching is part of this validation. + + +## Cloud-authorized local access (additive follow-up) + +The API style/envelopes and device model above remain unchanged. Cloud access +already grants the owner the bridge's per-process local nonce via signed +connection info in authenticated owner-only presence. That capability can also +create an origin-bound remembered browser device; unauthenticated local access +still requires explicit terminal approval. + +Typed operation: POST /api/browser/cloud-devices, +request {bridgeId:string, credential:string} -> {credential:string}. +Authorization: exact allowed Origin AND bearer equal to this bridge's cloud +local nonce AND request bridgeId equal to its actual identity. Disabled in +--local and --solo. Credential is browser-generated random32-byte base64url, +persisted as a hash only. Identical credential + origin + bridge is idempotent; +conflicting binding409. Same error envelope: invalid400, invalid proof401, +origin/bridge403, conflicting binding409, device cap429, storage failure503. +No collection/pagination. Maximum100 devices bounds creation. Additive route; +existing pair/bootstrap/commands APIs unchanged. + +Journey: authenticated cloud presence -> bounded loopback exchange -> verified +bootstrap -> remembered local device -> /local restores without approval or +cloud login. Snapshot/join hooks prepare it proactively; clicking This computer +awaits pending preparation. Failed remote-loopback attempts never affect cloud +use. Cloud nonce is never logged, persisted in browser storage, or put in a URL. + +Follow-up verification: 798 bridge tests and 271 UI tests pass; both production +builds pass. Added proof/origin/identity/idempotency tests and browser handoff, +revocation, retry and remembered-access tests. diff --git a/docs/local-browser.md b/docs/local-browser.md index ab9b63c..4c03ca9 100644 --- a/docs/local-browser.md +++ b/docs/local-browser.md @@ -4,7 +4,22 @@ Choose **This computer** on the hosted login page or cloud dashboard to manage a bridge on the same computer without cloud login. Session and loop operations use the same bridge controllers as cloud mode; terminals connect over loopback. -## Start and approve +## Coming from Cloud + +When the authenticated cloud dashboard sees this computer's bridge, it uses the +bridge's cloud-issued local capability to establish remembered local access +automatically. Choose **This computer** to switch without `ftown-bridge pair` or +a second terminal. The dashboard prepares this access while cloud is available, +so it also works after Fly disconnects. Browser local-network permission may +still be required. + +Only a matching bridge identity and allowed browser origin can exchange that +capability. Remote bridges cannot authorize a different bridge on this computer. +A saved credential that was revoked is not silently replaced by background +polling. Devices established from Cloud can be listed and revoked with the same +CLI commands below. + +## Start and approve without Cloud For a bridge that never contacts the cloud: diff --git a/ui/src/components/Dashboard.tsx b/ui/src/components/Dashboard.tsx index 080aa90..95c97ab 100644 --- a/ui/src/components/Dashboard.tsx +++ b/ui/src/components/Dashboard.tsx @@ -181,7 +181,7 @@ export function Dashboard({ client, connectionStatus, connectionError, userId, t const rpc = useBridgeRpc(client, userId); const { bridgeExec } = rpc; const { sessions: rawSessions, createSession, stopSession, retrySession, renameSession, setSessionParent, removeSession, refreshSessions } = useSessions(client, userId, rpc); - const { bridges, hasBridges } = useBridges(client, userId); + const { bridges, hasBridges, prepareLocalAccess } = useBridges(client, userId); const { loops, createLoop, updateLoop, deleteLoop, runLoopNow, getLoopRuns } = useLoops(client, userId, rpc); // Keep bridgeOrder stable when bridges connect/disconnect; only append new ids (sorted). @@ -893,7 +893,10 @@ PY`; {connectionStatus === "connected" ? "Cloud connected" : "Cloud reconnecting"} } - {localMode ? "Cloud" : "This computer"} + { + event.preventDefault(); + void prepareLocalAccess().then(() => { window.location.href = "/local"; }); + }} className="btn-ghost">{localMode ? "Cloud" : "This computer"} diff --git a/ui/src/hooks/useBridges.ts b/ui/src/hooks/useBridges.ts index 195fb25..d82f532 100644 --- a/ui/src/hooks/useBridges.ts +++ b/ui/src/hooks/useBridges.ts @@ -1,5 +1,6 @@ "use client"; +import { createCloudLocalAccess } from "@/lib/cloud-local-access"; import { useState, useEffect, useRef, useCallback } from "react"; import type { MessageClient, MessageSubscription } from "@/lib/message-client"; @@ -13,6 +14,7 @@ export interface BridgeInfo { interface UseBridgesResult { bridges: BridgeInfo[]; hasBridges: boolean; + prepareLocalAccess: () => Promise; } /** @@ -84,6 +86,8 @@ export function applyBridgeLeave(allClients: BridgeInfo[], clientId: string): Br export function useBridges(client: MessageClient | null, userId: string | null): UseBridgesResult { const [bridges, setBridges] = useState([]); const subRef = useRef(null); + const localAdvertsRef = useRef([]); + const provisionRef = useRef<((info: unknown) => Promise) | null>(null); // Every known client per bridgeId (not deduped) — the source of truth fed // to dedupeBridges to produce the exposed `bridges` list. Kept in a ref // (not state) since it's an internal accumulator; only the derived, @@ -93,7 +97,8 @@ export function useBridges(client: MessageClient | null, userId: string | null): const fetchPresence = useCallback(async (sub: MessageSubscription) => { try { const result = await sub.presence(); - console.log("[bridges] presence result:", JSON.stringify(result.clients, null, 2)); + localAdvertsRef.current = Object.values(result.clients).map(info => info.connInfo); + for (const info of localAdvertsRef.current) void provisionRef.current?.(info); const bridgeList: BridgeInfo[] = Object.entries(result.clients) .filter(([, info]) => info.connInfo && typeof info.connInfo === "object" && "bridgeId" in (info.connInfo as Record)) .map(([clientId, info]) => { @@ -121,6 +126,8 @@ export function useBridges(client: MessageClient | null, userId: string | null): return; } + const abort = new AbortController(); + provisionRef.current = createCloudLocalAccess(abort.signal); const channel = `bridges:presence#${userId}`; const existing = client.getSubscription(channel); @@ -139,6 +146,8 @@ export function useBridges(client: MessageClient | null, userId: string | null): sub.on("join", (ctx) => { const data = ctx.info.connInfo as { bridgeId?: string; hostname?: string; connectedAt?: string } | undefined; if (!data?.bridgeId) return; + localAdvertsRef.current.push(ctx.info.connInfo); + void provisionRef.current?.(ctx.info.connInfo); const bridge: BridgeInfo = { clientId: ctx.info.client, bridgeId: data.bridgeId, @@ -162,6 +171,9 @@ export function useBridges(client: MessageClient | null, userId: string | null): }, 10_000); return () => { + abort.abort(); + provisionRef.current = null; + localAdvertsRef.current = []; clearInterval(presenceInterval); sub.removeAllListeners(); sub.unsubscribe(); @@ -172,7 +184,20 @@ export function useBridges(client: MessageClient | null, userId: string | null): }; }, [client, userId, fetchPresence]); + const prepareLocalAccess = useCallback(async () => { + // A click can precede the first presence snapshot. Give it a bounded chance + // to discover this computer without trapping navigation during an outage. + if (!localAdvertsRef.current.length && subRef.current) { + let timer: ReturnType | undefined; + try { + await Promise.race([fetchPresence(subRef.current), new Promise(resolve => { timer = setTimeout(resolve, 3000); })]); + } finally { if (timer) clearTimeout(timer); } + } + await Promise.all(localAdvertsRef.current.map(info => provisionRef.current?.(info))); + }, [fetchPresence]); + return { + prepareLocalAccess, bridges, hasBridges: bridges.length > 0, }; diff --git a/ui/src/lib/cloud-local-access.test.ts b/ui/src/lib/cloud-local-access.test.ts new file mode 100644 index 0000000..0c5f902 --- /dev/null +++ b/ui/src/lib/cloud-local-access.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createCloudLocalAccess } from './cloud-local-access'; +import { readDevice, rememberDevice } from './local-browser-client'; + +const advert = { bridgeId: 'bridge-a', localPort: 43127, localNonce: 'a'.repeat(32) }; +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); +afterEach(() => vi.unstubAllGlobals()); + +it('cloud proof establishes remembered access without pairing and coalesces click/presence requests', async () => { + let credential = ''; + const fetcher = vi.fn(async (url: string, init: RequestInit) => { + expect(url.startsWith('http://127.0.0.1:43127/api/browser/')).toBe(true); + expect(init.credentials).toBe('omit'); expect(init.redirect).toBe('error'); + if (url.endsWith('/cloud-devices')) { + expect(init.headers).toMatchObject({ Authorization: `Bearer ${advert.localNonce}` }); + const body = JSON.parse(init.body as string); + expect(body.bridgeId).toBe(advert.bridgeId); credential = body.credential; + expect(credential).toMatch(/^[A-Za-z0-9_-]{43}$/); + return json({ credential }); + } + expect(url.endsWith('/bootstrap')).toBe(true); + return json({ version: 1, bridgeId: advert.bridgeId, userId: 'owner', hostname: 'laptop', localPort: advert.localPort, localNonce: credential }); + }); + vi.stubGlobal('fetch', fetcher); + const prepare = createCloudLocalAccess(new AbortController().signal); + await Promise.all([prepare(advert), prepare(advert)]); + await prepare(advert); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(readDevice()).toEqual({ port: advert.localPort, bridgeId: advert.bridgeId, credential }); +}); + +it('reuses remembered authorization and never silently replaces a revoked saved credential', async () => { + rememberDevice({ port: advert.localPort, bridgeId: advert.bridgeId, credential: 'b'.repeat(43) }, true); + const fetcher = vi.fn(async (_url: string, _init: RequestInit) => json({ error: 'revoked' }, 401)); vi.stubGlobal('fetch', fetcher); + await createCloudLocalAccess(new AbortController().signal)(advert); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0]?.[0]).toContain('/bootstrap'); +}); + +it('ignores untrusted/missing adverts and does not save access for a different bridge on the same port', async () => { + const fetcher = vi.fn(async (url: string, init: RequestInit) => url.endsWith('/cloud-devices') + ? json({ credential: JSON.parse(init.body as string).credential }) + : json({ version: 1, bridgeId: 'foreign', userId: 'owner', hostname: 'x', localPort: advert.localPort, localNonce: 'wrong' })); + vi.stubGlobal('fetch', fetcher); + const prepare = createCloudLocalAccess(new AbortController().signal); + for (const bad of [null, {}, { ...advert, localPort: 65536 }, { ...advert, localNonce: '' }]) await prepare(bad); + expect(fetcher).not.toHaveBeenCalled(); + await prepare(advert); + expect(readDevice()).toBeNull(); +}); + +it('retries a failed exchange with the same proposed credential without affecting cloud use', async () => { + const fetcher = vi.fn(async (_url: string, _init: RequestInit) => { throw new Error('remote bridge is not on this computer'); }); + vi.stubGlobal('fetch', fetcher); + const prepare = createCloudLocalAccess(new AbortController().signal); + await prepare(advert); await prepare(advert); + expect(fetcher).toHaveBeenCalledTimes(2); + expect((fetcher.mock.calls[0]?.[1] as RequestInit).body).toBe((fetcher.mock.calls[1]?.[1] as RequestInit).body); + expect(readDevice()).toBeNull(); +}); diff --git a/ui/src/lib/cloud-local-access.ts b/ui/src/lib/cloud-local-access.ts new file mode 100644 index 0000000..d0771b4 --- /dev/null +++ b/ui/src/lib/cloud-local-access.ts @@ -0,0 +1,49 @@ +import { bootstrapLocal, localOrigin, localRequest, readDevice, rememberDevice } from './local-browser-client'; + +/** Only call with authenticated cloud presence connInfo, never publication data. */ +export function createCloudLocalAccess(signal: AbortSignal): (info: unknown) => Promise { + const pending = new Map>(); + const complete = new Set(); + const candidates = new Map(); + return async (info: unknown) => { + if (signal.aborted || !info || typeof info !== 'object') return; + const advert = info as Record; + const { bridgeId, localPort, localNonce } = advert; + if (typeof bridgeId !== 'string' || !bridgeId || typeof localPort !== 'number' || + typeof localNonce !== 'string' || !/^[a-f0-9]{32}$/.test(localNonce)) return; + try { localOrigin(localPort); } catch { return; } + const key = `${bridgeId}:${localPort}`; + if (complete.has(key)) return; + if (pending.has(key)) return pending.get(key); + const work = (async () => { + const bounded = AbortSignal.any([signal, AbortSignal.timeout(3000)]); + const saved = readDevice(localPort); + if (saved?.bridgeId === bridgeId) { + // A revoked saved credential must not be silently recreated by polling. + await bootstrapLocal(saved, bounded); + complete.add(key); + return; + } + let credential = candidates.get(key); + if (!credential) { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + credential = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + candidates.set(key, credential); + } + const granted = await localRequest<{ credential: string }>(localPort, '/api/browser/cloud-devices', + localNonce, { bridgeId, credential }, bounded); + if (granted.credential !== credential) throw new Error('Unexpected local credential'); + const device = { port: localPort, bridgeId, credential }; + await bootstrapLocal(device, bounded); + if (signal.aborted) return; + rememberDevice(device, true); + complete.add(key); + candidates.delete(key); + })().catch(() => { + // Most cloud bridges are remote. A failed loopback attempt must not affect + // cloud use; later authenticated presence snapshots can retry safely. + }).finally(() => pending.delete(key)); + pending.set(key, work); + return work; + }; +} diff --git a/ui/src/lib/local-browser-client.ts b/ui/src/lib/local-browser-client.ts index 0f445cf..7f5b754 100644 --- a/ui/src/lib/local-browser-client.ts +++ b/ui/src/lib/local-browser-client.ts @@ -56,10 +56,10 @@ export function rememberDevice(device: LocalDevice, remember: boolean): void { storage.setItem(key(device.port), JSON.stringify(device)); storage.setItem(SELECTED, String(device.port)); } -export function readDevice(): LocalDevice | null { +export function readDevice(selectedPort?: number): LocalDevice | null { for (const storage of [sessionStorage, localStorage]) { try { - const port = storage.getItem(SELECTED); + const port = selectedPort === undefined ? storage.getItem(SELECTED) : String(selectedPort); if (!port) continue; localOrigin(port); const data = JSON.parse(storage.getItem(key(Number(port))) ?? 'null') as LocalDevice | null;