mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-10 01:48:38 +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,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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user