chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// 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<Allowlist> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, 'utf-8');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ENOENT') {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Empty-file path (mktemp default, partial write, manual `: > file`): treat
|
||||
// as "no entries yet" rather than a parse error. The first grant will fill
|
||||
// it in atomically via saveAllowlist.
|
||||
if (raw.trim() === '') {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Allowlist;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
||||
throw new Error('invalid_allowlist');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise<void> {
|
||||
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<Allowlist> {
|
||||
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<Allowlist> {
|
||||
const allowlist = await loadAllowlist(path);
|
||||
allowlist.entries = allowlist.entries.filter(e => e.identity !== identity);
|
||||
await saveAllowlist(allowlist, path);
|
||||
return allowlist;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�');
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// /auth/mint endpoint handler. Two trust models, kept distinct:
|
||||
//
|
||||
// 1. Self-service mint: caller's tailnet identity (from WhoIs) must already
|
||||
// be in the allowlist. NEVER auto-allowlists.
|
||||
// 2. Owner-granted mint: not on /auth/mint at all — that's the CLI
|
||||
// `gstack-ios-qa-mint --remote <identity>` 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;
|
||||
attemptsPath?: string;
|
||||
endpoint?: string;
|
||||
}): Promise<MintResponse | MintError> {
|
||||
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',
|
||||
path: opts.attemptsPath,
|
||||
});
|
||||
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',
|
||||
path: opts.attemptsPath,
|
||||
});
|
||||
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',
|
||||
path: opts.attemptsPath,
|
||||
});
|
||||
return { error: 'rate_limited' };
|
||||
}
|
||||
|
||||
return {
|
||||
session_token: result.token,
|
||||
expires_at: result.expires_at,
|
||||
capability: result.capability,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Owner-grant CLI. Adds (or upgrades) an identity to the allowlist so a
|
||||
// remote agent on the tailnet can self-service mint a session token via
|
||||
// POST /auth/mint. Never auto-allowlists; explicit user intent only.
|
||||
//
|
||||
// Invoked from bin/gstack-ios-qa-mint.
|
||||
|
||||
import { grantIdentity, revokeIdentity, loadAllowlist, defaultAllowlistPath } from './allowlist';
|
||||
import type { Capability } from './types';
|
||||
|
||||
const CAPABILITIES: Capability[] = ['observe', 'interact', 'mutate', 'restore'];
|
||||
|
||||
interface ParsedArgs {
|
||||
command: 'grant' | 'revoke' | 'list' | 'help';
|
||||
identity: string | null;
|
||||
capability: Capability;
|
||||
ttlSeconds: number | null;
|
||||
note: string | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
// Default: help. Recognized positional commands: grant | revoke | list.
|
||||
let command: ParsedArgs['command'] = 'help';
|
||||
let identity: string | null = null;
|
||||
let capability: Capability = 'interact';
|
||||
let ttlSeconds: number | null = null;
|
||||
let note: string | null = null;
|
||||
let path = defaultAllowlistPath();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
switch (a) {
|
||||
case 'grant': command = 'grant'; break;
|
||||
case 'revoke': command = 'revoke'; break;
|
||||
case 'list': command = 'list'; break;
|
||||
case '--help':
|
||||
case '-h': command = 'help'; break;
|
||||
case '--remote':
|
||||
case '--identity':
|
||||
identity = argv[++i] ?? null;
|
||||
break;
|
||||
case '--capability':
|
||||
case '--cap': {
|
||||
const v = argv[++i];
|
||||
if (!CAPABILITIES.includes(v as Capability)) {
|
||||
process.stderr.write(`unknown capability: ${v} (want one of ${CAPABILITIES.join(', ')})\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
capability = v as Capability;
|
||||
break;
|
||||
}
|
||||
case '--ttl': {
|
||||
const v = parseInt(argv[++i] ?? '', 10);
|
||||
if (!Number.isFinite(v) || v <= 0) {
|
||||
process.stderr.write('--ttl must be a positive integer (seconds)\n');
|
||||
process.exit(2);
|
||||
}
|
||||
ttlSeconds = v;
|
||||
break;
|
||||
}
|
||||
case '--note': note = argv[++i] ?? null; break;
|
||||
case '--allowlist-path': path = argv[++i] ?? path; break;
|
||||
}
|
||||
}
|
||||
return { command, identity, capability, ttlSeconds, note, path };
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const help = `gstack-ios-qa-mint — manage the tailnet allowlist for remote iOS QA agents
|
||||
|
||||
USAGE
|
||||
gstack-ios-qa-mint grant --remote <identity> [--capability <tier>] [--ttl <seconds>] [--note <text>]
|
||||
gstack-ios-qa-mint revoke --remote <identity>
|
||||
gstack-ios-qa-mint list
|
||||
|
||||
ARGUMENTS
|
||||
--remote <identity> Canonical tailnet identity (e.g. user@example.com or tag:ci).
|
||||
--capability <tier> observe | interact (default) | mutate | restore
|
||||
--ttl <seconds> Optional expiry. Omit for no-expiry entry.
|
||||
--note <text> Free-form note kept alongside the entry.
|
||||
--allowlist-path <path> Override the allowlist file location.
|
||||
|
||||
EXAMPLES
|
||||
gstack-ios-qa-mint grant --remote 'alice@example.com' --capability interact
|
||||
gstack-ios-qa-mint grant --remote 'tag:ci' --capability mutate --ttl 86400 --note 'nightly run'
|
||||
gstack-ios-qa-mint revoke --remote 'alice@example.com'
|
||||
gstack-ios-qa-mint list
|
||||
|
||||
The allowlist lives at ~/.gstack/ios-qa-allowlist.json (mode 0600). The daemon's
|
||||
self-service /auth/mint endpoint reads this file on every request.
|
||||
`;
|
||||
process.stdout.write(help);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.command === 'help') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === 'list') {
|
||||
const allowlist = await loadAllowlist(args.path);
|
||||
if (allowlist.entries.length === 0) {
|
||||
process.stdout.write('(empty allowlist)\n');
|
||||
return;
|
||||
}
|
||||
for (const e of allowlist.entries) {
|
||||
const caps = e.capabilities.join(',');
|
||||
const exp = e.expires_at ? ` expires=${e.expires_at}` : '';
|
||||
const note = e.note ? ` note="${e.note}"` : '';
|
||||
process.stdout.write(`${e.identity} cap=${caps}${exp}${note}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.identity) {
|
||||
process.stderr.write('error: --remote <identity> required\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (args.command === 'grant') {
|
||||
const result = await grantIdentity({
|
||||
identity: args.identity,
|
||||
capability: args.capability,
|
||||
ttlSeconds: args.ttlSeconds,
|
||||
note: args.note ?? undefined,
|
||||
path: args.path,
|
||||
});
|
||||
const entry = result.entries.find(e => e.identity === args.identity);
|
||||
process.stdout.write(`granted ${args.identity} capability=${args.capability}` +
|
||||
(entry?.expires_at ? ` expires=${entry.expires_at}` : '') + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === 'revoke') {
|
||||
await revokeIdentity(args.identity, args.path);
|
||||
process.stdout.write(`revoked ${args.identity}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`gstack-ios-qa-mint: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Thin wrappers around `xcrun devicectl` and DNS resolution. Every function
|
||||
// here is unit-testable in isolation by injecting a spawnImpl + resolveImpl.
|
||||
//
|
||||
// Production code uses the defaults: spawnSync('xcrun', [...]) and
|
||||
// dns.lookup('<host>.coredevice.local'). Tests inject stubs.
|
||||
|
||||
import { spawnSync, type SpawnSyncReturns } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface DeviceEntry {
|
||||
identifier: string;
|
||||
name: string;
|
||||
model: string;
|
||||
state: string; // "connected" | "available" | "available (paired)" | ...
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
export interface SpawnImpl {
|
||||
(cmd: string, args: string[]): SpawnSyncReturns<Buffer>;
|
||||
}
|
||||
|
||||
export interface ResolveImpl {
|
||||
(hostname: string): Promise<string[]>; // returns IPv6 addresses
|
||||
}
|
||||
|
||||
const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 });
|
||||
|
||||
/**
|
||||
* Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder
|
||||
* on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on
|
||||
* recent macOS — returns ESERVFAIL for `*.coredevice.local`).
|
||||
*
|
||||
* Prefer the IPv6 record but fall back to whatever getaddrinfo returns.
|
||||
*/
|
||||
const defaultResolve: ResolveImpl = async (hostname) => {
|
||||
const dns = await import('dns');
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => {
|
||||
if (err) { reject(err); return; }
|
||||
const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address);
|
||||
if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; }
|
||||
resolve(ipv6);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility
|
||||
* and for environments where mDNSResponder is not in the resolver chain. On
|
||||
* macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment
|
||||
* on `defaultResolve` above.
|
||||
*/
|
||||
const legacyResolve6: ResolveImpl = async (hostname) => {
|
||||
const dns = await import('dns');
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.resolve6(hostname, (err, addrs) => {
|
||||
if (err) reject(err);
|
||||
else resolve(addrs);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List devices currently known to CoreDevice. Includes connected, paired,
|
||||
* and pairing-in-progress devices.
|
||||
*/
|
||||
export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
|
||||
const tmp = join(tmpdir(), `devicectl-list-${process.pid}-${Date.now()}.json`);
|
||||
try {
|
||||
const r = spawn('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp]);
|
||||
if (r.status !== 0) return [];
|
||||
const raw = readFileSync(tmp, 'utf-8');
|
||||
const obj = JSON.parse(raw);
|
||||
const list = (obj.result?.devices ?? []) as Array<Record<string, unknown>>;
|
||||
return list.map((d) => {
|
||||
const conn = d.connectionProperties as Record<string, unknown> | undefined;
|
||||
const props = d.deviceProperties as Record<string, unknown> | undefined;
|
||||
const hw = d.hardwareProperties as Record<string, unknown> | undefined;
|
||||
const pairingState = String(conn?.pairingState ?? '');
|
||||
return {
|
||||
identifier: String(d.identifier ?? ''),
|
||||
name: String(props?.name ?? 'unknown'),
|
||||
model: String(hw?.productType ?? 'unknown'),
|
||||
state: String(conn?.tunnelState ?? 'unknown'),
|
||||
paired: pairingState === 'paired',
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info
|
||||
* details --json-output`. This is the most reliable path on macOS 26.x: the
|
||||
* tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is
|
||||
* authoritative (it's what CoreDevice itself uses to route).
|
||||
*
|
||||
* A side effect of running `devicectl device info details` is that it forces
|
||||
* CoreDevice to bring up / refresh the tunnel session, which is why we prefer
|
||||
* this over mDNS even on machines where mDNS works.
|
||||
*
|
||||
* Returns null when the device isn't found, isn't tunneled, or devicectl
|
||||
* fails — callers should fall through to mDNS resolution.
|
||||
*/
|
||||
export function getDeviceTunnelIPv6FromDevicectl(
|
||||
udid: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): string | null {
|
||||
const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`);
|
||||
try {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
|
||||
if (r.status !== 0) return null;
|
||||
const raw = readFileSync(tmp, 'utf-8');
|
||||
const obj = JSON.parse(raw);
|
||||
// `result.connectionProperties.tunnelIPAddress` is the canonical location.
|
||||
// Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress`
|
||||
// — accept either.
|
||||
const conn = obj?.result?.connectionProperties as Record<string, unknown> | undefined;
|
||||
const tunnel = obj?.result?.tunnel as Record<string, unknown> | undefined;
|
||||
const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined;
|
||||
if (typeof addr === 'string' && addr.includes(':')) return addr;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic devicectl `info details` poll that keeps the CoreDevice
|
||||
* tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while
|
||||
* a devicectl command is in-flight or Xcode itself is debugging. Without
|
||||
* something poking it, the tunnel IPv6 becomes unroutable within seconds —
|
||||
* `curl` to the address times out even though the address looks valid.
|
||||
*
|
||||
* Implementation note: we chose `device info details` (cheap, ~10ms of CPU
|
||||
* per tick, no persistent child process) over `device console` (which would
|
||||
* keep the tunnel up continuously but spams stdout, can wedge on backpressure,
|
||||
* and is harder to kill cleanly). The 5-second interval is comfortably under
|
||||
* the empirically-observed tunnel teardown timeout (~10-15s of idle).
|
||||
*
|
||||
* Returns a `stop()` function that cancels the timer. Safe to call multiple
|
||||
* times.
|
||||
*/
|
||||
export function startTunnelKeepalive(
|
||||
udid: string,
|
||||
opts: { intervalMs?: number; spawn?: SpawnImpl } = {},
|
||||
): { stop: () => void } {
|
||||
const intervalMs = opts.intervalMs ?? 5_000;
|
||||
const spawn = opts.spawn ?? defaultSpawn;
|
||||
let stopped = false;
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
// Fire-and-forget: ignore result, the side-effect of the spawn is what
|
||||
// keeps the tunnel up. We deliberately do not use the JSON output here.
|
||||
try {
|
||||
const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`);
|
||||
spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
} catch { /* ignore — next tick will retry */ }
|
||||
};
|
||||
const handle = setInterval(tick, intervalMs);
|
||||
// Don't keep the event loop alive just for this — daemon owns the lifecycle.
|
||||
if (typeof handle.unref === 'function') handle.unref();
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is
|
||||
* derived from the device name as printed by `devicectl list devices`. The
|
||||
* resolved address looks like `fd72:8347:2ead::1` — RFC 4193 ULA, regenerated
|
||||
* per session.
|
||||
*/
|
||||
export async function getDeviceTunnelIPv6(
|
||||
deviceName: string,
|
||||
resolve: ResolveImpl = defaultResolve,
|
||||
): Promise<string | null> {
|
||||
// CoreDevice mDNS host: lowercase, spaces and apostrophes → hyphens, plus
|
||||
// ".coredevice.local" suffix. Apple normalizes "Garry's Durendal" to
|
||||
// "Garrys-Durendal.coredevice.local".
|
||||
const slug = deviceName
|
||||
.replace(/['']/g, '') // strip apostrophes
|
||||
.replace(/[\s_]+/g, '-') // spaces/underscores → hyphens
|
||||
.replace(/[^a-zA-Z0-9-]/g, '') // anything else not URL-safe → drop
|
||||
+ '.coredevice.local';
|
||||
try {
|
||||
const addrs = await resolve(slug);
|
||||
return addrs[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a device's tunnel IPv6 using every strategy we know, in order of
|
||||
* decreasing reliability:
|
||||
*
|
||||
* 1. `devicectl device info details --json-output` (most reliable on
|
||||
* macOS 26.x; also has the useful side-effect of bumping the tunnel).
|
||||
* 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder
|
||||
* on macOS, unlike `dns.resolve6`).
|
||||
* 3. mDNS via `dns.resolve6` (legacy path — kept for backwards
|
||||
* compatibility; will ESERVFAIL on recent macOS).
|
||||
*
|
||||
* Returns the first address that any strategy yields, or null.
|
||||
*/
|
||||
export async function resolveTunnelIPv6(opts: {
|
||||
udid: string;
|
||||
deviceName: string;
|
||||
spawn?: SpawnImpl;
|
||||
resolve?: ResolveImpl;
|
||||
legacyResolve?: ResolveImpl;
|
||||
}): Promise<string | null> {
|
||||
const spawn = opts.spawn ?? defaultSpawn;
|
||||
const resolveLookup = opts.resolve ?? defaultResolve;
|
||||
const resolveLegacy = opts.legacyResolve ?? legacyResolve6;
|
||||
|
||||
// 1. devicectl-based
|
||||
const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn);
|
||||
if (fromDevicectl) return fromDevicectl;
|
||||
|
||||
// 2. mDNS via dns.lookup
|
||||
const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup);
|
||||
if (fromLookup) return fromLookup;
|
||||
|
||||
// 3. last-resort: legacy dns.resolve6
|
||||
const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy);
|
||||
return fromLegacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a specific bundle ID has a running process on the device.
|
||||
*/
|
||||
export function isAppRunning(
|
||||
udid: string,
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): boolean {
|
||||
const tmp = join(tmpdir(), `devicectl-procs-${process.pid}-${Date.now()}.json`);
|
||||
try {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'info', 'processes', '-d', udid, '--json-output', tmp]);
|
||||
if (r.status !== 0) return false;
|
||||
const raw = readFileSync(tmp, 'utf-8');
|
||||
return raw.includes(`/${bundleId}/`) || raw.includes(`/${bundleId}.app/`);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch an app on the device. Returns true on success, false otherwise.
|
||||
* Locked-device errors (the iPhone needs to be unlocked first) are surfaced
|
||||
* through the error string.
|
||||
*/
|
||||
export function launchApp(
|
||||
udid: string,
|
||||
bundleId: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): { ok: boolean; error?: string } {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]);
|
||||
if (r.status === 0) return { ok: true };
|
||||
const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
|
||||
if (err.includes('was not, or could not be, unlocked')) {
|
||||
return { ok: false, error: 'device_locked' };
|
||||
}
|
||||
if (err.includes('FBSOpenApplicationServiceErrorDomain')) {
|
||||
return { ok: false, error: 'launch_failed' };
|
||||
}
|
||||
return { ok: false, error: err.split('\n')[0] ?? 'unknown' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file out of an app's data container. Used to scrape the boot token
|
||||
* from `tmp/gstack-ios-qa.token` after the StateServer starts.
|
||||
*/
|
||||
export function copyFileFromAppContainer(opts: {
|
||||
udid: string;
|
||||
bundleId: string;
|
||||
sourceRelativePath: string;
|
||||
spawn?: SpawnImpl;
|
||||
}): string | null {
|
||||
const spawn = opts.spawn ?? defaultSpawn;
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-copy-'));
|
||||
const dest = join(dir, 'fetched');
|
||||
try {
|
||||
const r = spawn('xcrun', [
|
||||
'devicectl', 'device', 'copy', 'from',
|
||||
'--device', opts.udid,
|
||||
'--domain-type', 'appDataContainer',
|
||||
'--domain-identifier', opts.bundleId,
|
||||
'--source', opts.sourceRelativePath,
|
||||
'--destination', dest,
|
||||
]);
|
||||
if (r.status !== 0) return null;
|
||||
return readFileSync(dest, 'utf-8').replace(/[\r\n]+$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an .app bundle on the device. The bundle must be signed with a
|
||||
* dev/distribution profile that includes the device.
|
||||
*/
|
||||
export function installApp(
|
||||
udid: string,
|
||||
appBundlePath: string,
|
||||
spawn: SpawnImpl = defaultSpawn,
|
||||
): { ok: boolean; error?: string } {
|
||||
const r = spawn('xcrun', ['devicectl', 'device', 'install', 'app', '--device', udid, appBundlePath]);
|
||||
if (r.status === 0) return { ok: true };
|
||||
return { ok: false, error: (r.stderr?.toString() ?? r.stdout?.toString() ?? 'unknown').split('\n')[0] };
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// 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 { bootstrapTunnel } from './tunnel-bootstrap';
|
||||
import { startTunnelKeepalive } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
|
||||
interface DaemonOptions {
|
||||
loopbackPort: number;
|
||||
tailnetEnabled: boolean;
|
||||
tailnetSocketPath?: string;
|
||||
tailnetSessionTtlSeconds?: number;
|
||||
pidfilePath?: string;
|
||||
// Explicit security-log + allowlist paths. Default to the env-var-derived
|
||||
// locations (defaultAuditPath etc.) when omitted, but passing them lets
|
||||
// concurrent test instances stay isolated without racing on process.env.
|
||||
auditPath?: string;
|
||||
attemptsPath?: string;
|
||||
allowlistPath?: string;
|
||||
// Test injection
|
||||
tunnelProvider?: () => Promise<DeviceTunnel | null>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon | { error: string; reason?: string }> {
|
||||
// 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<DeviceTunnel | null> => {
|
||||
// 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<typeof createServer> | null = null;
|
||||
let tailnetPort: number | null = null;
|
||||
if (opts.tailnetEnabled && probe?.ok) {
|
||||
tailnetServer = createServer(async (req, res) => {
|
||||
await handleTailnet({
|
||||
req,
|
||||
res,
|
||||
tokenStore,
|
||||
getTunnel,
|
||||
auditPath: opts.auditPath,
|
||||
attemptsPath: opts.attemptsPath,
|
||||
allowlistPath: opts.allowlistPath,
|
||||
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<typeof createServer> | null | undefined) => {
|
||||
if (!s) return Promise.resolve();
|
||||
(s as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
|
||||
(s as unknown as { closeIdleConnections?: () => void }).closeIdleConnections?.();
|
||||
return new Promise<void>((resolve) => s.close(() => resolve()));
|
||||
};
|
||||
await Promise.all([
|
||||
closeAll(loopbackServer),
|
||||
v6Bound ? closeAll(loopbackServerV6) : Promise.resolve(),
|
||||
closeAll(tailnetServer),
|
||||
]);
|
||||
await claim.release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listenAsync(server: ReturnType<typeof createServer>, port: number, host: string): Promise<void> {
|
||||
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<DeviceTunnel | null>;
|
||||
// Explicit security-log + allowlist paths (default to env-derived when undefined).
|
||||
auditPath?: string;
|
||||
attemptsPath?: string;
|
||||
allowlistPath?: string;
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<Buffer | { error: 'body_too_large' }> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = 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',
|
||||
path: attemptsPath,
|
||||
});
|
||||
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,
|
||||
allowlistPath,
|
||||
attemptsPath,
|
||||
});
|
||||
|
||||
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,
|
||||
path: attemptsPath,
|
||||
});
|
||||
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,
|
||||
}, auditPath);
|
||||
}
|
||||
|
||||
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');
|
||||
const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
|
||||
const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
|
||||
|
||||
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
|
||||
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
|
||||
// Otherwise return null (proxy will return 503 device_not_connected).
|
||||
//
|
||||
// After a successful bootstrap we spawn a periodic devicectl `info details`
|
||||
// call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice
|
||||
// only holds the tunnel up while a devicectl command is in-flight, so
|
||||
// without a poke every few seconds the IPv6 becomes unroutable.
|
||||
let keepalive: { stop: () => void } | null = null;
|
||||
const realTunnelProvider = async () => {
|
||||
const result = await bootstrapTunnel({
|
||||
udid: targetUDID,
|
||||
bundleId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
|
||||
return null;
|
||||
}
|
||||
if (keepalive) keepalive.stop();
|
||||
keepalive = startTunnelKeepalive(result.tunnel.udid);
|
||||
return result.tunnel;
|
||||
};
|
||||
|
||||
const shutdown = () => {
|
||||
if (keepalive) { keepalive.stop(); keepalive = null; }
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('exit', shutdown);
|
||||
|
||||
startDaemon({
|
||||
loopbackPort: port,
|
||||
tailnetEnabled: tailnet,
|
||||
tunnelProvider: realTunnelProvider,
|
||||
}).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);
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>; body: Buffer }> {
|
||||
const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
|
||||
if (body.length > MAX_BODY) {
|
||||
return makeError(413, 'body_too_large');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'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<string, string> = {};
|
||||
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<string, string>; 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<typeof tierForRoute>;
|
||||
} {
|
||||
const tier = tierForRoute(method, path);
|
||||
return { allowed: tier !== null, requiredCapability: tier };
|
||||
}
|
||||
@@ -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<string, SessionToken>();
|
||||
private mintsPerIdentity = new Map<string, number[]>(); // 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;
|
||||
}
|
||||
}
|
||||
@@ -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=<n> pid=<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<void> }
|
||||
| { 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})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<TailscaleProbe> {
|
||||
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=<addr:port>. Returns canonicalized identity.
|
||||
*
|
||||
* Canonicalization rules (matches Tailscale convention):
|
||||
* - User OAuth: `user@example.com` (no acct: prefix, lowercase email)
|
||||
* - Tagged nodes: `tag:<name>` (lowercased)
|
||||
* - Node keys: `node:<hex>` (rare, prefer tags)
|
||||
*/
|
||||
export async function whoIs(addr: string, socketPath: string = '/var/run/tailscale.sock'): Promise<WhoIsResult> {
|
||||
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<string, unknown>;
|
||||
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, unknown>): string | null {
|
||||
// Tagged node — tag is more specific than user identity for ACL purposes.
|
||||
const node = obj.Node as Record<string, unknown> | 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<string, unknown> | 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;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Bootstrap the CoreDevice tunnel to a connected iPhone running the iOS app
|
||||
// under test. Orchestrates the full hand-rolled flow we verified end-to-end:
|
||||
//
|
||||
// 1. find a paired, connected device via devicectl list devices
|
||||
// 2. launch the app on it (no-op if already running)
|
||||
// 3. wait briefly for the in-app StateServer to start
|
||||
// 4. copy the boot token from the app's sandbox via devicectl copy from
|
||||
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
|
||||
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
|
||||
// bearer that subsequent proxied requests carry
|
||||
//
|
||||
// Step 5 is critical: after rotation, anything scraping os_log or the
|
||||
// on-disk token file sees a dead credential. The Mac daemon holds the only
|
||||
// live token, which it scopes per-tailnet-session via /auth/mint.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { DeviceTunnel } from './proxy';
|
||||
import {
|
||||
listDevices,
|
||||
resolveTunnelIPv6,
|
||||
isAppRunning,
|
||||
launchApp,
|
||||
copyFileFromAppContainer,
|
||||
type SpawnImpl,
|
||||
type ResolveImpl,
|
||||
} from './devicectl';
|
||||
|
||||
export interface BootstrapOptions {
|
||||
/** Target device UDID. If null, picks the first connected paired device. */
|
||||
udid?: string;
|
||||
/** Bundle ID of the iOS app hosting the StateServer. */
|
||||
bundleId: string;
|
||||
/** StateServer port. Defaults to 9999. */
|
||||
port?: number;
|
||||
/** Token-path inside the app sandbox (relative to data container). */
|
||||
bootTokenPath?: string;
|
||||
/** Max time to wait for the StateServer to start after launch (ms). */
|
||||
startupTimeoutMs?: number;
|
||||
/** Test injection. */
|
||||
spawnImpl?: SpawnImpl;
|
||||
resolveImpl?: ResolveImpl;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export type BootstrapResult =
|
||||
| { ok: true; tunnel: DeviceTunnel }
|
||||
| { ok: false; error: BootstrapErrorReason; detail?: string };
|
||||
|
||||
export type BootstrapErrorReason =
|
||||
| 'no_devices'
|
||||
| 'no_paired_device'
|
||||
| 'device_not_found'
|
||||
| 'launch_failed'
|
||||
| 'device_locked'
|
||||
| 'state_server_unreachable'
|
||||
| 'boot_token_unavailable'
|
||||
| 'rotate_failed'
|
||||
| 'resolve_failed';
|
||||
|
||||
/**
|
||||
* Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by
|
||||
* the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
|
||||
* (or when the user wants real-device control instead of a stub).
|
||||
*/
|
||||
export async function bootstrapTunnel(opts: BootstrapOptions): Promise<BootstrapResult> {
|
||||
const port = opts.port ?? 9999;
|
||||
const tokenPath = opts.bootTokenPath ?? 'tmp/gstack-ios-qa.token';
|
||||
const startupTimeoutMs = opts.startupTimeoutMs ?? 5_000;
|
||||
const spawn = opts.spawnImpl;
|
||||
const resolve = opts.resolveImpl;
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
|
||||
// Step 1: pick a device
|
||||
const devices = listDevices(spawn);
|
||||
if (devices.length === 0) {
|
||||
return { ok: false, error: 'no_devices' };
|
||||
}
|
||||
const target = opts.udid
|
||||
? devices.find((d) => d.identifier === opts.udid)
|
||||
: devices.find((d) => d.paired) ?? devices[0];
|
||||
if (!target) {
|
||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||
}
|
||||
if (!target.paired) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'no_paired_device',
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: launch app (idempotent — devicectl returns success if already running)
|
||||
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
|
||||
const launched = launchApp(target.identifier, opts.bundleId, spawn);
|
||||
if (!launched.ok) {
|
||||
return { ok: false, error: launched.error === 'device_locked' ? 'device_locked' : 'launch_failed', detail: launched.error };
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: resolve tunnel IPv6. Try devicectl `info details` first (most
|
||||
// reliable on macOS 26.x), fall through to mDNS via dns.lookup, then
|
||||
// dns.resolve6 as a last-ditch fallback. See devicectl.ts:resolveTunnelIPv6
|
||||
// for the rationale.
|
||||
// When tests inject `resolve`, use it for both the mDNS-lookup path AND the
|
||||
// legacy resolve6 path — otherwise the legacy path would make a real DNS
|
||||
// call. In production, only `resolve` is set (to the dns.lookup-based
|
||||
// default) and the legacy path uses the real dns.resolve6.
|
||||
const ipv6 = await resolveTunnelIPv6({
|
||||
udid: target.identifier,
|
||||
deviceName: target.name,
|
||||
spawn,
|
||||
resolve,
|
||||
legacyResolve: resolve,
|
||||
});
|
||||
if (!ipv6) {
|
||||
return { ok: false, error: 'resolve_failed', detail: target.name };
|
||||
}
|
||||
|
||||
// Step 4: wait for StateServer to become reachable, then scrape boot token.
|
||||
// Probe /healthz with retries (the listener can take a moment to bind).
|
||||
const deadline = Date.now() + startupTimeoutMs;
|
||||
let healthOK = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (r.ok) { healthOK = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
}
|
||||
if (!healthOK) {
|
||||
return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
|
||||
}
|
||||
|
||||
const bootToken = copyFileFromAppContainer({
|
||||
udid: target.identifier,
|
||||
bundleId: opts.bundleId,
|
||||
sourceRelativePath: tokenPath,
|
||||
spawn,
|
||||
});
|
||||
if (!bootToken) {
|
||||
return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
|
||||
}
|
||||
|
||||
// Step 5: rotate the boot token to a fresh in-memory-only one.
|
||||
const rotatedToken = randomBytes(32).toString('base64url');
|
||||
try {
|
||||
const r = await fetchFn(`http://[${ipv6}]:${port}/auth/rotate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${bootToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ new_token: rotatedToken }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: 'rotate_failed', detail: `HTTP ${r.status}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: 'rotate_failed', detail: (err as Error).message };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tunnel: {
|
||||
udid: target.identifier,
|
||||
ipv6Addr: ipv6,
|
||||
port,
|
||||
bootTokenRotated: rotatedToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Shared types for the ios-qa daemon.
|
||||
|
||||
export type Capability = 'observe' | 'interact' | 'mutate' | 'restore';
|
||||
|
||||
export const CAPABILITY_ORDER: Record<Capability, number> = {
|
||||
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:<name>" or "node:<key>"
|
||||
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<string, Capability> = {
|
||||
'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
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// CLI tests for gstack-ios-qa-mint. Invokes the bash launcher end-to-end
|
||||
// so we catch any breakage between bin/, the entry-point resolution, and
|
||||
// the underlying allowlist primitives. Runs against a temp allowlist path
|
||||
// so the user's real ~/.gstack/ios-qa-allowlist.json is untouched.
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, statSync, existsSync, chmodSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..', '..', '..');
|
||||
const MINT_BIN = join(ROOT, 'bin', 'gstack-ios-qa-mint');
|
||||
const DAEMON_BIN = join(ROOT, 'bin', 'gstack-ios-qa-daemon');
|
||||
|
||||
function runMint(args: string[]) {
|
||||
return spawnSync(MINT_BIN, args, { stdio: 'pipe', encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
describe('bin/gstack-ios-qa-mint launcher', () => {
|
||||
let tmpDir: string;
|
||||
let listPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-cli-mint-'));
|
||||
listPath = join(tmpDir, 'allowlist.json');
|
||||
});
|
||||
|
||||
test('--help prints usage without touching allowlist', () => {
|
||||
const r = runMint(['--help']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('gstack-ios-qa-mint');
|
||||
expect(r.stdout).toContain('grant');
|
||||
expect(r.stdout).toContain('revoke');
|
||||
expect(r.stdout).toContain('list');
|
||||
});
|
||||
|
||||
test('grant + list + revoke roundtrip', () => {
|
||||
const grant = runMint([
|
||||
'grant', '--remote', 'alice@example.com',
|
||||
'--capability', 'interact',
|
||||
'--allowlist-path', listPath,
|
||||
]);
|
||||
expect(grant.status).toBe(0);
|
||||
expect(grant.stdout).toContain('granted alice@example.com');
|
||||
|
||||
// File must exist and be mode 0600 (owner-only). Mint creates the
|
||||
// parent directory with 0700 + writes the file at 0600.
|
||||
expect(existsSync(listPath)).toBe(true);
|
||||
const mode = statSync(listPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
|
||||
const list = runMint(['list', '--allowlist-path', listPath]);
|
||||
expect(list.status).toBe(0);
|
||||
expect(list.stdout).toContain('alice@example.com');
|
||||
expect(list.stdout).toContain('cap=interact');
|
||||
|
||||
const revoke = runMint(['revoke', '--remote', 'alice@example.com', '--allowlist-path', listPath]);
|
||||
expect(revoke.status).toBe(0);
|
||||
|
||||
const listAfter = runMint(['list', '--allowlist-path', listPath]);
|
||||
expect(listAfter.status).toBe(0);
|
||||
expect(listAfter.stdout).toContain('(empty allowlist)');
|
||||
});
|
||||
|
||||
test('grant without --remote exits non-zero with clear error', () => {
|
||||
const r = runMint(['grant', '--capability', 'interact', '--allowlist-path', listPath]);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('--remote');
|
||||
});
|
||||
|
||||
test('rejects unknown capability', () => {
|
||||
const r = runMint([
|
||||
'grant', '--remote', 'alice@example.com',
|
||||
'--capability', 'godmode',
|
||||
'--allowlist-path', listPath,
|
||||
]);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('unknown capability');
|
||||
});
|
||||
|
||||
test('grant with --ttl persists expires_at', () => {
|
||||
const r = runMint([
|
||||
'grant', '--remote', 'tag:ci',
|
||||
'--capability', 'mutate',
|
||||
'--ttl', '3600',
|
||||
'--note', 'nightly',
|
||||
'--allowlist-path', listPath,
|
||||
]);
|
||||
expect(r.status).toBe(0);
|
||||
const raw = readFileSync(listPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.entries[0].identity).toBe('tag:ci');
|
||||
expect(parsed.entries[0].capabilities).toEqual(['mutate']);
|
||||
expect(parsed.entries[0].expires_at).toBeTruthy();
|
||||
expect(parsed.entries[0].note).toBe('nightly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bin/gstack-ios-qa-daemon launcher', () => {
|
||||
test('launcher is executable', () => {
|
||||
expect(existsSync(DAEMON_BIN)).toBe(true);
|
||||
const mode = statSync(DAEMON_BIN).mode & 0o111;
|
||||
expect(mode).not.toBe(0);
|
||||
});
|
||||
|
||||
test('reports missing bun runtime cleanly', () => {
|
||||
// Simulate `bun` missing by giving PATH only /usr/bin + /bin (so bash
|
||||
// resolves but `command -v bun` does not). The launcher's preflight
|
||||
// check should fire BEFORE attempting to exec bun.
|
||||
const r = spawnSync(DAEMON_BIN, [], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
});
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('bun');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | string[] | undefined>; body: string }> }> {
|
||||
return new Promise((resolve) => {
|
||||
const received: Array<{ method: string; path: string; headers: Record<string, string | string[] | undefined>; 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<string, string>; 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<ReturnType<typeof startStubStateServer>>;
|
||||
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<ReturnType<typeof startStubStateServer>>;
|
||||
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';
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> };
|
||||
await winner.release();
|
||||
});
|
||||
});
|
||||
|
||||
import { afterEach } from 'bun:test';
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -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:<key> 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
// Bootstrap unit tests. Injects spawn + resolve + fetch stubs so we exercise
|
||||
// every branch (no_devices, no_paired_device, device_locked, healthz timeout,
|
||||
// rotate_failed, success) without needing a real iPhone connected.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
|
||||
import {
|
||||
getDeviceTunnelIPv6FromDevicectl,
|
||||
resolveTunnelIPv6,
|
||||
startTunnelKeepalive,
|
||||
type SpawnImpl,
|
||||
} from '../src/devicectl';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
interface ScriptedCall {
|
||||
argsMatch: RegExp;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
/** If set, write this content to the JSON output path before returning. */
|
||||
jsonOutput?: object;
|
||||
/** If set, write this content to the file matching `--destination`. */
|
||||
destOutput?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a spawnImpl that walks through a scripted sequence of expected calls.
|
||||
* Each call matches its args against `argsMatch`. Unmatched calls return
|
||||
* exit-code 1 with an "unexpected call" stderr.
|
||||
*/
|
||||
function makeSpawn(scripts: ScriptedCall[]): SpawnImpl {
|
||||
let idx = 0;
|
||||
return (cmd: string, args: string[]) => {
|
||||
const joined = `${cmd} ${args.join(' ')}`;
|
||||
const script = scripts[idx];
|
||||
if (!script) {
|
||||
return makeReturn(1, '', `unexpected call beyond scripted: ${joined}`);
|
||||
}
|
||||
if (!script.argsMatch.test(joined)) {
|
||||
return makeReturn(1, '', `unexpected call shape: ${joined} (expected ${script.argsMatch})`);
|
||||
}
|
||||
idx++;
|
||||
// Honor --json-output: write to that path BEFORE returning.
|
||||
if (script.jsonOutput) {
|
||||
const flagIdx = args.indexOf('--json-output');
|
||||
if (flagIdx !== -1 && args[flagIdx + 1]) {
|
||||
writeFileSync(args[flagIdx + 1]!, JSON.stringify(script.jsonOutput));
|
||||
}
|
||||
}
|
||||
if (script.destOutput) {
|
||||
const flagIdx = args.indexOf('--destination');
|
||||
if (flagIdx !== -1 && args[flagIdx + 1]) {
|
||||
writeFileSync(args[flagIdx + 1]!, script.destOutput);
|
||||
}
|
||||
}
|
||||
return makeReturn(script.exitCode ?? 0, script.stdout ?? '', script.stderr ?? '');
|
||||
};
|
||||
}
|
||||
|
||||
function makeReturn(exit: number, stdout: string, stderr: string) {
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
status: exit,
|
||||
signal: null,
|
||||
} as ReturnType<SpawnImpl>;
|
||||
}
|
||||
|
||||
describe('bootstrapTunnel', () => {
|
||||
test('returns no_devices when devicectl list shows zero', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: { result: { devices: [] } },
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('no_devices');
|
||||
});
|
||||
|
||||
test('returns no_paired_device when device is connected but not paired', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: {
|
||||
devices: [{
|
||||
identifier: 'TEST-UDID',
|
||||
connectionProperties: { tunnelState: 'available (pairing)', pairingState: 'unpaired' },
|
||||
deviceProperties: { name: 'Test iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('no_paired_device');
|
||||
expect(r.detail).toContain('Trust');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns device_locked when launchApp errors due to lock', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device process launch/,
|
||||
stderr: 'Locked ("Unable to launch com.test because the device was not, or could not be, unlocked").',
|
||||
exitCode: 1,
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('device_locked');
|
||||
});
|
||||
|
||||
test('returns state_server_unreachable when healthz never responds', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///private/var/containers/Bundle/Application/.../com.test.app/com.test', processIdentifier: 1234 }] } },
|
||||
stdout: 'com.test',
|
||||
},
|
||||
{
|
||||
// devicectl device info details (devicectl-based IPv6 resolution).
|
||||
// Return no tunnelIPAddress so we fall through to the injected resolver.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: {} } },
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: async () => ['fd00::1'],
|
||||
// fetch always fails.
|
||||
fetchImpl: (async () => { throw new Error('connection refused'); }) as typeof fetch,
|
||||
startupTimeoutMs: 200, // short, so test runs fast
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
|
||||
});
|
||||
|
||||
test('happy path: returns DeviceTunnel with rotated token', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST-UDID',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test Device' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test', processIdentifier: 5678 }] } },
|
||||
stdout: '/com.test.app/',
|
||||
},
|
||||
{
|
||||
// devicectl-based IPv6 resolution succeeds — returns the tunnel
|
||||
// address directly, so the injected resolveImpl is never called.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from/,
|
||||
destOutput: 'BOOT-TOKEN-XYZ-123\n',
|
||||
},
|
||||
]);
|
||||
const fetchCalls: Array<{ url: string; method: string }> = [];
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: async () => ['fd99::beef'],
|
||||
fetchImpl: (async (url, init) => {
|
||||
const u = String(url);
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
fetchCalls.push({ url: u, method });
|
||||
if (u.endsWith('/healthz')) {
|
||||
return new Response('{"version":"1.0.0"}', { status: 200 }) as Response;
|
||||
}
|
||||
if (u.endsWith('/auth/rotate') && method === 'POST') {
|
||||
// Verify the boot token is sent (not the rotated one).
|
||||
const auth = (init?.headers as Record<string, string>)['Authorization'] ?? '';
|
||||
if (auth !== 'Bearer BOOT-TOKEN-XYZ-123') {
|
||||
return new Response('wrong bearer', { status: 401 }) as Response;
|
||||
}
|
||||
return new Response('{"ok":true}', { status: 200 }) as Response;
|
||||
}
|
||||
return new Response('not found', { status: 404 }) as Response;
|
||||
}) as typeof fetch,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.tunnel.udid).toBe('TEST-UDID');
|
||||
expect(r.tunnel.ipv6Addr).toBe('fd99::beef');
|
||||
expect(r.tunnel.port).toBe(9999);
|
||||
expect(r.tunnel.bootTokenRotated).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(r.tunnel.bootTokenRotated).not.toBe('BOOT-TOKEN-XYZ-123');
|
||||
expect(r.tunnel.bootTokenRotated.length).toBeGreaterThan(20);
|
||||
}
|
||||
// Verify the bootstrap sequence: /healthz first, /auth/rotate second.
|
||||
expect(fetchCalls[0]?.url).toContain('/healthz');
|
||||
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
|
||||
});
|
||||
|
||||
test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
// jsonOutput body contains the bundle id path, so isAppRunning() returns true.
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
// devicectl device info details returns no tunnel address.
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: {} } },
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: async () => { throw new Error('ENOTFOUND'); },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('resolve_failed');
|
||||
});
|
||||
|
||||
test('respects explicit udid when set', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{ identifier: 'A', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'A' }, hardwareProperties: { productType: 'iPhone18,2' } },
|
||||
{ identifier: 'B', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'B' }, hardwareProperties: { productType: 'iPhone18,2' } },
|
||||
] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes -d B/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details --device B/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device copy from --device B/,
|
||||
destOutput: 'TOKEN\n',
|
||||
},
|
||||
]);
|
||||
const r = await bootstrapTunnel({
|
||||
udid: 'B',
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: async () => ['fd00::b'],
|
||||
fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceTunnelIPv6FromDevicectl', () => {
|
||||
test('extracts tunnelIPAddress from connectionProperties', () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details --device TEST-UDID/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fde4:2827:528e::1' } } },
|
||||
},
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('TEST-UDID', spawn)).toBe('fde4:2827:528e::1');
|
||||
});
|
||||
|
||||
test('falls back to result.tunnel.ipAddress when connectionProperties absent', () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { tunnel: { ipAddress: 'fd00::dead:beef' } } },
|
||||
},
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBe('fd00::dead:beef');
|
||||
});
|
||||
|
||||
test('returns null when devicectl exits non-zero', () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1, stderr: 'no such device' },
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when tunnelIPAddress missing or non-string', () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
|
||||
]);
|
||||
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTunnelIPv6 fallback chain', () => {
|
||||
test('prefers devicectl-based resolution', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd11::1' } } },
|
||||
},
|
||||
]);
|
||||
let resolveCalled = false;
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { resolveCalled = true; return ['fd99::99']; },
|
||||
legacyResolve: async () => { resolveCalled = true; return ['fdAA::AA']; },
|
||||
});
|
||||
expect(addr).toBe('fd11::1');
|
||||
expect(resolveCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('falls through to dns.lookup when devicectl yields no address', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
|
||||
]);
|
||||
let legacyCalled = false;
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => ['fd22::2'],
|
||||
legacyResolve: async () => { legacyCalled = true; return ['fdAA::AA']; },
|
||||
});
|
||||
expect(addr).toBe('fd22::2');
|
||||
expect(legacyCalled).toBe(false);
|
||||
});
|
||||
|
||||
test('falls through to legacy resolve6 when both devicectl and dns.lookup fail', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1 },
|
||||
]);
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
legacyResolve: async () => ['fd33::3'],
|
||||
});
|
||||
expect(addr).toBe('fd33::3');
|
||||
});
|
||||
|
||||
test('returns null when all three strategies fail', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{ argsMatch: /devicectl device info details/, exitCode: 1 },
|
||||
]);
|
||||
const addr = await resolveTunnelIPv6({
|
||||
udid: 'U',
|
||||
deviceName: 'Test',
|
||||
spawn,
|
||||
resolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
legacyResolve: async () => { throw new Error('ESERVFAIL'); },
|
||||
});
|
||||
expect(addr).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startTunnelKeepalive', () => {
|
||||
test('invokes devicectl on each interval tick', async () => {
|
||||
const calls: string[] = [];
|
||||
const spawn: SpawnImpl = ((cmd: string, args: string[]) => {
|
||||
calls.push(`${cmd} ${args.slice(0, 4).join(' ')}`);
|
||||
return makeReturn(0, '{}', '');
|
||||
}) as SpawnImpl;
|
||||
const ka = startTunnelKeepalive('UDID-X', { intervalMs: 20, spawn });
|
||||
await new Promise((res) => setTimeout(res, 75));
|
||||
ka.stop();
|
||||
const before = calls.length;
|
||||
// After stop, no more calls.
|
||||
await new Promise((res) => setTimeout(res, 50));
|
||||
expect(calls.length).toBe(before);
|
||||
expect(before).toBeGreaterThanOrEqual(2);
|
||||
expect(calls[0]).toContain('devicectl');
|
||||
expect(calls[0]).toContain('device info details');
|
||||
});
|
||||
|
||||
test('stop() is idempotent', () => {
|
||||
const spawn: SpawnImpl = (() => makeReturn(0, '', '')) as SpawnImpl;
|
||||
const ka = startTunnelKeepalive('U', { intervalMs: 1_000, spawn });
|
||||
ka.stop();
|
||||
ka.stop();
|
||||
// no throw
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user