diff --git a/package-lock.json b/package-lock.json index a036a8f..e964f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "execa": "^9.5.2", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", + "qrcode-terminal": "^0.12.0", "write-file-atomic": "^7.0.0", "yaml": "^2.7.1" }, @@ -23,6 +24,7 @@ "devDependencies": { "@types/node": "^22.13.4", "@types/proper-lockfile": "^4.1.4", + "@types/qrcode-terminal": "^0.12.2", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.7.3", @@ -926,6 +928,13 @@ "@types/retry": "*" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -1841,6 +1850,14 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..91145d2 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,14 @@ "execa": "^9.5.2", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", + "qrcode-terminal": "^0.12.0", "write-file-atomic": "^7.0.0", "yaml": "^2.7.1" }, "devDependencies": { "@types/node": "^22.13.4", "@types/proper-lockfile": "^4.1.4", + "@types/qrcode-terminal": "^0.12.2", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.7.3", diff --git a/src/cli.ts b/src/cli.ts index bfb207a..e90dd9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -263,6 +263,18 @@ worktreeCmd await worktreeCreateCommand(options); }); +program + .command('serve') + .description('Start the API server with TLS and display pairing QR code') + .option('-p, --port ', 'Port to listen on', parsePort, 7700) + .option('-H, --host
', 'Host to bind to', '0.0.0.0') + .option('--daemon', 'Run in daemon mode (suppress QR code)') + .option('--json', 'Output as JSON') + .action(async (options) => { + const { serveCommand } = await import('./commands/serve.js'); + await serveCommand(options); + }); + program .command('ui') .alias('dashboard') @@ -372,6 +384,14 @@ function parsePositiveInt(optionName: string) { }; } +function parsePort(v: string): number { + const port = Number(v); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535'); + } + return port; +} + async function main() { try { await program.parseAsync(process.argv); diff --git a/src/commands/serve.test.ts b/src/commands/serve.test.ts new file mode 100644 index 0000000..32a4c63 --- /dev/null +++ b/src/commands/serve.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect } from 'vitest'; + +import { buildPairingUrl, getLocalIp, verifyToken } from './serve.js'; + +describe('buildPairingUrl', () => { + test('given valid params, should encode all fields into ppg:// URL', () => { + const url = buildPairingUrl({ + host: '192.168.1.10', + port: 7700, + fingerprint: 'AA:BB:CC', + token: 'test-token-123', + }); + + expect(url).toContain('ppg://connect'); + expect(url).toContain('host=192.168.1.10'); + expect(url).toContain('port=7700'); + expect(url).toContain('ca=AA%3ABB%3ACC'); + expect(url).toContain('token=test-token-123'); + }); + + test('given special characters in token, should URL-encode them', () => { + const url = buildPairingUrl({ + host: '10.0.0.1', + port: 8080, + fingerprint: 'DE:AD:BE:EF', + token: 'a+b/c=d', + }); + + expect(url).toContain('token=a%2Bb%2Fc%3Dd'); + }); +}); + +describe('getLocalIp', () => { + test('should return a non-empty string', () => { + const ip = getLocalIp(); + expect(ip).toBeTruthy(); + expect(typeof ip).toBe('string'); + }); + + test('should return a valid IPv4 address', () => { + const ip = getLocalIp(); + const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + expect(ip).toMatch(ipv4Pattern); + }); +}); + +describe('verifyToken', () => { + test('given matching tokens, should return true', () => { + expect(verifyToken('correct-token', 'correct-token')).toBe(true); + }); + + test('given different tokens of same length, should return false', () => { + expect(verifyToken('aaaa-bbbb-cccc', 'xxxx-yyyy-zzzz')).toBe(false); + }); + + test('given different length tokens, should return false', () => { + expect(verifyToken('short', 'much-longer-token')).toBe(false); + }); + + test('given empty provided token, should return false', () => { + expect(verifyToken('', 'expected-token')).toBe(false); + }); + + test('given both empty, should return true', () => { + expect(verifyToken('', '')).toBe(true); + }); +}); diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 0000000..84b1b2e --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,130 @@ +import os from 'node:os'; +import { createServer } from 'node:https'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import qrcode from 'qrcode-terminal'; +import { getRepoRoot } from '../core/worktree.js'; +import { requireManifest } from '../core/manifest.js'; +import { ensureTlsCerts } from '../core/tls.js'; +import { output, info, success } from '../lib/output.js'; + +export interface ServeOptions { + port: number; + host: string; + daemon?: boolean; + json?: boolean; +} + +export function buildPairingUrl(params: { + host: string; + port: number; + fingerprint: string; + token: string; +}): string { + const { host, port, fingerprint, token } = params; + const url = new URL('ppg://connect'); + url.searchParams.set('host', host); + url.searchParams.set('port', String(port)); + url.searchParams.set('ca', fingerprint); + url.searchParams.set('token', token); + return url.toString(); +} + +export function getLocalIp(): string { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] ?? []) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +export function verifyToken(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function generateToken(): string { + return randomBytes(32).toString('base64url'); +} + +function displayQrCode(pairingUrl: string): Promise { + return new Promise((resolve) => { + qrcode.generate(pairingUrl, { small: true }, (code: string) => { + console.log(''); + console.log(code); + resolve(); + }); + }); +} + +export async function serveCommand(options: ServeOptions): Promise { + const projectRoot = await getRepoRoot(); + await requireManifest(projectRoot); + + const { port, host } = options; + const isDaemon = options.daemon ?? false; + const isInteractive = process.stdout.isTTY && !isDaemon; + + const tls = await ensureTlsCerts(projectRoot); + const token = generateToken(); + + const displayHost = host === '0.0.0.0' ? getLocalIp() : host; + const pairingUrl = buildPairingUrl({ + host: displayHost, + port, + fingerprint: tls.fingerprint, + token, + }); + + const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => { + const authHeader = req.headers.authorization ?? ''; + const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + if (!verifyToken(provided, token)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(port, host, () => resolve()); + }); + + if (options.json) { + output({ + status: 'listening', + host: displayHost, + port, + token, + fingerprint: tls.fingerprint, + pairingUrl, + }, true); + } else { + success(`Server listening on https://${displayHost}:${port}`); + info(`Token: ${token}`); + + if (isInteractive) { + info('Scan QR code to pair:'); + await displayQrCode(pairingUrl); + info(`Pairing URL: ${pairingUrl}`); + } + } + + // Keep running until killed + await new Promise((resolve) => { + const shutdown = () => { + server.close(() => resolve()); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + }); +} diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..541d560 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest. import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; +import type { Manifest } from '../types/manifest.js'; import * as tmux from '../core/tmux.js'; vi.mock('node:fs/promises', async () => { @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1; diff --git a/src/core/tls.ts b/src/core/tls.ts new file mode 100644 index 0000000..e405d00 --- /dev/null +++ b/src/core/tls.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { generateKeyPairSync, X509Certificate } from 'node:crypto'; +import { execa } from 'execa'; +import { ppgDir } from '../lib/paths.js'; +import { execaEnv } from '../lib/env.js'; + +export interface TlsCredentials { + key: string; + cert: string; + fingerprint: string; +} + +export async function ensureTlsCerts(projectRoot: string): Promise { + const certsDir = path.join(ppgDir(projectRoot), 'certs'); + const keyPath = path.join(certsDir, 'server.key'); + const certPath = path.join(certsDir, 'server.crt'); + + try { + const [key, cert] = await Promise.all([ + fs.readFile(keyPath, 'utf-8'), + fs.readFile(certPath, 'utf-8'), + ]); + const fingerprint = getCertFingerprint(cert); + return { key, cert, fingerprint }; + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + } + + await fs.mkdir(certsDir, { recursive: true }); + + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string; + const certPem = await generateSelfSignedCert(keyPem, buildSubjectAltName()); + + await Promise.all([ + fs.writeFile(keyPath, keyPem, { mode: 0o600 }), + fs.writeFile(certPath, certPem), + ]); + + const fingerprint = getCertFingerprint(certPem); + return { key: keyPem, cert: certPem, fingerprint }; +} + +async function generateSelfSignedCert(keyPem: string, subjectAltName: string): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-tls-')); + const tmpKey = path.join(tmpDir, 'server.key'); + const tmpCert = path.join(tmpDir, 'server.crt'); + + try { + await fs.writeFile(tmpKey, keyPem, { mode: 0o600 }); + await execa('openssl', [ + 'req', '-new', '-x509', + '-key', tmpKey, + '-out', tmpCert, + '-days', '365', + '-subj', '/CN=ppg-server', + '-addext', subjectAltName, + ], { ...execaEnv, stdio: 'pipe' }); + return await fs.readFile(tmpCert, 'utf-8'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +function buildSubjectAltName(): string { + const sanEntries = new Set([ + 'DNS:localhost', + 'IP:127.0.0.1', + 'IP:::1', + ]); + + for (const addresses of Object.values(os.networkInterfaces())) { + for (const iface of addresses ?? []) { + if (iface.internal) continue; + if (iface.family !== 'IPv4' && iface.family !== 'IPv6') continue; + sanEntries.add(`IP:${iface.address}`); + } + } + + return `subjectAltName=${Array.from(sanEntries).join(',')}`; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error as { code?: unknown }).code === code; +} + +export function getCertFingerprint(certPem: string): string { + const x509 = new X509Certificate(certPem); + return x509.fingerprint256; +}