Skill Tracer
Skills › gstack › ios-qa › proxy.ts

proxy.ts

160 lines · ios-qa/daemon/src/proxy.ts at 3f79d81

1// Tailnet → USB proxy. When an authenticated request hits the tailnet
2// listener and clears capability + allowlist checks, the daemon forwards it
3// to the iOS StateServer over the device's CoreDevice IPv6 tunnel, injecting
4// the rotated boot token in Authorization: Bearer and preserving the
5// X-Session-Id from the caller.
6
7import { request as httpRequest } from 'http';
8import type { IncomingMessage } from 'http';
9import { sanitizeReplacer } from './audit';
10import { tierForRoute } from './types';
12const MAX_BODY = 1_048_576; // 1MB hard cap on tailnet ingress
13// Hard cap on one upstream response held in memory (a full-resolution
14// screenshot is well under this). Larger responses fail instead of growing
15// the daemon without bound.
16export const MAX_RESPONSE_BODY = 64 * 1_048_576;
17export const DEFAULT_PROXY_TIMEOUT_MS = 30_000;
19export interface DeviceTunnel {
20 udid: string;
21 ipv6Addr: string;
22 port: number;
23 bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer
26export interface ProxyError {
27 status: number;
28 body: Record<string, unknown>;
31/**
32 * Forward a parsed inbound request to the StateServer. Returns the upstream
33 * response or a ProxyError. Caller writes to the ServerResponse.
34 */
35export async function proxyToDevice(opts: {
36 inbound: IncomingMessage;
37 body: Buffer;
38 tunnel: DeviceTunnel;
39 sessionId: string | null;
40 agentIdentity?: string;
41 /** Wall-clock deadline for the whole response (headers and body), not an idle timer. */
42 timeoutMs?: number;
43 maxResponseBytes?: number;
44}): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
45 const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
46 if (body.length > MAX_BODY) {
47 return makeError(413, 'body_too_large');
48 }
50 const headers: Record<string, string> = {
51 'authorization': `Bearer ${tunnel.bootTokenRotated}`,
52 'content-type': inbound.headers['content-type'] || 'application/json',
53 'content-length': String(body.length),
54 };
55 if (sessionId) headers['x-session-id'] = sessionId;
56 if (agentIdentity) headers['x-agent-identity'] = agentIdentity;
58 // Bracket IPv6 literals; pass IPv4 + hostnames bare. The CoreDevice tunnel
59 // is always IPv6 in production, but tests inject 127.0.0.1 to talk to a
60 // local stub. Detect by `:` count (IPv6 has multiple colons) or `:` absence
61 // (IPv4/hostname).
62 const isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2;
63 const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr;
64 const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`;
65 const maxResponseBytes = opts.maxResponseBytes ?? MAX_RESPONSE_BODY;
66 return new Promise((resolve, reject) => {
67 let settled = false;
68 let response: IncomingMessage | undefined;
69 let received = 0;
70 const chunks: Buffer[] = [];
71 const finish = (result: Awaited<ReturnType<typeof proxyToDevice>> | Error, destroy = false) => {
72 if (settled) return;
73 settled = true;
74 clearTimeout(deadline);
75 response?.off('data', onData);
76 response?.off('end', onEnd);
77 response?.off('aborted', onAborted);
78 chunks.length = 0;
79 if (destroy) req.destroy();
80 if (result instanceof Error) reject(result);
81 else resolve(result);
82 };
83 const onError = (err: Error) => {
84 const e = err as { code?: string };
85 if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') {
86 finish(makeError(503, 'device_disconnected'), true);
87 } else if (e.code === 'ETIMEDOUT') {
88 finish(makeError(504, 'upstream_timeout'), true);
89 } else {
90 finish(err, true);
91 }
92 };
93 const onAborted = () => onError(Object.assign(new Error('Upstream response aborted'), { code: 'ECONNRESET' }));
94 const tooLarge = () => finish(makeError(502, 'upstream_response_too_large'), true);
95 const onData = (chunk: Buffer) => {
96 received += chunk.length;
97 if (received > maxResponseBytes) tooLarge();
98 else chunks.push(chunk);
99 };
100 const onEnd = () => {
101 const respHeaders: Record<string, string> = {};
102 for (const [k, v] of Object.entries(response!.headers)) {
103 if (typeof v === 'string') respHeaders[k] = v;
104 }
105 finish({
106 status: response!.statusCode ?? 502,
107 headers: respHeaders,
108 body: Buffer.concat(chunks),
109 });
110 };
111 const req = httpRequest(url, { method: inbound.method, headers }, (res) => {
112 response = res;
113 res.on('error', onError);
114 res.once('close', () => {
115 if (!settled) onAborted();
116 res.off('error', onError);
117 });
118 if (settled) {
119 res.destroy();
120 return;
121 }
122 if (Number(res.headers['content-length'] ?? 0) > maxResponseBytes) {
123 tooLarge();
124 return;
125 }
126 res.on('data', onData);
127 res.once('end', onEnd);
128 res.once('aborted', onAborted);
129 });
130 const deadline = setTimeout(() => finish(makeError(504, 'upstream_timeout'), true), opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS);
131 req.on('error', onError);
132 req.once('close', () => {
133 if (!settled && !response) onAborted();
134 req.off('error', onError);
135 });
136 req.write(body);
137 req.end();
138 });
141function makeError(status: number, error: string): { status: number; headers: Record<string, string>; body: Buffer } {
142 const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
143 return {
144 status,
145 headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
146 body,
147 };
150/**
151 * Determine whether the endpoint is allowed on the tailnet listener AND what
152 * capability tier it requires.
153 */
154export function classifyRoute(method: string, path: string): {
155 allowed: boolean;
156 requiredCapability: ReturnType<typeof tierForRoute>;
157} {
158 const tier = tierForRoute(method, path);
159 return { allowed: tier !== null, requiredCapability: tier };