From 2781a5385f6c236b9f6d085f79c8b51ce56ffee9 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:06:51 -0600 Subject: [PATCH 1/3] feat: add QR code display on ppg serve startup Add `ppg serve` command that starts an HTTPS server with self-signed TLS certificates and displays a pairing QR code for iOS device connectivity. - Generate self-signed EC certs (prime256v1) stored in .ppg/certs/ - Encode pairing URL: ppg://connect?host=...&port=...&ca=...&token=... - Include CA SHA-256 fingerprint for certificate pinning - Print auth token to stdout at generation time - Render QR code via qrcode-terminal (small mode) - Only display QR in interactive terminal (skip in --daemon mode) - Support --json for machine-readable output - Add qrcode-terminal to dependencies Closes #88 --- package-lock.json | 17 ++++ package.json | 2 + src/cli.ts | 12 +++ src/commands/serve.ts | 199 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 src/commands/serve.ts 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..916b66a 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', (v: string) => Number(v), 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') diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 0000000..07a6aaa --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createServer } from 'node:https'; +import { execSync } from 'node:child_process'; +import { randomBytes, generateKeyPairSync, X509Certificate } from 'node:crypto'; +import qrcode from 'qrcode-terminal'; +import { getRepoRoot } from '../core/worktree.js'; +import { ppgDir } from '../lib/paths.js'; +import { NotInitializedError } from '../lib/errors.js'; +import { output, info, success } from '../lib/output.js'; + +export interface ServeOptions { + port?: number; + host?: string; + daemon?: boolean; + json?: boolean; +} + +const DEFAULT_PORT = 7700; +const DEFAULT_HOST = '0.0.0.0'; + +interface TlsCredentials { + key: string; + cert: string; + fingerprint: string; +} + +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 { + // Generate self-signed certificate + await fs.mkdir(certsDir, { recursive: true }); + + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string; + const certPem = generateSelfSignedCert(keyPem); + + await Promise.all([ + fs.writeFile(keyPath, keyPem, { mode: 0o600 }), + fs.writeFile(certPath, certPem), + ]); + + const fingerprint = getCertFingerprint(certPem); + return { key: keyPem, cert: certPem, fingerprint }; + } +} + +function generateSelfSignedCert(keyPem: string): string { + const tmpKey = path.join(os.tmpdir(), `ppg-key-${process.pid}.pem`); + const tmpCert = path.join(os.tmpdir(), `ppg-cert-${process.pid}.pem`); + + try { + fsSync.writeFileSync(tmpKey, keyPem, { mode: 0o600 }); + execSync( + `openssl req -new -x509 -key "${tmpKey}" -out "${tmpCert}" -days 365 -subj "/CN=ppg-server" -addext "subjectAltName=IP:127.0.0.1,IP:::1"`, + { stdio: 'pipe' }, + ); + return fsSync.readFileSync(tmpCert, 'utf-8'); + } finally { + try { fsSync.unlinkSync(tmpKey); } catch {} + try { fsSync.unlinkSync(tmpCert); } catch {} + } +} + +function getCertFingerprint(certPem: string): string { + const x509 = new X509Certificate(certPem); + return x509.fingerprint256; +} + +function generateToken(): string { + return randomBytes(32).toString('base64url'); +} + +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(); +} + +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'; +} + +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(); + const manifestFile = path.join(ppgDir(projectRoot), 'manifest.json'); + try { + await fs.access(manifestFile); + } catch { + throw new NotInitializedError(projectRoot); + } + + const port = options.port ?? DEFAULT_PORT; + const host = options.host ?? DEFAULT_HOST; + const isDaemon = options.daemon ?? false; + const isInteractive = process.stdout.isTTY && !isDaemon; + + // Generate TLS credentials and auth token + const tls = await ensureTlsCerts(projectRoot); + const token = generateToken(); + + // Resolve the display host for pairing URL + const displayHost = host === '0.0.0.0' ? getLocalIp() : host; + const pairingUrl = buildPairingUrl({ + host: displayHost, + port, + fingerprint: tls.fingerprint, + token, + }); + + // Create HTTPS server + const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => { + const authHeader = req.headers.authorization; + if (authHeader !== `Bearer ${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); + }); +} From a111a3a8fc6b5b73257dc51e6035b58fc2df653d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:52:02 -0600 Subject: [PATCH 2/3] fix: address code review findings for serve command - Critical: use crypto.timingSafeEqual for bearer token comparison instead of string equality to prevent timing side-channel attacks - High: extract TLS logic to core/tls.ts, use execa with argument arrays instead of execSync with shell string interpolation - High: add unit tests for buildPairingUrl, getLocalIp, verifyToken - Medium: use requireManifest() instead of manual fs.access check - Medium: use fs.mkdtemp for temp key files instead of predictable PID-based paths - Medium: replace sync fs and child_process with async equivalents - Low: remove redundant default fallbacks (Commander provides them) --- src/commands/serve.test.ts | 67 +++++++++++++++++++++ src/commands/serve.ts | 115 ++++++++----------------------------- src/core/tls.ts | 71 +++++++++++++++++++++++ 3 files changed, 161 insertions(+), 92 deletions(-) create mode 100644 src/commands/serve.test.ts create mode 100644 src/core/tls.ts 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 index 07a6aaa..84b1b2e 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,92 +1,20 @@ -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; -import path from 'node:path'; import os from 'node:os'; import { createServer } from 'node:https'; -import { execSync } from 'node:child_process'; -import { randomBytes, generateKeyPairSync, X509Certificate } from 'node:crypto'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import qrcode from 'qrcode-terminal'; import { getRepoRoot } from '../core/worktree.js'; -import { ppgDir } from '../lib/paths.js'; -import { NotInitializedError } from '../lib/errors.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; + port: number; + host: string; daemon?: boolean; json?: boolean; } -const DEFAULT_PORT = 7700; -const DEFAULT_HOST = '0.0.0.0'; - -interface TlsCredentials { - key: string; - cert: string; - fingerprint: string; -} - -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 { - // Generate self-signed certificate - await fs.mkdir(certsDir, { recursive: true }); - - const { privateKey } = generateKeyPairSync('ec', { - namedCurve: 'prime256v1', - }); - - const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string; - const certPem = generateSelfSignedCert(keyPem); - - await Promise.all([ - fs.writeFile(keyPath, keyPem, { mode: 0o600 }), - fs.writeFile(certPath, certPem), - ]); - - const fingerprint = getCertFingerprint(certPem); - return { key: keyPem, cert: certPem, fingerprint }; - } -} - -function generateSelfSignedCert(keyPem: string): string { - const tmpKey = path.join(os.tmpdir(), `ppg-key-${process.pid}.pem`); - const tmpCert = path.join(os.tmpdir(), `ppg-cert-${process.pid}.pem`); - - try { - fsSync.writeFileSync(tmpKey, keyPem, { mode: 0o600 }); - execSync( - `openssl req -new -x509 -key "${tmpKey}" -out "${tmpCert}" -days 365 -subj "/CN=ppg-server" -addext "subjectAltName=IP:127.0.0.1,IP:::1"`, - { stdio: 'pipe' }, - ); - return fsSync.readFileSync(tmpCert, 'utf-8'); - } finally { - try { fsSync.unlinkSync(tmpKey); } catch {} - try { fsSync.unlinkSync(tmpCert); } catch {} - } -} - -function getCertFingerprint(certPem: string): string { - const x509 = new X509Certificate(certPem); - return x509.fingerprint256; -} - -function generateToken(): string { - return randomBytes(32).toString('base64url'); -} - -function buildPairingUrl(params: { +export function buildPairingUrl(params: { host: string; port: number; fingerprint: string; @@ -101,7 +29,7 @@ function buildPairingUrl(params: { return url.toString(); } -function getLocalIp(): string { +export function getLocalIp(): string { const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name] ?? []) { @@ -113,6 +41,17 @@ function getLocalIp(): string { 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) => { @@ -125,23 +64,15 @@ function displayQrCode(pairingUrl: string): Promise { export async function serveCommand(options: ServeOptions): Promise { const projectRoot = await getRepoRoot(); - const manifestFile = path.join(ppgDir(projectRoot), 'manifest.json'); - try { - await fs.access(manifestFile); - } catch { - throw new NotInitializedError(projectRoot); - } + await requireManifest(projectRoot); - const port = options.port ?? DEFAULT_PORT; - const host = options.host ?? DEFAULT_HOST; + const { port, host } = options; const isDaemon = options.daemon ?? false; const isInteractive = process.stdout.isTTY && !isDaemon; - // Generate TLS credentials and auth token const tls = await ensureTlsCerts(projectRoot); const token = generateToken(); - // Resolve the display host for pairing URL const displayHost = host === '0.0.0.0' ? getLocalIp() : host; const pairingUrl = buildPairingUrl({ host: displayHost, @@ -150,10 +81,10 @@ export async function serveCommand(options: ServeOptions): Promise { token, }); - // Create HTTPS server const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => { - const authHeader = req.headers.authorization; - if (authHeader !== `Bearer ${token}`) { + 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; diff --git a/src/core/tls.ts b/src/core/tls.ts new file mode 100644 index 0000000..8b402cc --- /dev/null +++ b/src/core/tls.ts @@ -0,0 +1,71 @@ +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 { + 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); + + 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): 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=IP:127.0.0.1,IP:::1', + ], { ...execaEnv, stdio: 'pipe' }); + return await fs.readFile(tmpCert, 'utf-8'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +export function getCertFingerprint(certPem: string): string { + const x509 = new X509Certificate(certPem); + return x509.fingerprint256; +} From aefca80284b59027e1225874492096f8a1147cd7 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:40:17 -0600 Subject: [PATCH 3/3] Harden serve TLS setup and fix typecheck regression --- src/cli.ts | 10 ++++++- src/commands/spawn.test.ts | 5 ++-- src/core/tls.ts | 61 ++++++++++++++++++++++++++++---------- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 916b66a..e90dd9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -266,7 +266,7 @@ worktreeCmd program .command('serve') .description('Start the API server with TLS and display pairing QR code') - .option('-p, --port ', 'Port to listen on', (v: string) => Number(v), 7700) + .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') @@ -384,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/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 index 8b402cc..e405d00 100644 --- a/src/core/tls.ts +++ b/src/core/tls.ts @@ -24,27 +24,31 @@ export async function ensureTlsCerts(projectRoot: string): Promise { +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'); @@ -57,7 +61,7 @@ async function generateSelfSignedCert(keyPem: string): Promise { '-out', tmpCert, '-days', '365', '-subj', '/CN=ppg-server', - '-addext', 'subjectAltName=IP:127.0.0.1,IP:::1', + '-addext', subjectAltName, ], { ...execaEnv, stdio: 'pipe' }); return await fs.readFile(tmpCert, 'utf-8'); } finally { @@ -65,6 +69,31 @@ async function generateSelfSignedCert(keyPem: string): Promise { } } +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;