mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-27 03:59:59 +02:00
feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy
On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Allowlist file at ~/.gstack/ios-qa-allowlist.json. The single source of
|
||||
// truth for who can call what at which capability tier.
|
||||
//
|
||||
// Self-service mint over tailnet ONLY succeeds for identities present in the
|
||||
// allowlist. Owner-granted mint (CLI on the Mac) is what writes new entries
|
||||
// to the allowlist. Self-service mint NEVER auto-allowlists.
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { Allowlist, AllowlistEntry, Capability } from './types';
|
||||
import { capabilityCovers } from './types';
|
||||
|
||||
export function defaultAllowlistPath(): string {
|
||||
return process.env.GSTACK_IOS_ALLOWLIST_PATH
|
||||
?? join(homedir(), '.gstack', 'ios-qa-allowlist.json');
|
||||
}
|
||||
|
||||
export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Allowlist;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
||||
throw new Error('invalid_allowlist');
|
||||
}
|
||||
return parsed;
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ENOENT') {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise<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,85 @@
|
||||
// /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;
|
||||
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',
|
||||
});
|
||||
return { error: 'identity_not_allowed' };
|
||||
}
|
||||
|
||||
// Must have at least the requested capability.
|
||||
if (!hasCapability(allowlist, opts.callerIdentity, wantedCap)) {
|
||||
await writeAttempt({
|
||||
rawIdentity: opts.callerIdentity,
|
||||
endpoint: opts.endpoint ?? '/auth/mint',
|
||||
reason: 'capability_insufficient',
|
||||
});
|
||||
return { error: 'capability_insufficient' };
|
||||
}
|
||||
|
||||
// Find the entry to determine the highest tier they can hold.
|
||||
const entry = allowlist.entries.find(e => e.identity === opts.callerIdentity);
|
||||
// Mint at the requested tier, capped at the highest granted tier.
|
||||
const grantedTier = entry?.capabilities.find(c => capabilityCovers(c, wantedCap)) ?? wantedCap;
|
||||
|
||||
const result = opts.tokenStore.mint({
|
||||
identity: opts.callerIdentity,
|
||||
capability: grantedTier,
|
||||
deviceUdid: opts.request.device_udid ?? null,
|
||||
origin: 'self_service',
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
await writeAttempt({
|
||||
rawIdentity: opts.callerIdentity,
|
||||
endpoint: opts.endpoint ?? '/auth/mint',
|
||||
reason: 'rate_limited',
|
||||
});
|
||||
return { error: 'rate_limited' };
|
||||
}
|
||||
|
||||
return {
|
||||
session_token: result.token,
|
||||
expires_at: result.expires_at,
|
||||
capability: result.capability,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// gstack-ios-qa-daemon entrypoint.
|
||||
//
|
||||
// Two listeners:
|
||||
// - Loopback (127.0.0.1 + ::1): full command surface for the spawning agent.
|
||||
// - Tailnet (optional, --tailnet flag): capability-tier allowlist.
|
||||
//
|
||||
// The tailnet listener is opened ONLY if:
|
||||
// 1. The user passed --tailnet at the CLI.
|
||||
// 2. The tailscaled LocalAPI socket probe succeeds (fail-closed otherwise).
|
||||
//
|
||||
// All tailnet ingress is auth-gated against the SessionTokenStore. Identity
|
||||
// validation uses tailscaled's WhoIs endpoint. Capability tiers come from
|
||||
// types.ts. Audit + attempts logging is in audit.ts.
|
||||
|
||||
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
||||
import { parse as parseUrl } from 'url';
|
||||
import { tryClaim } from './single-instance';
|
||||
import { probeTailscale, whoIs } from './tailscale-localapi';
|
||||
import { SessionTokenStore } from './session-tokens';
|
||||
import { mintForCaller } from './auth-mint';
|
||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
|
||||
import type { Capability } from './types';
|
||||
|
||||
interface DaemonOptions {
|
||||
loopbackPort: number;
|
||||
tailnetEnabled: boolean;
|
||||
tailnetSocketPath?: string;
|
||||
tailnetSessionTtlSeconds?: number;
|
||||
pidfilePath?: string;
|
||||
// Test injection
|
||||
tunnelProvider?: () => Promise<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,
|
||||
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>;
|
||||
}
|
||||
|
||||
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 } = ctx;
|
||||
const url = parseUrl(req.url ?? '/');
|
||||
const path = url.pathname ?? '/';
|
||||
const method = req.method ?? 'GET';
|
||||
const route = `${method} ${path}`;
|
||||
|
||||
try {
|
||||
// Classify the route.
|
||||
const classification = classifyRoute(method, path);
|
||||
if (!classification.allowed) {
|
||||
sendJson(res, 404, { error: 'endpoint_not_in_tailnet_allowlist', path });
|
||||
return;
|
||||
}
|
||||
const requiredCapability = classification.requiredCapability as Capability;
|
||||
|
||||
// /healthz on tailnet requires auth (codex catch).
|
||||
// No special-case; treated like every other observe-tier endpoint.
|
||||
|
||||
// /auth/mint — special path. No bearer required; uses WhoIs.
|
||||
if (method === 'POST' && path === '/auth/mint') {
|
||||
const peerAddr = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
||||
let callerIdentity: string;
|
||||
try {
|
||||
const who = await whoIsImpl(peerAddr);
|
||||
callerIdentity = who.identity;
|
||||
} catch (err) {
|
||||
await writeAttempt({
|
||||
rawIdentity: peerAddr,
|
||||
endpoint: route,
|
||||
reason: 'whois_unparseable',
|
||||
});
|
||||
sendJson(res, 502, { error: 'whois_failed', detail: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readBody(req);
|
||||
if ('error' in body) { sendJson(res, 413, body); return; }
|
||||
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { capability?: Capability; device_udid?: string };
|
||||
|
||||
const result = await mintForCaller({
|
||||
callerIdentity,
|
||||
request: parsed,
|
||||
tokenStore,
|
||||
endpoint: route,
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
const status = result.error === 'rate_limited' ? 429 : 403;
|
||||
sendJson(res, status, result);
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// All other endpoints: bearer auth + capability check.
|
||||
const auth = req.headers['authorization'] as string | undefined;
|
||||
const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null;
|
||||
const validation = tokenStore.validate(token, requiredCapability);
|
||||
if (!validation.ok) {
|
||||
await writeAttempt({
|
||||
rawIdentity: token ? 'token:' + token.slice(0, 8) : 'no_token',
|
||||
endpoint: route,
|
||||
reason: validation.reason,
|
||||
});
|
||||
const status = validation.reason === 'capability_insufficient' ? 403 : 401;
|
||||
sendJson(res, status, { error: validation.reason });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = validation.session;
|
||||
|
||||
// Read body once + enforce limit.
|
||||
const body = await readBody(req);
|
||||
if ('error' in body) { sendJson(res, 413, body); return; }
|
||||
|
||||
// Tailnet-only own-session revoke.
|
||||
if (method === 'POST' && path === '/auth/revoke') {
|
||||
tokenStore.revoke(session.token);
|
||||
sendJson(res, 200, { revoked: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy to device.
|
||||
const tunnel = await getTunnel();
|
||||
if (!tunnel) {
|
||||
sendJson(res, 503, { error: 'device_not_connected' });
|
||||
return;
|
||||
}
|
||||
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
|
||||
const upstream = await proxyToDevice({
|
||||
inbound: req,
|
||||
body,
|
||||
tunnel,
|
||||
sessionId,
|
||||
agentIdentity: session.identity,
|
||||
});
|
||||
|
||||
// Audit the action (mutating endpoints only).
|
||||
if (requiredCapability !== 'observe') {
|
||||
await writeAudit({
|
||||
ts: new Date().toISOString(),
|
||||
identity: session.identity,
|
||||
device_udid: tunnel.udid,
|
||||
endpoint: route,
|
||||
session_id: sessionId ?? '-',
|
||||
capability: session.capability,
|
||||
request_id: req.headers['x-request-id']?.toString() ?? '-',
|
||||
status: upstream.status,
|
||||
});
|
||||
}
|
||||
|
||||
res.writeHead(upstream.status, upstream.headers);
|
||||
res.end(upstream.body);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// CLI entry — runs when this file is executed directly, not when imported.
|
||||
if (import.meta.main) {
|
||||
const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10);
|
||||
const tailnet = process.argv.includes('--tailnet');
|
||||
startDaemon({
|
||||
loopbackPort: port,
|
||||
tailnetEnabled: tailnet,
|
||||
}).then((d) => {
|
||||
if ('error' in d) {
|
||||
process.stderr.write(`daemon error: ${d.error}\n`);
|
||||
process.exit(0); // exit 0 because READY was already printed
|
||||
}
|
||||
}).catch((err) => {
|
||||
process.stderr.write(`daemon fatal: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
Reference in New Issue
Block a user