Skill Tracer
Skills › gstack › ios-qa › tailscale-localapi.ts

tailscale-localapi.ts

120 lines · ios-qa/daemon/src/tailscale-localapi.ts at 3f79d81

1// tailscaled LocalAPI client. Reads the unix socket at /var/run/tailscale.sock
2// (or wherever tailscaled is listening), calls WhoIs, returns a canonicalized
3// identity string.
4//
5// **Fail-closed semantics:** every error path here MUST be surfaced as a
6// reason the tailnet listener should refuse to open. Daemon caller must
7// distinguish "socket missing" (Tailscale not installed) from "WhoIs returned
8// unparseable response" (Tailscale broken) so the user knows what to fix.
9
10import { request as httpRequest } from 'http';
11import type { WhoIsResult } from './types';
13export interface TailscaleProbe {
14 ok: boolean;
15 reason?: 'socket_missing' | 'permission_denied' | 'whois_unparseable' | 'unreachable';
16 ownIdentity?: string;
19/**
20 * Probe whether tailscaled LocalAPI is usable. Called before opening the
21 * tailnet listener. Returns ok=true only if WhoIs against the daemon's own
22 * identity returns a parseable result.
23 */
24export async function probeTailscale(socketPath: string = '/var/run/tailscale.sock'): Promise<TailscaleProbe> {
25 try {
26 const result = await whoIs('127.0.0.1:9999', socketPath);
27 return { ok: true, ownIdentity: result.identity };
28 } catch (err: unknown) {
29 const e = err as { code?: string; message?: string };
30 if (e.code === 'ENOENT' || (e.message ?? '').includes('ENOENT')) {
31 return { ok: false, reason: 'socket_missing' };
32 }
33 if (e.code === 'EACCES' || (e.message ?? '').includes('EACCES')) {
34 return { ok: false, reason: 'permission_denied' };
35 }
36 if ((e.message ?? '').includes('unparseable') || (e.message ?? '').includes('JSON')) {
37 return { ok: false, reason: 'whois_unparseable' };
38 }
39 return { ok: false, reason: 'unreachable' };
40 }
43/**
44 * Call /localapi/v0/whois?addr=<addr:port>. Returns canonicalized identity.
45 *
46 * Canonicalization rules (matches Tailscale convention):
47 * - User OAuth: `user@example.com` (no acct: prefix, lowercase email)
48 * - Tagged nodes: `tag:<name>` (lowercased)
49 * - Node keys: `node:<hex>` (rare, prefer tags)
50 */
51export async function whoIs(addr: string, socketPath: string = '/var/run/tailscale.sock'): Promise<WhoIsResult> {
52 return new Promise((resolve, reject) => {
53 const req = httpRequest({
54 socketPath,
55 path: `/localapi/v0/whois?addr=${encodeURIComponent(addr)}`,
56 method: 'GET',
57 headers: { Host: 'local-tailscaled.sock' },
58 }, (res) => {
59 const chunks: Buffer[] = [];
60 res.on('data', (c) => chunks.push(c));
61 res.on('end', () => {
62 if (res.statusCode !== 200) {
63 reject(new Error(`whois http ${res.statusCode}`));
64 return;
65 }
66 try {
67 const raw = Buffer.concat(chunks).toString('utf-8');
68 const obj = JSON.parse(raw) as Record<string, unknown>;
69 const identity = canonicalize(obj);
70 if (!identity) {
71 reject(new Error('whois response unparseable'));
72 return;
73 }
74 resolve({ identity, raw: obj });
75 } catch (e) {
76 reject(new Error(`whois response unparseable: ${(e as Error).message}`));
77 }
78 });
79 });
80 req.on('error', reject);
81 req.end();
82 });
85/**
86 * Reduce a WhoIs response object to a canonical identity string.
87 *
88 * Expected response shape (Tailscale LocalAPI v0):
89 * {
90 * "Node": { "ComputedName": "...", "Tags": ["tag:ci"], ... },
91 * "UserProfile": { "LoginName": "user@example.com", ... },
92 * }
93 */
94export function canonicalize(obj: Record<string, unknown>): string | null {
95 // Tagged node — tag is more specific than user identity for ACL purposes.
96 const node = obj.Node as Record<string, unknown> | undefined;
97 if (node) {
98 const tags = node.Tags as string[] | undefined;
99 if (Array.isArray(tags) && tags.length > 0 && typeof tags[0] === 'string') {
100 const tag = tags[0].toLowerCase();
101 // Tags from Tailscale are already in `tag:foo` form.
102 return tag.startsWith('tag:') ? tag : `tag:${tag}`;
103 }
104 }
105 const profile = obj.UserProfile as Record<string, unknown> | undefined;
106 if (profile) {
107 const loginName = profile.LoginName as string | undefined;
108 if (typeof loginName === 'string' && loginName.includes('@')) {
109 return loginName.toLowerCase();
110 }
111 }
112 // Fallback to node key — rare but possible.
113 if (node) {
114 const key = node.Key as string | undefined;
115 if (typeof key === 'string' && key.startsWith('nodekey:')) {
116 return `node:${key.replace('nodekey:', '')}`;
117 }
118 }
119 return null;