diff --git a/ios-qa/daemon/src/allowlist.ts b/ios-qa/daemon/src/allowlist.ts new file mode 100644 index 000000000..8c79ba41e --- /dev/null +++ b/ios-qa/daemon/src/allowlist.ts @@ -0,0 +1,107 @@ +// Allowlist file at ~/.gstack/ios-qa-allowlist.json. The single source of +// truth for who can call what at which capability tier. +// +// Self-service mint over tailnet ONLY succeeds for identities present in the +// allowlist. Owner-granted mint (CLI on the Mac) is what writes new entries +// to the allowlist. Self-service mint NEVER auto-allowlists. + +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import type { Allowlist, AllowlistEntry, Capability } from './types'; +import { capabilityCovers } from './types'; + +export function defaultAllowlistPath(): string { + return process.env.GSTACK_IOS_ALLOWLIST_PATH + ?? join(homedir(), '.gstack', 'ios-qa-allowlist.json'); +} + +export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as Allowlist; + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + throw new Error('invalid_allowlist'); + } + return parsed; + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') { + return { version: 1, entries: [] }; + } + throw err; + } +} + +export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, JSON.stringify(allowlist, null, 2) + '\n', { mode: 0o600 }); +} + +/** + * Look up an identity in the allowlist. Returns the entry if present AND + * not expired. Lookup is exact-match on canonicalized identity. + */ +export function findEntry(allowlist: Allowlist, identity: string): AllowlistEntry | null { + const now = Date.now(); + for (const entry of allowlist.entries) { + if (entry.identity !== identity) continue; + if (entry.expires_at) { + const exp = Date.parse(entry.expires_at); + if (Number.isFinite(exp) && exp < now) continue; + } + return entry; + } + return null; +} + +/** + * Check whether an identity has at least the requested capability tier. + * Returns false on missing/expired entries OR insufficient tier. + */ +export function hasCapability(allowlist: Allowlist, identity: string, need: Capability): boolean { + const entry = findEntry(allowlist, identity); + if (!entry) return false; + return entry.capabilities.some(c => capabilityCovers(c, need)); +} + +/** + * Owner-granted mint path. Adds (or upgrades) an allowlist entry. + */ +export async function grantIdentity(opts: { + identity: string; + capability: Capability; + ttlSeconds?: number | null; // null/undefined = no expiry + note?: string; + path?: string; +}): Promise { + const path = opts.path ?? defaultAllowlistPath(); + const allowlist = await loadAllowlist(path); + const existingIdx = allowlist.entries.findIndex(e => e.identity === opts.identity); + const expiresAt = opts.ttlSeconds && opts.ttlSeconds > 0 + ? new Date(Date.now() + opts.ttlSeconds * 1000).toISOString() + : null; + const newEntry: AllowlistEntry = { + identity: opts.identity, + capabilities: [opts.capability], + expires_at: expiresAt, + note: opts.note, + }; + if (existingIdx >= 0) { + allowlist.entries[existingIdx] = newEntry; + } else { + allowlist.entries.push(newEntry); + } + await saveAllowlist(allowlist, path); + return allowlist; +} + +/** + * Revoke an identity from the allowlist. + */ +export async function revokeIdentity(identity: string, path: string = defaultAllowlistPath()): Promise { + const allowlist = await loadAllowlist(path); + allowlist.entries = allowlist.entries.filter(e => e.identity !== identity); + await saveAllowlist(allowlist, path); + return allowlist; +} diff --git a/ios-qa/daemon/src/audit.ts b/ios-qa/daemon/src/audit.ts new file mode 100644 index 000000000..9ee4cf739 --- /dev/null +++ b/ios-qa/daemon/src/audit.ts @@ -0,0 +1,91 @@ +// Audit + attempts logging. Reuses the same rotation primitives as +// browse/src/tunnel-denial-log.ts (10MB rotation, 5 generations). + +import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { createHash } from 'crypto'; +import type { AuditRow, AttemptRow } from './types'; + +const MAX_BYTES = 10 * 1024 * 1024; +const MAX_GENS = 5; + +export function defaultAuditPath(): string { + return process.env.GSTACK_IOS_AUDIT_PATH + ?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl'); +} + +export function defaultAttemptsPath(): string { + return process.env.GSTACK_IOS_ATTEMPTS_PATH + ?? join(homedir(), '.gstack', 'security', 'attempts.jsonl'); +} + +let _saltCache: string | null = null; + +async function loadDeviceSalt(): Promise { + if (_saltCache) return _saltCache; + const path = join(homedir(), '.gstack', 'security', 'device-salt'); + try { + _saltCache = (await readFile(path, 'utf-8')).trim(); + } catch { + // No salt; generate ephemeral. Real install writes one via /setup. + const { randomBytes } = await import('crypto'); + _saltCache = randomBytes(32).toString('hex'); + } + return _saltCache!; +} + +async function rotateIfNeeded(path: string): Promise { + try { + const s = await stat(path); + if (s.size < MAX_BYTES) return; + } catch { + return; // file doesn't exist yet + } + // Rotate: path → path.1 → path.2 → ... → path.MAX_GENS + for (let i = MAX_GENS - 1; i >= 0; i--) { + const src = i === 0 ? path : `${path}.${i}`; + const dst = `${path}.${i + 1}`; + try { + await rename(src, dst); + } catch { + // best-effort + } + } +} + +export async function writeAudit(row: AuditRow, path: string = defaultAuditPath()): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await rotateIfNeeded(path); + await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 }); +} + +export async function writeAttempt(opts: { + rawIdentity: string; + endpoint: string; + reason: AttemptRow['reason']; + path?: string; +}): Promise { + const salt = await loadDeviceSalt(); + const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16); + const row: AttemptRow = { + ts: new Date().toISOString(), + identity_canon: hash, + endpoint: opts.endpoint, + reason: opts.reason, + }; + const path = opts.path ?? defaultAttemptsPath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await rotateIfNeeded(path); + await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 }); +} + +// Sanitize-replacer for JSON responses — mirrors browse's sanitize-replacer.ts. +// Strips lone UTF-16 surrogate halves that would otherwise reach the +// Anthropic API as \uD800-style escapes and trigger 400. +export function sanitizeReplacer(_key: string, value: unknown): unknown { + if (typeof value !== 'string') return value; + // Replace lone high surrogates not followed by low surrogates, and lone + // low surrogates not preceded by high surrogates. + return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?` writing to the allowlist file. + +import { SessionTokenStore } from './session-tokens'; +import { hasCapability, loadAllowlist } from './allowlist'; +import { writeAttempt } from './audit'; +import type { Capability } from './types'; +import { capabilityCovers } from './types'; + +export interface MintRequest { + capability?: Capability; // requested tier; default 'interact' + device_udid?: string; +} + +export interface MintResponse { + session_token: string; + expires_at: number; + capability: Capability; +} + +export interface MintError { + error: 'identity_not_allowed' | 'capability_insufficient' | 'rate_limited'; +} + +export async function mintForCaller(opts: { + callerIdentity: string; + request: MintRequest; + tokenStore: SessionTokenStore; + allowlistPath?: string; + endpoint?: string; +}): Promise { + const allowlist = await loadAllowlist(opts.allowlistPath); + const wantedCap: Capability = opts.request.capability ?? 'interact'; + + // Must be in the allowlist. + if (!hasCapability(allowlist, opts.callerIdentity, 'observe')) { + await writeAttempt({ + rawIdentity: opts.callerIdentity, + endpoint: opts.endpoint ?? '/auth/mint', + reason: 'identity_not_allowed', + }); + return { error: 'identity_not_allowed' }; + } + + // Must have at least the requested capability. + if (!hasCapability(allowlist, opts.callerIdentity, wantedCap)) { + await writeAttempt({ + rawIdentity: opts.callerIdentity, + endpoint: opts.endpoint ?? '/auth/mint', + reason: 'capability_insufficient', + }); + return { error: 'capability_insufficient' }; + } + + // Find the entry to determine the highest tier they can hold. + const entry = allowlist.entries.find(e => e.identity === opts.callerIdentity); + // Mint at the requested tier, capped at the highest granted tier. + const grantedTier = entry?.capabilities.find(c => capabilityCovers(c, wantedCap)) ?? wantedCap; + + const result = opts.tokenStore.mint({ + identity: opts.callerIdentity, + capability: grantedTier, + deviceUdid: opts.request.device_udid ?? null, + origin: 'self_service', + }); + + if ('error' in result) { + await writeAttempt({ + rawIdentity: opts.callerIdentity, + endpoint: opts.endpoint ?? '/auth/mint', + reason: 'rate_limited', + }); + return { error: 'rate_limited' }; + } + + return { + session_token: result.token, + expires_at: result.expires_at, + capability: result.capability, + }; +} diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts new file mode 100644 index 000000000..d1461c4be --- /dev/null +++ b/ios-qa/daemon/src/index.ts @@ -0,0 +1,410 @@ +// gstack-ios-qa-daemon entrypoint. +// +// Two listeners: +// - Loopback (127.0.0.1 + ::1): full command surface for the spawning agent. +// - Tailnet (optional, --tailnet flag): capability-tier allowlist. +// +// The tailnet listener is opened ONLY if: +// 1. The user passed --tailnet at the CLI. +// 2. The tailscaled LocalAPI socket probe succeeds (fail-closed otherwise). +// +// All tailnet ingress is auth-gated against the SessionTokenStore. Identity +// validation uses tailscaled's WhoIs endpoint. Capability tiers come from +// types.ts. Audit + attempts logging is in audit.ts. + +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { parse as parseUrl } from 'url'; +import { tryClaim } from './single-instance'; +import { probeTailscale, whoIs } from './tailscale-localapi'; +import { SessionTokenStore } from './session-tokens'; +import { mintForCaller } from './auth-mint'; +import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; +import { writeAudit, writeAttempt, sanitizeReplacer } from './audit'; +import type { Capability } from './types'; + +interface DaemonOptions { + loopbackPort: number; + tailnetEnabled: boolean; + tailnetSocketPath?: string; + tailnetSessionTtlSeconds?: number; + pidfilePath?: string; + // Test injection + tunnelProvider?: () => Promise; + whoIsImpl?: (addr: string) => Promise<{ identity: string; raw: unknown }>; + probeImpl?: () => Promise<{ ok: boolean; reason?: string; ownIdentity?: string }>; +} + +export interface RunningDaemon { + loopbackPort: number; + tailnetPort: number | null; + tokenStore: SessionTokenStore; + close: () => Promise; +} + +export async function startDaemon(opts: DaemonOptions): Promise { + // 1. Single-instance enforcement. + const claim = await tryClaim({ port: opts.loopbackPort, path: opts.pidfilePath }); + if (!claim.claimed) { + // Existing daemon — print READY with the existing port and exit. + // The spawnAndWaitReady caller will receive this and connect to the + // existing port instead. + process.stdout.write(`READY: port=${claim.existing.port} pid=${claim.existing.pid}\n`); + return { error: 'already_running', reason: `existing daemon pid=${claim.existing.pid}` }; + } + + const tokenStore = new SessionTokenStore(); + let tunnel: DeviceTunnel | null = null; + let cachedTunnelAt = 0; + + const getTunnel = async (): Promise => { + // Cache the tunnel for 30s; refresh on demand. + if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel; + if (opts.tunnelProvider) { + tunnel = await opts.tunnelProvider(); + cachedTunnelAt = Date.now(); + } + return tunnel; + }; + + // 2. Tailnet probe (fail-closed). + const probe = opts.tailnetEnabled + ? (opts.probeImpl ? await opts.probeImpl() : await probeTailscale(opts.tailnetSocketPath)) + : null; + + if (opts.tailnetEnabled && (!probe || !probe.ok)) { + process.stderr.write(`tailnet binding refused: ${probe?.reason ?? 'probe_failed'}\n`); + // Loopback still runs. + } + + // 3. Loopback listener (full surface). + const loopbackServer = createServer(async (req, res) => { + await handleLoopback({ req, res, tokenStore, getTunnel }); + }); + // Use port 0 for OS-assigned port when test/random port collisions are a risk. + const requestedPort = opts.loopbackPort; + await listenAsync(loopbackServer, requestedPort, '127.0.0.1'); + const actualPort = (loopbackServer.address() as { port: number }).port; + + // ipv6 — bind a SECOND server to ::1 on the same actualPort. In test (port 0) + // mode this can collide; we try the actualPort first and skip ipv6 if it + // fails (tests don't exercise ::1 explicitly). + const loopbackServerV6 = createServer(async (req, res) => { + await handleLoopback({ req, res, tokenStore, getTunnel }); + }); + let v6Bound = false; + try { + await listenAsync(loopbackServerV6, actualPort, '::1'); + v6Bound = true; + } catch { + // IPv6 loopback bind failed (port collision or no v6 on host). Loopback + // IPv4 already serves the spawning agent. Continue. + } + + // 4. Tailnet listener (if probe succeeded). + let tailnetServer: ReturnType | null = null; + let tailnetPort: number | null = null; + if (opts.tailnetEnabled && probe?.ok) { + tailnetServer = createServer(async (req, res) => { + await handleTailnet({ + req, + res, + tokenStore, + getTunnel, + whoIsImpl: opts.whoIsImpl ?? ((addr) => whoIs(addr, opts.tailnetSocketPath)), + }); + }); + const tailnetBindAddr = process.env.GSTACK_IOS_TAILNET_BIND ?? '127.0.0.1'; + // For tailnet port: actualPort + 1 if specified, else port 0 (OS-assigned). + const requestedTailnetPort = requestedPort === 0 ? 0 : actualPort + 1; + await listenAsync(tailnetServer, requestedTailnetPort, tailnetBindAddr); + tailnetPort = (tailnetServer.address() as { port: number }).port; + } + + // 5. READY line. + process.stdout.write(`READY: port=${actualPort} pid=${process.pid}\n`); + + return { + loopbackPort: actualPort, + tailnetPort, + tokenStore, + close: async () => { + // Force-close any open connections (keep-alive sockets) before waiting + // for the listening socket itself. Otherwise close() hangs forever on + // idle clients. + const closeAll = (s: ReturnType | null | undefined) => { + if (!s) return Promise.resolve(); + (s as unknown as { closeAllConnections?: () => void }).closeAllConnections?.(); + (s as unknown as { closeIdleConnections?: () => void }).closeIdleConnections?.(); + return new Promise((resolve) => s.close(() => resolve())); + }; + await Promise.all([ + closeAll(loopbackServer), + v6Bound ? closeAll(loopbackServerV6) : Promise.resolve(), + closeAll(tailnetServer), + ]); + await claim.release(); + }, + }; +} + +function listenAsync(server: ReturnType, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off('listening', onListening); + reject(err); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, host); + }); +} + +// ───────── Handlers ───────── + +interface HandlerCtx { + req: IncomingMessage; + res: ServerResponse; + tokenStore: SessionTokenStore; + getTunnel: () => Promise; +} + +function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let overLimit = false; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes && !overLimit) { + overLimit = true; + } + if (!overLimit) chunks.push(chunk); + }); + req.on('end', () => { + if (overLimit) { + resolve({ error: 'body_too_large' }); + } else { + resolve(Buffer.concat(chunks)); + } + }); + req.on('error', (err) => { + // Resolve with empty body if upstream cut us off after limit hit. + if (overLimit) resolve({ error: 'body_too_large' }); + else reject(err); + }); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body, sanitizeReplacer); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +/** + * Loopback handler — full surface for the spawning agent. No auth (the + * loopback bind itself is the boundary). + */ +async function handleLoopback(ctx: HandlerCtx): Promise { + const { req, res, tokenStore, getTunnel } = ctx; + const url = parseUrl(req.url ?? '/'); + const path = url.pathname ?? '/'; + const method = req.method ?? 'GET'; + + try { + // /healthz — public on loopback. + if (method === 'GET' && path === '/healthz') { + sendJson(res, 200, { version: '1.0.0', mode: 'loopback' }); + return; + } + + // /auth/sessions — list active sessions (owner only). + if (method === 'GET' && path === '/auth/sessions') { + sendJson(res, 200, { sessions: tokenStore.list() }); + return; + } + + // /auth/revoke — revoke a token. + if (method === 'POST' && path === '/auth/revoke') { + const body = await readBody(req); + if ('error' in body) { sendJson(res, 413, body); return; } + const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string }; + let count = 0; + if (parsed.token) { + count = tokenStore.revoke(parsed.token) ? 1 : 0; + } else if (parsed.identity) { + count = tokenStore.revokeByIdentity(parsed.identity); + } + sendJson(res, 200, { revoked: count }); + return; + } + + // Other endpoints — proxy to the device. + const tunnel = await getTunnel(); + if (!tunnel) { + sendJson(res, 503, { error: 'device_not_connected' }); + return; + } + const body = await readBody(req); + if ('error' in body) { sendJson(res, 413, body); return; } + const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; + const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined; + const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity }); + res.writeHead(upstream.status, upstream.headers); + res.end(upstream.body); + } catch (err) { + sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message }); + } +} + +interface TailnetCtx extends HandlerCtx { + whoIsImpl: (addr: string) => Promise<{ identity: string; raw: unknown }>; +} + +/** + * Tailnet handler — locked allowlist + capability tiers. + */ +async function handleTailnet(ctx: TailnetCtx): Promise { + const { req, res, tokenStore, getTunnel, whoIsImpl } = ctx; + const url = parseUrl(req.url ?? '/'); + const path = url.pathname ?? '/'; + const method = req.method ?? 'GET'; + const route = `${method} ${path}`; + + try { + // Classify the route. + const classification = classifyRoute(method, path); + if (!classification.allowed) { + sendJson(res, 404, { error: 'endpoint_not_in_tailnet_allowlist', path }); + return; + } + const requiredCapability = classification.requiredCapability as Capability; + + // /healthz on tailnet requires auth (codex catch). + // No special-case; treated like every other observe-tier endpoint. + + // /auth/mint — special path. No bearer required; uses WhoIs. + if (method === 'POST' && path === '/auth/mint') { + const peerAddr = `${req.socket.remoteAddress}:${req.socket.remotePort}`; + let callerIdentity: string; + try { + const who = await whoIsImpl(peerAddr); + callerIdentity = who.identity; + } catch (err) { + await writeAttempt({ + rawIdentity: peerAddr, + endpoint: route, + reason: 'whois_unparseable', + }); + sendJson(res, 502, { error: 'whois_failed', detail: (err as Error).message }); + return; + } + + const body = await readBody(req); + if ('error' in body) { sendJson(res, 413, body); return; } + const parsed = JSON.parse(body.toString('utf-8') || '{}') as { capability?: Capability; device_udid?: string }; + + const result = await mintForCaller({ + callerIdentity, + request: parsed, + tokenStore, + endpoint: route, + }); + + if ('error' in result) { + const status = result.error === 'rate_limited' ? 429 : 403; + sendJson(res, status, result); + return; + } + sendJson(res, 200, result); + return; + } + + // All other endpoints: bearer auth + capability check. + const auth = req.headers['authorization'] as string | undefined; + const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null; + const validation = tokenStore.validate(token, requiredCapability); + if (!validation.ok) { + await writeAttempt({ + rawIdentity: token ? 'token:' + token.slice(0, 8) : 'no_token', + endpoint: route, + reason: validation.reason, + }); + const status = validation.reason === 'capability_insufficient' ? 403 : 401; + sendJson(res, status, { error: validation.reason }); + return; + } + + const session = validation.session; + + // Read body once + enforce limit. + const body = await readBody(req); + if ('error' in body) { sendJson(res, 413, body); return; } + + // Tailnet-only own-session revoke. + if (method === 'POST' && path === '/auth/revoke') { + tokenStore.revoke(session.token); + sendJson(res, 200, { revoked: 1 }); + return; + } + + // Proxy to device. + const tunnel = await getTunnel(); + if (!tunnel) { + sendJson(res, 503, { error: 'device_not_connected' }); + return; + } + const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null; + const upstream = await proxyToDevice({ + inbound: req, + body, + tunnel, + sessionId, + agentIdentity: session.identity, + }); + + // Audit the action (mutating endpoints only). + if (requiredCapability !== 'observe') { + await writeAudit({ + ts: new Date().toISOString(), + identity: session.identity, + device_udid: tunnel.udid, + endpoint: route, + session_id: sessionId ?? '-', + capability: session.capability, + request_id: req.headers['x-request-id']?.toString() ?? '-', + status: upstream.status, + }); + } + + res.writeHead(upstream.status, upstream.headers); + res.end(upstream.body); + } catch (err) { + sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message }); + } +} + +// CLI entry — runs when this file is executed directly, not when imported. +if (import.meta.main) { + const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10); + const tailnet = process.argv.includes('--tailnet'); + startDaemon({ + loopbackPort: port, + tailnetEnabled: tailnet, + }).then((d) => { + if ('error' in d) { + process.stderr.write(`daemon error: ${d.error}\n`); + process.exit(0); // exit 0 because READY was already printed + } + }).catch((err) => { + process.stderr.write(`daemon fatal: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/ios-qa/daemon/src/proxy.ts b/ios-qa/daemon/src/proxy.ts new file mode 100644 index 000000000..143188699 --- /dev/null +++ b/ios-qa/daemon/src/proxy.ts @@ -0,0 +1,111 @@ +// Tailnet → USB proxy. When an authenticated request hits the tailnet +// listener and clears capability + allowlist checks, the daemon forwards it +// to the iOS StateServer over the device's CoreDevice IPv6 tunnel, injecting +// the rotated boot token in Authorization: Bearer and preserving the +// X-Session-Id from the caller. + +import { request as httpRequest } from 'http'; +import type { ServerResponse, IncomingMessage } from 'http'; +import { sanitizeReplacer } from './audit'; +import { tierForRoute } from './types'; + +const MAX_BODY = 1_048_576; // 1MB hard cap on tailnet ingress + +export interface DeviceTunnel { + udid: string; + ipv6Addr: string; + port: number; + bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer +} + +export interface ProxyError { + status: number; + body: Record; +} + +/** + * Forward a parsed inbound request to the StateServer. Returns the upstream + * response or a ProxyError. Caller writes to the ServerResponse. + */ +export async function proxyToDevice(opts: { + inbound: IncomingMessage; + body: Buffer; + tunnel: DeviceTunnel; + sessionId: string | null; + agentIdentity?: string; +}): Promise<{ status: number; headers: Record; body: Buffer }> { + const { inbound, body, tunnel, sessionId, agentIdentity } = opts; + if (body.length > MAX_BODY) { + return makeError(413, 'body_too_large'); + } + + const headers: Record = { + 'authorization': `Bearer ${tunnel.bootTokenRotated}`, + 'content-type': inbound.headers['content-type'] || 'application/json', + 'content-length': String(body.length), + }; + if (sessionId) headers['x-session-id'] = sessionId; + if (agentIdentity) headers['x-agent-identity'] = agentIdentity; + + // Bracket IPv6 literals; pass IPv4 + hostnames bare. The CoreDevice tunnel + // is always IPv6 in production, but tests inject 127.0.0.1 to talk to a + // local stub. Detect by `:` count (IPv6 has multiple colons) or `:` absence + // (IPv4/hostname). + const isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2; + const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr; + const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`; + return new Promise((resolve, reject) => { + const req = httpRequest(url, { + method: inbound.method, + headers, + timeout: 30_000, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const respHeaders: Record = {}; + for (const [k, v] of Object.entries(res.headers)) { + if (typeof v === 'string') respHeaders[k] = v; + } + resolve({ + status: res.statusCode ?? 502, + headers: respHeaders, + body: Buffer.concat(chunks), + }); + }); + }); + req.on('error', (err) => { + const e = err as { code?: string }; + if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') { + resolve(makeError(503, 'device_disconnected')); + } else if (e.code === 'ETIMEDOUT') { + resolve(makeError(504, 'upstream_timeout')); + } else { + reject(err); + } + }); + req.write(body); + req.end(); + }); +} + +function makeError(status: number, error: string): { status: number; headers: Record; body: Buffer } { + const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer)); + return { + status, + headers: { 'content-type': 'application/json', 'content-length': String(body.length) }, + body, + }; +} + +/** + * Determine whether the endpoint is allowed on the tailnet listener AND what + * capability tier it requires. + */ +export function classifyRoute(method: string, path: string): { + allowed: boolean; + requiredCapability: ReturnType; +} { + const tier = tierForRoute(method, path); + return { allowed: tier !== null, requiredCapability: tier }; +} diff --git a/ios-qa/daemon/src/session-tokens.ts b/ios-qa/daemon/src/session-tokens.ts new file mode 100644 index 000000000..de7f0d954 --- /dev/null +++ b/ios-qa/daemon/src/session-tokens.ts @@ -0,0 +1,126 @@ +// Short-lived session token store. In-memory only (never disk). Refreshable +// via /session/heartbeat. Listable and revokable from loopback listener. + +import { randomBytes } from 'crypto'; +import type { Capability, SessionToken } from './types'; +import { capabilityCovers } from './types'; + +const TOKEN_BYTES = 32; // 256-bit +const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h per D9 +const MAX_TTL_MS = 24 * 60 * 60 * 1000; // 24h hard cap + +export class SessionTokenStore { + private tokens = new Map(); + private mintsPerIdentity = new Map(); // ts (ms) for rate limiting + + constructor( + private now: () => number = () => Date.now(), + ) {} + + /** + * Mint a session token. Returns null on rate limit. + */ + mint(opts: { + identity: string; + capability: Capability; + ttlMs?: number; + deviceUdid?: string | null; + origin: SessionToken['origin']; + }): SessionToken | { error: 'rate_limited' } { + if (!this.checkRateLimit(opts.identity)) { + return { error: 'rate_limited' }; + } + const ttl = Math.min(opts.ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS); + const token = randomBytes(TOKEN_BYTES).toString('base64url'); + const expires_at = this.now() + ttl; + const session: SessionToken = { + token, + identity: opts.identity, + capability: opts.capability, + expires_at, + device_udid: opts.deviceUdid ?? null, + origin: opts.origin, + }; + this.tokens.set(token, session); + return session; + } + + /** + * Validate a token. Returns the session if valid (token exists, not + * expired). Otherwise returns null with a reason for the audit log. + */ + validate(token: string | null | undefined, need: Capability): + | { ok: true; session: SessionToken } + | { ok: false; reason: 'no_token' | 'invalid_token' | 'expired_token' | 'capability_insufficient' } { + if (!token) return { ok: false, reason: 'no_token' }; + const s = this.tokens.get(token); + if (!s) return { ok: false, reason: 'invalid_token' }; + if (s.expires_at < this.now()) { + this.tokens.delete(token); + return { ok: false, reason: 'expired_token' }; + } + if (!capabilityCovers(s.capability, need)) { + return { ok: false, reason: 'capability_insufficient' }; + } + return { ok: true, session: s }; + } + + /** + * Slide token expiry forward by ttlMs. Caps at the token's original max + * (which itself is bounded by MAX_TTL_MS). Returns the new expiry. + */ + heartbeat(token: string, ttlMs?: number): number | null { + const s = this.tokens.get(token); + if (!s) return null; + if (s.expires_at < this.now()) { + this.tokens.delete(token); + return null; + } + const newExpiry = this.now() + Math.min(ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS); + s.expires_at = newExpiry; + return newExpiry; + } + + revoke(token: string): boolean { + return this.tokens.delete(token); + } + + revokeByIdentity(identity: string): number { + let count = 0; + for (const [token, s] of this.tokens) { + if (s.identity === identity) { + this.tokens.delete(token); + count++; + } + } + return count; + } + + list(): SessionToken[] { + return [...this.tokens.values()]; + } + + // For tests: clear all state. + reset() { + this.tokens.clear(); + this.mintsPerIdentity.clear(); + } + + /** + * Rate limit: 10 mints / 60s per identity. Sliding window. + */ + private checkRateLimit(identity: string): boolean { + const now = this.now(); + const window = 60_000; + const limit = 10; + const hits = this.mintsPerIdentity.get(identity) ?? []; + const recent = hits.filter(t => now - t < window); + if (recent.length >= limit) { + this.mintsPerIdentity.set(identity, recent); + return false; + } + recent.push(now); + this.mintsPerIdentity.set(identity, recent); + return true; + } +} diff --git a/ios-qa/daemon/src/single-instance.ts b/ios-qa/daemon/src/single-instance.ts new file mode 100644 index 000000000..0196b0032 --- /dev/null +++ b/ios-qa/daemon/src/single-instance.ts @@ -0,0 +1,171 @@ +// Single-instance enforcement. Daemon takes an exclusive flock on +// ~/.gstack/ios-qa-daemon.pid on startup. Second invocation discovers the +// existing daemon's port + connects. Stale lock (PID dead) is reclaimed. +// +// Readiness protocol: daemon writes `READY: port= pid=` to stdout +// once both listeners are up; the spawner reads stdout with a 5s timeout. + +import { readFile, mkdir, unlink } from 'fs/promises'; +import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { spawn } from 'child_process'; + +export interface PidfileContents { + pid: number; + port: number; + startedAt: number; +} + +export function defaultPidfilePath(): string { + return process.env.GSTACK_IOS_DAEMON_PIDFILE + ?? join(homedir(), '.gstack', 'ios-qa-daemon.pid'); +} + +/** + * Try to claim the pidfile. Returns: + * - { claimed: true } when this process now owns the lock + * - { claimed: false, existing } when another live daemon holds it + * + * The "live" check is process.kill(pid, 0): succeeds if the PID exists, + * fails with ESRCH if not. We DO NOT trust a stale pidfile. + */ +export async function tryClaim(opts: { + port: number; + path?: string; +}): Promise< + | { claimed: true; release: () => Promise } + | { claimed: false; existing: PidfileContents } +> { + const path = opts.path ?? defaultPidfilePath(); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + + // Check for an existing pidfile. + if (existsSync(path)) { + try { + const raw = await readFile(path, 'utf-8'); + const existing = JSON.parse(raw) as PidfileContents; + if (isAlive(existing.pid)) { + return { claimed: false, existing }; + } + // Stale — drop it and continue to claim. + await unlink(path).catch(() => {}); + } catch { + // Unparseable pidfile — treat as stale. + await unlink(path).catch(() => {}); + } + } + + // Use SYNCHRONOUS open with O_EXCL for atomic exclusion. Bun's async + // fs.open(wx) doesn't reliably preserve O_EXCL semantics across concurrent + // calls in the same process. Sync openSync goes straight to syscall and is + // genuinely atomic. + // + // Constant 0x800 = O_EXCL on macOS/Linux; combined with O_CREAT (0x200) and + // O_WRONLY (0x1) it's the equivalent of 'wx'. The sync API accepts the + // string flag form too, but explicit numeric flags are the most defensive. + const contents: PidfileContents = { + pid: process.pid, + port: opts.port, + startedAt: Date.now(), + }; + let fd: number; + try { + fd = openSync(path, 'wx', 0o600); + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'EEXIST') { + // Race: another caller won. + const raw = await readFile(path, 'utf-8').catch(() => '{}'); + const existing = JSON.parse(raw || '{}') as PidfileContents; + return { claimed: false, existing }; + } + throw err; + } + try { + writeSync(fd, JSON.stringify(contents, null, 2)); + } finally { + closeSync(fd); + } + + // Cleanup on exit. + const cleanup = async () => { + try { + // Verify we still own it before unlinking. + const raw = await readFile(path, 'utf-8'); + const cur = JSON.parse(raw) as PidfileContents; + if (cur.pid === process.pid) { + await unlink(path); + } + } catch { + // best-effort + } + }; + + process.on('exit', () => { + try { unlinkSync(path); } catch { /* ignore */ } + }); + process.on('SIGINT', () => { cleanup().finally(() => process.exit(0)); }); + process.on('SIGTERM', () => { cleanup().finally(() => process.exit(0)); }); + + return { claimed: true, release: cleanup }; +} + +function isAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + const e = err as { code?: string }; + return e.code !== 'ESRCH'; + } +} + +/** + * Spawn a daemon process and wait for the READY line. Returns the port the + * daemon claims to be listening on. + * + * Used by /ios-qa skill to spawn-on-demand. If another daemon is already + * running, the spawned child detects the existing pidfile and prints a + * READY line with the existing port (loaded from the pidfile). + */ +export async function spawnAndWaitReady(opts: { + cmd: string; + args: string[]; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; +}): Promise<{ pid: number; port: number }> { + const timeoutMs = opts.timeoutMs ?? 5000; + const child = spawn(opts.cmd, opts.args, { + stdio: ['ignore', 'pipe', 'inherit'], + detached: true, + env: opts.env ?? process.env, + }); + + return new Promise((resolve, reject) => { + let buffer = ''; + const onTimeout = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`daemon spawn timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const match = buffer.match(/READY:\s*port=(\d+)\s+pid=(\d+)/); + if (match) { + clearTimeout(onTimeout); + child.unref(); + resolve({ pid: parseInt(match[2]!, 10), port: parseInt(match[1]!, 10) }); + } + }); + child.on('error', (err) => { + clearTimeout(onTimeout); + reject(err); + }); + child.on('exit', (code, signal) => { + clearTimeout(onTimeout); + reject(new Error(`daemon exited before READY (code=${code} signal=${signal})`)); + }); + }); +} diff --git a/ios-qa/daemon/src/tailscale-localapi.ts b/ios-qa/daemon/src/tailscale-localapi.ts new file mode 100644 index 000000000..526e81ab8 --- /dev/null +++ b/ios-qa/daemon/src/tailscale-localapi.ts @@ -0,0 +1,120 @@ +// tailscaled LocalAPI client. Reads the unix socket at /var/run/tailscale.sock +// (or wherever tailscaled is listening), calls WhoIs, returns a canonicalized +// identity string. +// +// **Fail-closed semantics:** every error path here MUST be surfaced as a +// reason the tailnet listener should refuse to open. Daemon caller must +// distinguish "socket missing" (Tailscale not installed) from "WhoIs returned +// unparseable response" (Tailscale broken) so the user knows what to fix. + +import { request as httpRequest } from 'http'; +import type { WhoIsResult } from './types'; + +export interface TailscaleProbe { + ok: boolean; + reason?: 'socket_missing' | 'permission_denied' | 'whois_unparseable' | 'unreachable'; + ownIdentity?: string; +} + +/** + * Probe whether tailscaled LocalAPI is usable. Called before opening the + * tailnet listener. Returns ok=true only if WhoIs against the daemon's own + * identity returns a parseable result. + */ +export async function probeTailscale(socketPath: string = '/var/run/tailscale.sock'): Promise { + try { + const result = await whoIs('127.0.0.1:9999', socketPath); + return { ok: true, ownIdentity: result.identity }; + } catch (err: unknown) { + const e = err as { code?: string; message?: string }; + if (e.code === 'ENOENT' || (e.message ?? '').includes('ENOENT')) { + return { ok: false, reason: 'socket_missing' }; + } + if (e.code === 'EACCES' || (e.message ?? '').includes('EACCES')) { + return { ok: false, reason: 'permission_denied' }; + } + if ((e.message ?? '').includes('unparseable') || (e.message ?? '').includes('JSON')) { + return { ok: false, reason: 'whois_unparseable' }; + } + return { ok: false, reason: 'unreachable' }; + } +} + +/** + * Call /localapi/v0/whois?addr=. Returns canonicalized identity. + * + * Canonicalization rules (matches Tailscale convention): + * - User OAuth: `user@example.com` (no acct: prefix, lowercase email) + * - Tagged nodes: `tag:` (lowercased) + * - Node keys: `node:` (rare, prefer tags) + */ +export async function whoIs(addr: string, socketPath: string = '/var/run/tailscale.sock'): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest({ + socketPath, + path: `/localapi/v0/whois?addr=${encodeURIComponent(addr)}`, + method: 'GET', + headers: { Host: 'local-tailscaled.sock' }, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`whois http ${res.statusCode}`)); + return; + } + try { + const raw = Buffer.concat(chunks).toString('utf-8'); + const obj = JSON.parse(raw) as Record; + const identity = canonicalize(obj); + if (!identity) { + reject(new Error('whois response unparseable')); + return; + } + resolve({ identity, raw: obj }); + } catch (e) { + reject(new Error(`whois response unparseable: ${(e as Error).message}`)); + } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +/** + * Reduce a WhoIs response object to a canonical identity string. + * + * Expected response shape (Tailscale LocalAPI v0): + * { + * "Node": { "ComputedName": "...", "Tags": ["tag:ci"], ... }, + * "UserProfile": { "LoginName": "user@example.com", ... }, + * } + */ +export function canonicalize(obj: Record): string | null { + // Tagged node — tag is more specific than user identity for ACL purposes. + const node = obj.Node as Record | undefined; + if (node) { + const tags = node.Tags as string[] | undefined; + if (Array.isArray(tags) && tags.length > 0 && typeof tags[0] === 'string') { + const tag = tags[0].toLowerCase(); + // Tags from Tailscale are already in `tag:foo` form. + return tag.startsWith('tag:') ? tag : `tag:${tag}`; + } + } + const profile = obj.UserProfile as Record | undefined; + if (profile) { + const loginName = profile.LoginName as string | undefined; + if (typeof loginName === 'string' && loginName.includes('@')) { + return loginName.toLowerCase(); + } + } + // Fallback to node key — rare but possible. + if (node) { + const key = node.Key as string | undefined; + if (typeof key === 'string' && key.startsWith('nodekey:')) { + return `node:${key.replace('nodekey:', '')}`; + } + } + return null; +} diff --git a/ios-qa/daemon/src/types.ts b/ios-qa/daemon/src/types.ts new file mode 100644 index 000000000..b6c747dd6 --- /dev/null +++ b/ios-qa/daemon/src/types.ts @@ -0,0 +1,91 @@ +// Shared types for the ios-qa daemon. + +export type Capability = 'observe' | 'interact' | 'mutate' | 'restore'; + +export const CAPABILITY_ORDER: Record = { + observe: 0, + interact: 1, + mutate: 2, + restore: 3, +}; + +export function capabilityCovers(have: Capability, need: Capability): boolean { + return CAPABILITY_ORDER[have] >= CAPABILITY_ORDER[need]; +} + +export interface AllowlistEntry { + identity: string; + capabilities: Capability[]; + expires_at: string | null; + note?: string; +} + +export interface Allowlist { + version: 1; + entries: AllowlistEntry[]; +} + +export interface SessionToken { + token: string; + identity: string; + capability: Capability; + expires_at: number; // epoch ms + device_udid: string | null; + origin: 'self_service' | 'owner_granted'; +} + +export interface AuditRow { + ts: string; + identity: string; + device_udid: string; + endpoint: string; + session_id: string; + capability: Capability; + request_id: string; + status: number; +} + +export interface AttemptRow { + ts: string; + identity_canon: string; // sha256 salted — never the raw identity + endpoint: string; + reason: 'no_token' | 'invalid_token' | 'expired_token' | 'identity_not_allowed' | + 'capability_insufficient' | 'rate_limited' | 'allowlist_violation' | + 'tailnet_socket_missing' | 'whois_unparseable'; +} + +export interface WhoIsResult { + identity: string; // canonicalized: "user@example.com" or "tag:" or "node:" + raw: unknown; +} + +// Path allowlist for tailnet listener — by capability tier. +// Each endpoint is mapped to the MINIMUM tier required. +export const TAILNET_ENDPOINT_TIERS: Record = { + 'GET /healthz': 'observe', + 'POST /auth/mint': 'observe', // any allowlisted caller can attempt; daemon then filters by tier + 'POST /auth/revoke': 'observe', // own-session revoke + 'GET /screenshot': 'observe', + 'GET /elements': 'observe', + 'GET /state/snapshot': 'observe', + 'GET /state/*': 'observe', + 'POST /session/acquire': 'interact', + 'POST /session/release': 'interact', + 'POST /session/heartbeat': 'interact', + 'POST /tap': 'interact', + 'POST /swipe': 'interact', + 'POST /type': 'interact', + 'POST /state/*': 'mutate', + 'POST /state/restore': 'restore', +}; + +export function tierForRoute(method: string, path: string): Capability | null { + const exact = `${method} ${path}`; + if (TAILNET_ENDPOINT_TIERS[exact]) return TAILNET_ENDPOINT_TIERS[exact]; + // Wildcard /state/* + if (path.startsWith('/state/') && path !== '/state/snapshot' && path !== '/state/restore') { + if (method === 'GET') return 'observe'; + if (method === 'POST') return 'mutate'; + } + return null; // not allowlisted on tailnet +} diff --git a/ios-qa/daemon/test/allowlist.test.ts b/ios-qa/daemon/test/allowlist.test.ts new file mode 100644 index 000000000..f670e3ceb --- /dev/null +++ b/ios-qa/daemon/test/allowlist.test.ts @@ -0,0 +1,146 @@ +// Allowlist tests — codex flagged identity canonicalization gaps. + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + loadAllowlist, + findEntry, + hasCapability, + grantIdentity, + revokeIdentity, + saveAllowlist, +} from '../src/allowlist'; + +let tmpDir: string; +let listPath: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-allowlist-')); + listPath = join(tmpDir, 'allowlist.json'); +}); + +describe('Allowlist', () => { + test('loadAllowlist returns empty on missing file', async () => { + const list = await loadAllowlist(listPath); + expect(list).toEqual({ version: 1, entries: [] }); + }); + + test('saveAllowlist writes mode 0600 JSON', async () => { + await saveAllowlist({ + version: 1, + entries: [{ identity: 'user@example.com', capabilities: ['observe'], expires_at: null }], + }, listPath); + expect(existsSync(listPath)).toBe(true); + const raw = readFileSync(listPath, 'utf-8'); + expect(JSON.parse(raw).entries[0].identity).toBe('user@example.com'); + }); + + test('findEntry matches exact identity', async () => { + const list = { + version: 1 as const, + entries: [{ identity: 'user@example.com', capabilities: ['mutate' as const], expires_at: null }], + }; + expect(findEntry(list, 'user@example.com')?.identity).toBe('user@example.com'); + expect(findEntry(list, 'USER@example.com')).toBeNull(); // exact-match only + expect(findEntry(list, 'unknown@example.com')).toBeNull(); + }); + + test('findEntry skips expired entries', async () => { + const list = { + version: 1 as const, + entries: [ + { identity: 'expired', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 60_000).toISOString() }, + ], + }; + expect(findEntry(list, 'expired')).toBeNull(); + }); + + test('findEntry accepts future expiry', async () => { + const list = { + version: 1 as const, + entries: [ + { identity: 'future', capabilities: ['observe' as const], expires_at: new Date(Date.now() + 60_000).toISOString() }, + ], + }; + expect(findEntry(list, 'future')?.identity).toBe('future'); + }); + + test('hasCapability is tier-aware', async () => { + const list = { + version: 1 as const, + entries: [ + { identity: 'restore-user', capabilities: ['restore' as const], expires_at: null }, + { identity: 'observe-user', capabilities: ['observe' as const], expires_at: null }, + ], + }; + expect(hasCapability(list, 'restore-user', 'observe')).toBe(true); + expect(hasCapability(list, 'restore-user', 'interact')).toBe(true); + expect(hasCapability(list, 'restore-user', 'mutate')).toBe(true); + expect(hasCapability(list, 'restore-user', 'restore')).toBe(true); + expect(hasCapability(list, 'observe-user', 'observe')).toBe(true); + expect(hasCapability(list, 'observe-user', 'interact')).toBe(false); + expect(hasCapability(list, 'observe-user', 'mutate')).toBe(false); + expect(hasCapability(list, 'observe-user', 'restore')).toBe(false); + }); + + test('grantIdentity adds a new entry', async () => { + await grantIdentity({ + identity: 'new@example.com', + capability: 'interact', + path: listPath, + }); + const list = await loadAllowlist(listPath); + expect(list.entries).toHaveLength(1); + expect(list.entries[0]!.identity).toBe('new@example.com'); + expect(list.entries[0]!.capabilities).toContain('interact'); + }); + + test('grantIdentity upgrades an existing entry', async () => { + await grantIdentity({ identity: 'u', capability: 'observe', path: listPath }); + await grantIdentity({ identity: 'u', capability: 'restore', path: listPath }); + const list = await loadAllowlist(listPath); + expect(list.entries).toHaveLength(1); + expect(list.entries[0]!.capabilities).toContain('restore'); + }); + + test('grantIdentity with ttl sets expires_at', async () => { + await grantIdentity({ identity: 'u', capability: 'observe', ttlSeconds: 3600, path: listPath }); + const list = await loadAllowlist(listPath); + const exp = Date.parse(list.entries[0]!.expires_at!); + expect(exp).toBeGreaterThan(Date.now()); + expect(exp).toBeLessThan(Date.now() + 3700 * 1000); + }); + + test('revokeIdentity removes the entry', async () => { + await grantIdentity({ identity: 'u', capability: 'observe', path: listPath }); + await revokeIdentity('u', listPath); + const list = await loadAllowlist(listPath); + expect(list.entries).toHaveLength(0); + }); + + // Codex-flagged identity canonicalization variants — verify the matcher + // works for each. + test('user identity, tagged node, node key, expired node all canonicalize distinctly', async () => { + const list = { + version: 1 as const, + entries: [ + { identity: 'alice@example.com', capabilities: ['observe' as const], expires_at: null }, + { identity: 'tag:ci', capabilities: ['mutate' as const], expires_at: null }, + { identity: 'node:abcdef0123', capabilities: ['observe' as const], expires_at: null }, + { identity: 'bob@example.com', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 1000).toISOString() }, + ], + }; + expect(hasCapability(list, 'alice@example.com', 'observe')).toBe(true); + expect(hasCapability(list, 'tag:ci', 'mutate')).toBe(true); + expect(hasCapability(list, 'node:abcdef0123', 'observe')).toBe(true); + expect(hasCapability(list, 'bob@example.com', 'observe')).toBe(false); // expired + expect(hasCapability(list, 'tag:CI', 'mutate')).toBe(false); // case-sensitive — canonicalize before lookup + }); +}); + +import { afterEach } from 'bun:test'; +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/ios-qa/daemon/test/audit.test.ts b/ios-qa/daemon/test/audit.test.ts new file mode 100644 index 000000000..b366b1d37 --- /dev/null +++ b/ios-qa/daemon/test/audit.test.ts @@ -0,0 +1,111 @@ +// Audit + attempts logging tests. Codex-flagged: identity must be hashed in +// attempts.jsonl (no raw identity leak), rotation works, sanitize-replacer +// strips lone surrogates. + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { writeAudit, writeAttempt, sanitizeReplacer } from '../src/audit'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-audit-')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('writeAudit', () => { + test('appends a JSONL row', async () => { + const path = join(tmpDir, 'audit.jsonl'); + await writeAudit({ + ts: '2026-05-18T00:00:00Z', + identity: 'u@e.com', + device_udid: 'UDID-1', + endpoint: 'POST /tap', + session_id: 'S1', + capability: 'interact', + request_id: 'req-1', + status: 200, + }, path); + const lines = readFileSync(path, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!).identity).toBe('u@e.com'); + }); +}); + +describe('writeAttempt', () => { + test('hashes raw identity with the device salt (no raw leak)', async () => { + const auditPath = join(tmpDir, 'attempts.jsonl'); + await writeAttempt({ + rawIdentity: 'attacker@evil.com', + endpoint: 'POST /auth/mint', + reason: 'identity_not_allowed', + path: auditPath, + }); + const lines = readFileSync(auditPath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + const row = JSON.parse(lines[0]!); + expect(row.reason).toBe('identity_not_allowed'); + expect(row.identity_canon).not.toBe('attacker@evil.com'); + expect(row.identity_canon).toMatch(/^[a-f0-9]{16}$/); // 16-char hex + }); + + test('does NOT log the raw identity anywhere in the row', async () => { + const path = join(tmpDir, 'attempts.jsonl'); + await writeAttempt({ + rawIdentity: 'secret@example.com', + endpoint: 'POST /auth/mint', + reason: 'identity_not_allowed', + path, + }); + const raw = readFileSync(path, 'utf-8'); + expect(raw).not.toContain('secret@example.com'); + }); +}); + +describe('sanitizeReplacer', () => { + // Helper: check every UTF-16 code unit in a string. Returns true iff any + // unpaired surrogate is present. More reliable than .toContain('\uD800') + // since Bun's matcher does UTF-8 byte comparison for non-ASCII. + const hasUnpairedSurrogate = (s: string): boolean => { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0xD800 && c <= 0xDBFF) { + const next = s.charCodeAt(i + 1); + if (!(next >= 0xDC00 && next <= 0xDFFF)) return true; + i++; // skip the valid pair + } else if (c >= 0xDC00 && c <= 0xDFFF) { + return true; + } + } + return false; + }; + + test('replaces lone high surrogates with U+FFFD', () => { + const out = JSON.stringify({ s: 'before\uD800after' }, sanitizeReplacer); + expect(hasUnpairedSurrogate(out)).toBe(false); + expect(out.includes('�')).toBe(true); + }); + + test('replaces lone low surrogates with U+FFFD', () => { + const out = JSON.stringify({ s: 'before\uDC00after' }, sanitizeReplacer); + expect(hasUnpairedSurrogate(out)).toBe(false); + expect(out.includes('�')).toBe(true); + }); + + test('preserves valid surrogate pairs', () => { + // 😀 = U+1F600 = surrogate pair D83D DE00. Must stay intact. + const out = JSON.stringify({ s: '😀' }, sanitizeReplacer); + expect(out.includes('😀')).toBe(true); + expect(hasUnpairedSurrogate(out)).toBe(false); + expect(out.includes('�')).toBe(false); + }); + + test('passes through non-string values', () => { + expect(JSON.stringify({ n: 42, b: true, x: null }, sanitizeReplacer)).toBe('{"n":42,"b":true,"x":null}'); + }); +}); diff --git a/ios-qa/daemon/test/auth-mint.test.ts b/ios-qa/daemon/test/auth-mint.test.ts new file mode 100644 index 000000000..844c6eff9 --- /dev/null +++ b/ios-qa/daemon/test/auth-mint.test.ts @@ -0,0 +1,103 @@ +// /auth/mint endpoint tests. Codex-flagged: identity allowlist, capability +// cap, rate-limit cap, self-service vs owner-granted distinction. + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { mintForCaller } from '../src/auth-mint'; +import { SessionTokenStore } from '../src/session-tokens'; +import { grantIdentity } from '../src/allowlist'; + +let tmpDir: string; +let listPath: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-mint-')); + listPath = join(tmpDir, 'allowlist.json'); +}); + +describe('mintForCaller', () => { + test('rejects unknown identity', async () => { + const store = new SessionTokenStore(); + const r = await mintForCaller({ + callerIdentity: 'stranger@example.com', + request: { capability: 'observe' }, + tokenStore: store, + allowlistPath: listPath, + }); + expect(r).toEqual({ error: 'identity_not_allowed' }); + }); + + test('mints at the requested tier when allowlisted at that tier', async () => { + await grantIdentity({ identity: 'u@e.com', capability: 'mutate', path: listPath }); + const store = new SessionTokenStore(); + const r = await mintForCaller({ + callerIdentity: 'u@e.com', + request: { capability: 'interact' }, + tokenStore: store, + allowlistPath: listPath, + }); + expect('error' in r).toBe(false); + if ('error' in r) throw new Error('unexpected'); + expect(r.capability).toBe('mutate'); // returns the granted tier (higher covers interact) + expect(r.session_token.length).toBeGreaterThan(0); + }); + + test('refuses to mint above the allowlisted tier', async () => { + await grantIdentity({ identity: 'observe-only@e.com', capability: 'observe', path: listPath }); + const store = new SessionTokenStore(); + const r = await mintForCaller({ + callerIdentity: 'observe-only@e.com', + request: { capability: 'mutate' }, + tokenStore: store, + allowlistPath: listPath, + }); + expect(r).toEqual({ error: 'capability_insufficient' }); + }); + + test('rate limits hit at 11th mint per identity', async () => { + await grantIdentity({ identity: 'spammer@e.com', capability: 'observe', path: listPath }); + const store = new SessionTokenStore(); + let lastError: unknown = null; + let success = 0; + for (let i = 0; i < 11; i++) { + const r = await mintForCaller({ + callerIdentity: 'spammer@e.com', + request: { capability: 'observe' }, + tokenStore: store, + allowlistPath: listPath, + }); + if ('error' in r) lastError = r; + else success++; + } + expect(success).toBe(10); + expect(lastError).toEqual({ error: 'rate_limited' }); + }); + + test('expired allowlist entries reject the mint', async () => { + // Write an expired entry directly. + const { saveAllowlist } = await import('../src/allowlist'); + await saveAllowlist({ + version: 1, + entries: [{ + identity: 'expired@e.com', + capabilities: ['restore'], + expires_at: new Date(Date.now() - 60_000).toISOString(), + }], + }, listPath); + const store = new SessionTokenStore(); + const r = await mintForCaller({ + callerIdentity: 'expired@e.com', + request: { capability: 'observe' }, + tokenStore: store, + allowlistPath: listPath, + }); + expect(r).toEqual({ error: 'identity_not_allowed' }); + }); +}); + +import { afterEach } from 'bun:test'; +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts new file mode 100644 index 000000000..b131cc3ff --- /dev/null +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -0,0 +1,350 @@ +// End-to-end daemon integration tests. Starts a real daemon against a stub +// StateServer + mocked tailscaled. Exercises: +// +// - Loopback listener responses +// - Tailnet listener fail-closed when probe fails +// - Tailnet → USB proxy forwards bearer + X-Session-Id +// - Capability tier enforcement (interact → /tap ok, observe → /tap 403) +// - Rate limit on /auth/mint +// - Tailnet listener never binds 0.0.0.0 +// - Boot token never leaks in responses + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { createServer } from 'http'; +import type { Server, IncomingMessage } from 'http'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startDaemon, type RunningDaemon } from '../src/index'; +import { grantIdentity } from '../src/allowlist'; +import type { DeviceTunnel } from '../src/proxy'; + +let workDir: string; +const STATE_SERVER_TOKEN = 'rotated-mock-token-XXXXXXXX'; + +// Stub iOS StateServer running on loopback. Mimics the real Swift server's +// behavior for the integration test. +function startStubStateServer(): Promise<{ server: Server; port: number; receivedRequests: Array<{ method: string; path: string; headers: Record; body: string }> }> { + return new Promise((resolve) => { + const received: Array<{ method: string; path: string; headers: Record; body: string }> = []; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf-8'); + received.push({ method: req.method ?? '', path: req.url ?? '', headers: req.headers, body }); + + const auth = req.headers['authorization']; + // Validate the bearer is our rotated token. + if (!auth || auth !== `Bearer ${STATE_SERVER_TOKEN}`) { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + + if (req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ version: '1.0.0' })); + return; + } + if (req.url === '/screenshot') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ png_base64: 'abc=' })); + return; + } + if (req.url === '/tap') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, op: 'tap' })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + }); + }); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ server, port, receivedRequests: received }); + }); + }); +} + +async function fetchWith(method: string, url: string, init: { headers?: Record; body?: string } = {}): Promise<{ status: number; bodyText: string }> { + const res = await fetch(url, { method, headers: init.headers, body: init.body }); + return { status: res.status, bodyText: await res.text() }; +} + +describe('daemon — loopback listener', () => { + let stub: Awaited>; + let daemon: RunningDaemon; + let pidPath: string; + + beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-loopback-')); + pidPath = join(workDir, 'daemon.pid'); + stub = await startStubStateServer(); + + const tunnel: DeviceTunnel = { + udid: 'STUB-UDID', + ipv6Addr: '127.0.0.1', + port: stub.port, + bootTokenRotated: STATE_SERVER_TOKEN, + }; + + const d = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: false, + pidfilePath: pidPath, + tunnelProvider: async () => tunnel, + }); + if ('error' in d) throw new Error(d.error); + daemon = d; + }); + + afterAll(async () => { + await daemon?.close(); + stub.server.close(); + rmSync(workDir, { recursive: true, force: true }); + }); + + test('healthz returns 200 with mode=loopback', async () => { + const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`); + expect(r.status).toBe(200); + expect(JSON.parse(r.bodyText)).toMatchObject({ mode: 'loopback' }); + }); + + test('proxies /screenshot to stub StateServer with the rotated bearer', async () => { + const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/screenshot`); + expect(r.status).toBe(200); + expect(JSON.parse(r.bodyText)).toEqual({ png_base64: 'abc=' }); + // Verify the stub received the rotated token, NOT a passthrough or empty token. + const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1]; + expect(lastReq?.headers['authorization']).toBe(`Bearer ${STATE_SERVER_TOKEN}`); + }); + + test('proxies X-Session-Id passthrough on /tap', async () => { + const r = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/tap`, { + headers: { 'x-session-id': 'sess-loopback-1', 'content-type': 'application/json' }, + body: JSON.stringify({ x: 100, y: 200 }), + }); + expect(r.status).toBe(200); + const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1]; + expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1'); + }); + + test('returns 503 when no device tunnel is provided', async () => { + // Force tunnel provider to return null by closing + restarting with null provider. + await daemon.close(); + pidPath = join(workDir, 'daemon-2.pid'); + const d2 = await startDaemon({ + loopbackPort: daemon.loopbackPort + 1, + tailnetEnabled: false, + pidfilePath: pidPath, + tunnelProvider: async () => null, + }); + if ('error' in d2) throw new Error(d2.error); + try { + const r = await fetchWith('GET', `http://127.0.0.1:${d2.loopbackPort}/screenshot`); + expect(r.status).toBe(503); + } finally { + await d2.close(); + } + }); +}); + +describe('daemon — tailnet listener (mocked tailscaled)', () => { + let stub: Awaited>; + let daemon: RunningDaemon; + let listPath: string; + let pidPath: string; + + beforeEach(async () => { + workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-tailnet-')); + listPath = join(workDir, 'allowlist.json'); + pidPath = join(workDir, 'daemon.pid'); + stub = await startStubStateServer(); + + const tunnel: DeviceTunnel = { + udid: 'STUB-UDID', + ipv6Addr: '127.0.0.1', + port: stub.port, + bootTokenRotated: STATE_SERVER_TOKEN, + }; + + process.env.GSTACK_IOS_ALLOWLIST_PATH = listPath; + process.env.GSTACK_IOS_AUDIT_PATH = join(workDir, 'audit.jsonl'); + process.env.GSTACK_IOS_ATTEMPTS_PATH = join(workDir, 'attempts.jsonl'); + process.env.GSTACK_IOS_TAILNET_BIND = '127.0.0.1'; // safe test bind + + const d = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: true, + pidfilePath: pidPath, + tunnelProvider: async () => tunnel, + probeImpl: async () => ({ ok: true, ownIdentity: 'mac@example.com' }), + whoIsImpl: async () => ({ identity: 'caller@example.com', raw: {} }), + }); + if ('error' in d) throw new Error(d.error); + daemon = d; + }); + + afterEach(async () => { + if (daemon) await daemon.close(); + delete process.env.GSTACK_IOS_ALLOWLIST_PATH; + delete process.env.GSTACK_IOS_AUDIT_PATH; + delete process.env.GSTACK_IOS_ATTEMPTS_PATH; + delete process.env.GSTACK_IOS_TAILNET_BIND; + if (workDir) rmSync(workDir, { recursive: true, force: true }); + stub.server.close(); + }); + + test('tailnet listener refuses to open when probe fails', async () => { + await daemon.close(); + pidPath = join(workDir, 'daemon-fail.pid'); + const d = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: true, + pidfilePath: pidPath, + tunnelProvider: async () => null, + probeImpl: async () => ({ ok: false, reason: 'socket_missing' }), + }); + if ('error' in d) throw new Error(d.error); + try { + // Tailnet port should not exist (no listener). + expect(d.tailnetPort).toBeNull(); + // Loopback still works. + const r = await fetchWith('GET', `http://127.0.0.1:${d.loopbackPort}/healthz`); + expect(r.status).toBe(200); + } finally { + await d.close(); + } + }); + + test('non-allowlisted endpoint returns 404 on tailnet', async () => { + const r = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/auth/sessions`); + expect(r.status).toBe(404); + expect(JSON.parse(r.bodyText).error).toBe('endpoint_not_in_tailnet_allowlist'); + }); + + test('/auth/mint rejects unknown identity (mocked WhoIs)', async () => { + const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'observe' }), + }); + expect(r.status).toBe(403); + expect(JSON.parse(r.bodyText).error).toBe('identity_not_allowed'); + }); + + test('/auth/mint succeeds for allowlisted identity, then proxies are bearer-gated', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath }); + const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'interact' }), + }); + expect(mintR.status).toBe(200); + const { session_token } = JSON.parse(mintR.bodyText); + expect(typeof session_token).toBe('string'); + + // Use the token to call /tap. + const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, { + headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' }, + body: JSON.stringify({ x: 1, y: 2 }), + }); + expect(tapR.status).toBe(200); + + // Call without bearer → 401. + const tapNoAuth = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ x: 1 }), + }); + expect(tapNoAuth.status).toBe(401); + }); + + test('capability tier enforced — observe token cannot call /tap (interact-tier)', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath }); + const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'observe' }), + }); + const { session_token } = JSON.parse(mintR.bodyText); + + const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, { + headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' }, + body: JSON.stringify({ x: 1, y: 2 }), + }); + expect(tapR.status).toBe(403); + expect(JSON.parse(tapR.bodyText).error).toBe('capability_insufficient'); + }); + + test('rate limit kicks in at 11th /auth/mint per identity', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath }); + let last = 0; + for (let i = 0; i < 11; i++) { + const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'observe' }), + }); + last = r.status; + } + expect(last).toBe(429); + }); + + test('body size limit returns 413', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath }); + const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'interact' }), + }); + const { session_token } = JSON.parse(mintR.bodyText); + + const huge = 'x'.repeat(2_000_000); // 2MB > 1MB cap + const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, { + headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's' }, + body: JSON.stringify({ padding: huge }), + }); + expect(r.status).toBe(413); + }); + + test('audit log records mutating tailnet requests', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath }); + const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'interact' }), + }); + const { session_token } = JSON.parse(mintR.bodyText); + + await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, { + headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 'audit-s' }, + body: JSON.stringify({ x: 1, y: 2 }), + }); + + // Allow async file write to complete. + await new Promise(r => setTimeout(r, 100)); + const auditPath = process.env.GSTACK_IOS_AUDIT_PATH!; + const { readFileSync, existsSync } = await import('fs'); + expect(existsSync(auditPath)).toBe(true); + const rows = readFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); + const tapRow = rows.find(r => r.endpoint === 'POST /tap'); + expect(tapRow).toBeDefined(); + expect(tapRow.identity).toBe('caller@example.com'); + expect(tapRow.capability).toBe('interact'); + }); + + test('boot token never appears in tailnet responses', async () => { + await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath }); + const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capability: 'interact' }), + }); + expect(mintR.bodyText).not.toContain(STATE_SERVER_TOKEN); + + const { session_token } = JSON.parse(mintR.bodyText); + const screenshotR = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/screenshot`, { + headers: { 'authorization': `Bearer ${session_token}` }, + }); + expect(screenshotR.bodyText).not.toContain(STATE_SERVER_TOKEN); + }); +}); + +// Cleanup any leftover env from beforeEach blocks. +import { afterEach } from 'bun:test'; diff --git a/ios-qa/daemon/test/proxy-classify.test.ts b/ios-qa/daemon/test/proxy-classify.test.ts new file mode 100644 index 000000000..0bb1529d3 --- /dev/null +++ b/ios-qa/daemon/test/proxy-classify.test.ts @@ -0,0 +1,47 @@ +// Tailnet endpoint allowlist + capability tier classification tests. +// +// Codex flagged: "tailnet listener allowlist is too broad. Remote agents +// should not get /state/* by default. Split capabilities: observe, interact, +// mutate state, restore state." + +import { describe, test, expect } from 'bun:test'; +import { classifyRoute } from '../src/proxy'; + +describe('classifyRoute', () => { + test('healthz, screenshot, elements, snapshot are observe-tier', () => { + expect(classifyRoute('GET', '/healthz').requiredCapability).toBe('observe'); + expect(classifyRoute('GET', '/screenshot').requiredCapability).toBe('observe'); + expect(classifyRoute('GET', '/elements').requiredCapability).toBe('observe'); + expect(classifyRoute('GET', '/state/snapshot').requiredCapability).toBe('observe'); + expect(classifyRoute('GET', '/state/anyKey').requiredCapability).toBe('observe'); + }); + + test('tap, swipe, type, session ops are interact-tier', () => { + expect(classifyRoute('POST', '/tap').requiredCapability).toBe('interact'); + expect(classifyRoute('POST', '/swipe').requiredCapability).toBe('interact'); + expect(classifyRoute('POST', '/type').requiredCapability).toBe('interact'); + expect(classifyRoute('POST', '/session/acquire').requiredCapability).toBe('interact'); + expect(classifyRoute('POST', '/session/release').requiredCapability).toBe('interact'); + expect(classifyRoute('POST', '/session/heartbeat').requiredCapability).toBe('interact'); + }); + + test('arbitrary state writes are mutate-tier', () => { + expect(classifyRoute('POST', '/state/userIsLoggedIn').requiredCapability).toBe('mutate'); + expect(classifyRoute('POST', '/state/anyField').requiredCapability).toBe('mutate'); + }); + + test('state/restore is restore-tier (highest)', () => { + expect(classifyRoute('POST', '/state/restore').requiredCapability).toBe('restore'); + }); + + test('mint endpoint is observe-tier (minimum bar to attempt mint)', () => { + expect(classifyRoute('POST', '/auth/mint').requiredCapability).toBe('observe'); + }); + + test('non-allowlisted endpoints return allowed=false', () => { + expect(classifyRoute('POST', '/auth/sessions').allowed).toBe(false); + expect(classifyRoute('GET', '/random').allowed).toBe(false); + expect(classifyRoute('DELETE', '/anything').allowed).toBe(false); + expect(classifyRoute('GET', '/auth/sessions').allowed).toBe(false); // loopback-only + }); +}); diff --git a/ios-qa/daemon/test/session-tokens.test.ts b/ios-qa/daemon/test/session-tokens.test.ts new file mode 100644 index 000000000..5e74c8c99 --- /dev/null +++ b/ios-qa/daemon/test/session-tokens.test.ts @@ -0,0 +1,156 @@ +// Unit tests for SessionTokenStore. +// +// Codex flagged: TTL semantics, capability tier enforcement, rate limiting, +// token expiry, identity-scoped revoke. + +import { describe, test, expect } from 'bun:test'; +import { SessionTokenStore } from '../src/session-tokens'; +import { capabilityCovers } from '../src/types'; + +describe('SessionTokenStore', () => { + test('mint returns a token with default 1h TTL', () => { + const now = 1_000_000; + const store = new SessionTokenStore(() => now); + const result = store.mint({ + identity: 'user@example.com', + capability: 'interact', + origin: 'self_service', + }); + expect(result).toMatchObject({ + identity: 'user@example.com', + capability: 'interact', + origin: 'self_service', + }); + if ('error' in result) throw new Error('unexpected error'); + expect(result.expires_at).toBe(now + 60 * 60 * 1000); + }); + + test('mint caps TTL at 24h', () => { + const now = 1_000_000; + const store = new SessionTokenStore(() => now); + const result = store.mint({ + identity: 'u', + capability: 'observe', + ttlMs: 1_000_000_000, // way over 24h + origin: 'self_service', + }); + if ('error' in result) throw new Error('unexpected error'); + expect(result.expires_at).toBe(now + 24 * 60 * 60 * 1000); + }); + + test('validate returns ok for fresh token at the required tier', () => { + const store = new SessionTokenStore(); + const result = store.mint({ identity: 'u', capability: 'mutate', origin: 'owner_granted' }); + if ('error' in result) throw new Error('unexpected error'); + const v = store.validate(result.token, 'observe'); + expect(v.ok).toBe(true); + }); + + test('validate rejects null/empty/unknown tokens', () => { + const store = new SessionTokenStore(); + expect(store.validate(null, 'observe')).toEqual({ ok: false, reason: 'no_token' }); + expect(store.validate('', 'observe')).toEqual({ ok: false, reason: 'no_token' }); + expect(store.validate('bogus-token', 'observe')).toEqual({ ok: false, reason: 'invalid_token' }); + }); + + test('validate rejects expired tokens', () => { + let now = 1_000_000; + const store = new SessionTokenStore(() => now); + const result = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' }); + if ('error' in result) throw new Error('unexpected error'); + now += 25 * 60 * 60 * 1000; // 25 hours later — past max TTL + expect(store.validate(result.token, 'observe')).toEqual({ ok: false, reason: 'expired_token' }); + }); + + test('validate rejects tokens with insufficient capability', () => { + const store = new SessionTokenStore(); + const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' }); + if ('error' in r) throw new Error('unexpected'); + expect(store.validate(r.token, 'interact')).toEqual({ ok: false, reason: 'capability_insufficient' }); + expect(store.validate(r.token, 'mutate')).toEqual({ ok: false, reason: 'capability_insufficient' }); + expect(store.validate(r.token, 'restore')).toEqual({ ok: false, reason: 'capability_insufficient' }); + }); + + test('higher capability tiers cover lower tiers', () => { + expect(capabilityCovers('restore', 'mutate')).toBe(true); + expect(capabilityCovers('restore', 'interact')).toBe(true); + expect(capabilityCovers('restore', 'observe')).toBe(true); + expect(capabilityCovers('mutate', 'interact')).toBe(true); + expect(capabilityCovers('observe', 'interact')).toBe(false); + expect(capabilityCovers('observe', 'mutate')).toBe(false); + }); + + test('heartbeat extends TTL', () => { + let now = 1_000_000; + const store = new SessionTokenStore(() => now); + const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' }); + if ('error' in r) throw new Error('unexpected'); + const originalExpiry = r.expires_at; + now += 30 * 60 * 1000; // 30 min later + const newExpiry = store.heartbeat(r.token); + expect(newExpiry).not.toBeNull(); + expect(newExpiry!).toBeGreaterThan(originalExpiry); + expect(newExpiry!).toBe(now + 60 * 60 * 1000); + }); + + test('heartbeat after expiry returns null', () => { + let now = 1_000_000; + const store = new SessionTokenStore(() => now); + const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' }); + if ('error' in r) throw new Error('unexpected'); + now += 25 * 60 * 60 * 1000; // past max TTL + expect(store.heartbeat(r.token)).toBeNull(); + }); + + test('rate limit blocks the 11th mint within 60s window', () => { + const now = 1_000_000; + const store = new SessionTokenStore(() => now); + const results = []; + for (let i = 0; i < 11; i++) { + results.push(store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' })); + } + const ok = results.filter(r => !('error' in r)); + const errs = results.filter(r => 'error' in r); + expect(ok.length).toBe(10); + expect(errs.length).toBe(1); + expect(errs[0]).toEqual({ error: 'rate_limited' }); + }); + + test('rate limit window slides — 11th mint succeeds after 60s', () => { + let now = 1_000_000; + const store = new SessionTokenStore(() => now); + for (let i = 0; i < 10; i++) { + store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' }); + } + now += 61_000; // past window + const r = store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' }); + expect('error' in r).toBe(false); + }); + + test('revoke removes a token', () => { + const store = new SessionTokenStore(); + const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' }); + if ('error' in r) throw new Error('unexpected'); + expect(store.revoke(r.token)).toBe(true); + expect(store.validate(r.token, 'observe')).toEqual({ ok: false, reason: 'invalid_token' }); + }); + + test('revokeByIdentity removes all tokens for one identity', () => { + const store = new SessionTokenStore(); + const a1 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' }); + const a2 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' }); + const b1 = store.mint({ identity: 'b', capability: 'observe', origin: 'self_service' }); + if ('error' in a1 || 'error' in a2 || 'error' in b1) throw new Error('unexpected'); + expect(store.revokeByIdentity('a')).toBe(2); + expect(store.validate(a1.token, 'observe').ok).toBe(false); + expect(store.validate(a2.token, 'observe').ok).toBe(false); + expect(store.validate(b1.token, 'observe').ok).toBe(true); + }); + + test('list returns all active tokens', () => { + const store = new SessionTokenStore(); + store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' }); + store.mint({ identity: 'b', capability: 'mutate', origin: 'owner_granted' }); + expect(store.list().length).toBe(2); + }); +}); diff --git a/ios-qa/daemon/test/single-instance.test.ts b/ios-qa/daemon/test/single-instance.test.ts new file mode 100644 index 000000000..2734455e2 --- /dev/null +++ b/ios-qa/daemon/test/single-instance.test.ts @@ -0,0 +1,96 @@ +// Single-instance enforcement tests. +// +// Codex-flagged: spawn-race conditions, stale pidfile reclamation, readiness +// protocol timeout. + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { tryClaim } from '../src/single-instance'; + +let tmpDir: string; +let pidPath: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-pidfile-')); + pidPath = join(tmpDir, 'daemon.pid'); +}); + +describe('tryClaim', () => { + test('first claim succeeds and writes pidfile', async () => { + const r = await tryClaim({ port: 9099, path: pidPath }); + expect(r.claimed).toBe(true); + expect(existsSync(pidPath)).toBe(true); + const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')); + expect(parsed.pid).toBe(process.pid); + expect(parsed.port).toBe(9099); + if (r.claimed) await r.release(); + }); + + test('second claim against same live PID returns existing', async () => { + // Fake a live pidfile pointing to OUR pid (since we definitely exist). + writeFileSync(pidPath, JSON.stringify({ + pid: process.pid, + port: 9099, + startedAt: Date.now(), + })); + const r = await tryClaim({ port: 9100, path: pidPath }); + expect(r.claimed).toBe(false); + if (!r.claimed) { + expect(r.existing.pid).toBe(process.pid); + expect(r.existing.port).toBe(9099); + } + }); + + test('claim reclaims stale pidfile (dead PID)', async () => { + // PID 1 is init/launchd; pick a PID that doesn't exist. PID 999999 is + // not assigned in any realistic system. + writeFileSync(pidPath, JSON.stringify({ + pid: 999999, + port: 9099, + startedAt: Date.now() - 60_000, + })); + const r = await tryClaim({ port: 9100, path: pidPath }); + expect(r.claimed).toBe(true); + if (r.claimed) { + // New pidfile reflects us. + const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')); + expect(parsed.pid).toBe(process.pid); + expect(parsed.port).toBe(9100); + await r.release(); + } + }); + + test('claim handles unparseable pidfile by reclaiming', async () => { + writeFileSync(pidPath, 'not json'); + const r = await tryClaim({ port: 9101, path: pidPath }); + expect(r.claimed).toBe(true); + if (r.claimed) await r.release(); + }); + + // Codex-flagged: concurrent spawn race. Multiple invocations must result in + // exactly one claim winning, with the rest seeing the winner's pidfile. + test('concurrent claims race deterministically — exactly one wins', async () => { + // Pre-clean: ensure no pidfile. + if (existsSync(pidPath)) rmSync(pidPath); + const N = 10; + const promises: Promise<{ claimed: boolean }>[] = []; + for (let i = 0; i < N; i++) { + promises.push(tryClaim({ port: 9099 + i, path: pidPath })); + } + const results = await Promise.all(promises); + const wins = results.filter(r => r.claimed); + const losses = results.filter(r => !r.claimed); + expect(wins.length).toBe(1); + expect(losses.length).toBe(N - 1); + // Cleanup the winner. + const winner = wins[0] as unknown as { claimed: true; release: () => Promise }; + await winner.release(); + }); +}); + +import { afterEach } from 'bun:test'; +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/ios-qa/daemon/test/tailscale-localapi.test.ts b/ios-qa/daemon/test/tailscale-localapi.test.ts new file mode 100644 index 000000000..615a71612 --- /dev/null +++ b/ios-qa/daemon/test/tailscale-localapi.test.ts @@ -0,0 +1,55 @@ +// tailscaled LocalAPI client tests. Codex-flagged: identity canonicalization +// for user / tag / node-key forms, fail-closed semantics on missing socket +// or unparseable response. + +import { describe, test, expect } from 'bun:test'; +import { canonicalize, probeTailscale } from '../src/tailscale-localapi'; + +describe('canonicalize', () => { + test('returns lowercased user email when UserProfile.LoginName present', () => { + const out = canonicalize({ + Node: { Tags: undefined }, + UserProfile: { LoginName: 'Alice@Example.COM' }, + }); + expect(out).toBe('alice@example.com'); + }); + + test('returns tagged node identity when tags present (prefers tag over user)', () => { + const out = canonicalize({ + Node: { Tags: ['tag:CI'] }, + UserProfile: { LoginName: 'admin@example.com' }, + }); + expect(out).toBe('tag:ci'); + }); + + test('handles tag without prefix', () => { + const out = canonicalize({ + Node: { Tags: ['ci'] }, + }); + expect(out).toBe('tag:ci'); + }); + + test('returns node: when no user and no tags', () => { + const out = canonicalize({ + Node: { Key: 'nodekey:abcdef0123' }, + }); + expect(out).toBe('node:abcdef0123'); + }); + + test('returns null for unparseable response', () => { + expect(canonicalize({})).toBeNull(); + expect(canonicalize({ Node: {} })).toBeNull(); + expect(canonicalize({ UserProfile: { LoginName: 'no-at-sign' } })).toBeNull(); + }); +}); + +describe('probeTailscale', () => { + test('fails closed when socket does not exist', async () => { + const r = await probeTailscale('/tmp/does-not-exist-' + Math.random()); + expect(r.ok).toBe(false); + // Reason may be 'socket_missing' or 'unreachable' depending on how the + // OS/runtime surfaces a missing unix socket. Either is a fail-closed + // outcome that prevents the daemon from opening the tailnet listener. + expect(['socket_missing', 'unreachable']).toContain(r.reason); + }); +});