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:
Garry Tan
2026-05-17 19:06:01 -07:00
parent 9ffde24474
commit 3126363c2c
17 changed files with 2376 additions and 0 deletions
+146
View File
@@ -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 });
});
+111
View File
@@ -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}');
});
});
+103
View File
@@ -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,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';
+47
View File
@@ -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
});
});
+156
View File
@@ -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);
});
});