session-cache.ts
114 lines · ios-qa/daemon/src/session-cache.ts at 3f79d81
1// Persisted session bearer. A successful bootstrap consumes the app's one-shot
2// boot token, so a restarted daemon used to have no way back in except
3// relaunching the app, which wipes its QA state (#1796). The daemon now keeps
4// the rotated bearer in $GSTACK_STATE_ROOT/ios-qa-session.json and a fresh
5// daemon process resumes it once, at its first tunnel request.
6//
7// The boot token is never written here. The bearer is bound to the device,
8// app and port that issued it and is only ever sent through recoverTunnel in
9// pinned-address mode: to the address devicectl reports for that UDID, after
10// /healthz names the same bundle. It is presented at most once per daemon
11// process, and the file is cleared before every bootstrap, so a rejected or
12// foreign bearer is never replayed.
14import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
15import { dirname, join } from 'path';
16import { resolveStateRoot } from '../../../lib/state-root';
17import { recoverTunnel, type RecoveryOptions, type TunnelRecovery } from './tunnel-bootstrap';
18import type { DeviceTunnel } from './proxy';
20export interface PersistedSession {
21 version: 1;
22 udid: string;
23 bundle_id: string;
24 port: number;
25 bearer: string;
26 created_at: string;
27}
29export function defaultSessionPath(): string {
30 return join(resolveStateRoot(), 'ios-qa-session.json');
31}
33/** Read the persisted session. A file that is not private to this user, or not valid, is removed unused. */
34export function readSession(path: string): { session: PersistedSession | null; discarded?: string } {
35 let st;
36 try {
37 st = lstatSync(path);
38 } catch {
39 return { session: null };
40 }
41 const discard = (reason: string) => {
42 clearSession(path);
43 return { session: null, discarded: reason };
44 };
45 if (!st.isFile()) return discard('it is not a regular file');
46 if (process.platform !== 'win32') {
47 if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return discard(`it is owned by uid ${st.uid}`);
48 if ((st.mode & 0o077) !== 0) return discard(`other users can access it (mode ${(st.mode & 0o777).toString(8)})`);
49 }
50 let parsed: Partial<PersistedSession>;
51 try {
52 parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<PersistedSession>;
53 } catch {
54 return discard('it is not valid JSON');
55 }
56 if (
57 parsed?.version !== 1
58 || typeof parsed.udid !== 'string' || !parsed.udid
59 || typeof parsed.bundle_id !== 'string' || !parsed.bundle_id
60 || !Number.isInteger(parsed.port)
61 || typeof parsed.bearer !== 'string' || !parsed.bearer
62 ) {
63 return discard('it does not hold a version 1 session');
64 }
65 return { session: parsed as PersistedSession };
66}
68/** Write the session owner-only through a fresh temp file, so an existing file's mode or link never applies. */
69export function writeSession(tunnel: DeviceTunnel, bundleId: string, path: string): void {
70 mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
71 const session: PersistedSession = {
72 version: 1,
73 udid: tunnel.udid,
74 bundle_id: bundleId,
75 port: tunnel.port,
76 bearer: tunnel.bootTokenRotated,
77 created_at: new Date().toISOString(),
78 };
79 const tmp = `${path}.${process.pid}.tmp`;
80 rmSync(tmp, { force: true });
81 writeFileSync(tmp, `${JSON.stringify(session)}\n`, { mode: 0o600, flag: 'wx' });
82 renameSync(tmp, path);
83}
85/** Best effort: a directory at the path stays, and writeSession then reports it. */
86export function clearSession(path: string): void {
87 try { rmSync(path, { force: true }); } catch { /* ignore */ }
88}
90/**
91 * Decide whether a new daemon process can resume the persisted bearer. Only
92 * a session bound to the configured app, port and (when pinned) UDID is
93 * probed, and only through recoverTunnel's pinned-address mode.
94 */
95export async function resumeSession(
96 path: string,
97 opts: Omit<RecoveryOptions, 'pinnedAddressOnly'> & { port: number },
98): Promise<TunnelRecovery> {
99 const { session, discarded } = readSession(path);
100 if (discarded) {
101 return { action: 'bootstrap', reason: `ignored ${path} because ${discarded}` };
102 }
103 if (!session) return { action: 'bootstrap', reason: `no persisted session at ${path}` };
104 if (session.bundle_id !== opts.bundleId || session.port !== opts.port || (opts.udid && session.udid !== opts.udid)) {
105 return {
106 action: 'bootstrap',
107 reason: `persisted session belongs to ${session.bundle_id} on ${session.udid} port ${session.port}, not this target`,
108 };
109 }
110 return recoverTunnel(
111 { udid: session.udid, ipv6Addr: '', port: session.port, bootTokenRotated: session.bearer },
112 { ...opts, pinnedAddressOnly: true },
113 );
114}