/** * Tailscale Coordination API client * * Wraps the public Tailscale coordination server API at * https://api.tailscale.com/api/v2/ * used to manage the user's tailnet from DashCaddy (device list, invite * keys, ACL edits). Distinct from src/managers/tailscale-manager.js, which * queries the *local* tailscaled daemon via the `tailscale` CLI for * status/read-side data. This module is the write-side: it talks to * Tailscale's cloud, so it requires a personal API token (configured in * DashCaddy settings, encrypted via credentialManager). * * Auth model: every request carries * Authorization: Bearer * The token is opaque to this module once retrieved; the route layer is * responsible for showing it to the user exactly once on create, never * echoing it in GET responses. * * Endpoints used (current as of Tailscale API v2, July 2026): * GET /api/v2/tailnet/{tailnet}/devices list all devices * GET /api/v2/device/{deviceId} one device * DELETE /api/v2/device/{deviceId} remove device from tailnet * POST /api/v2/tailnet/{tailnet}/keys create pre-auth key * GET /api/v2/tailnet/{tailnet}/keys list keys (metadata only) * DELETE /api/v2/keys/{keyId} delete a key * GET /api/v2/tailnet/{tailnet}/users list users * GET /api/v2/tailnet/{tailnet}/acl read ACL (HuJSON) * POST /api/v2/tailnet/{tailnet}/acl replace ACL (HuJSON) * * The `/api/v2/tailnet/-/preferences` endpoint that earlier versions of * this client used for ping() was retired by Tailscale (verified 2026-07-07). * ping() now hits /devices and derives the tailnet name from the * `MagicDNSSuffix` field on the first device. * * Failure modes: * - No token set → returns null from all methods; route layer decides UX * - 401 / 403 → token invalid; surface as { error: 'unauthorized' } * - 429 → rate limited; throw with retry-after info * - 5xx → transient; throw, route layer can retry * - Network error → throw; same as 5xx from the caller's POV * * Caching: device list is cached for 60 seconds (Tailnet state changes are * user-driven and rare; avoid hammering the API on dashboard polls). ACL, * users, keys, preferences are cached for 5 minutes. Writes invalidate * their own caches. The token-validity ping is cached separately for 1 hour. */ 'use strict'; const https = require('https'); const API_BASE = 'api.tailscale.com'; const API_PREFIX = '/api/v2'; const DEFAULT_TIMEOUT_MS = 10000; // Default TTLs (ms). Individual methods may override. const TTL_DEVICES_MS = 60 * 1000; const TTL_LIST_MS = 5 * 60 * 1000; const TTL_PING_MS = 60 * 60 * 1000; class TailscaleCoordError extends Error { constructor(message, { status, body, retryAfter, code } = {}) { super(message); this.name = 'TailscaleCoordError'; this.status = status; this.body = body; this.retryAfter = retryAfter; this.code = code || _codeFromStatus(status); } } function _codeFromStatus(status) { if (status === 401 || status === 403) return 'unauthorized'; if (status === 404) return 'not_found'; if (status === 429) return 'rate_limited'; if (status && status >= 500) return 'server_error'; return 'unknown'; } class TailscaleCoordClient { constructor({ apiToken, fetchImpl } = {}) { this.apiToken = apiToken || null; // Allow injection of a fetch-like function for tests. We only use the // subset of undici/fetch that maps cleanly to https.request — i.e. // a function returning { status, body, headers }. this.fetchImpl = fetchImpl || null; // cache: Map this._cache = new Map(); this._negativeCache = new Map(); } // ---------- public config / introspection ---------- isConfigured() { return !!this.apiToken; } /** * Set or clear the API token. Clears all caches because validity of * cached data depends on which token was used to fetch it. */ setApiToken(token) { this.apiToken = token || null; this._cache.clear(); this._negativeCache.clear(); } // ---------- cache helpers ---------- _cacheGet(key) { const entry = this._cache.get(key); if (!entry) return undefined; if (Date.now() >= entry.expiresAt) { this._cache.delete(key); return undefined; } return entry.value; } _cacheSet(key, value, ttlMs) { this._cache.set(key, { expiresAt: Date.now() + ttlMs, value }); this._negativeCache.delete(key); } _cacheInvalidate(prefix) { for (const k of [...this._cache.keys()]) { if (k.startsWith(prefix)) this._cache.delete(k); } } // ---------- low-level HTTP ---------- async _request(method, path, { body, query } = {}) { if (!this.apiToken) { throw new TailscaleCoordError('Tailscale API token not configured', { code: 'not_configured' }); } const qs = query ? '?' + Object.entries(query) .filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)) .join('&') : ''; const urlPath = API_PREFIX + path + qs; if (this.fetchImpl) { // Test path: callers pass a fetch-like impl that returns // { status, body, headers }. Body may be string (already serialized) // or undefined. const headers = { 'Authorization': 'Bearer ' + this.apiToken, 'Accept': 'application/json', }; const hasBody = body !== undefined; if (hasBody) headers['Content-Type'] = 'application/json'; const res = await this.fetchImpl(method, urlPath, { headers, body: hasBody ? JSON.stringify(body) : undefined, }); return _parseResponse(res); } // Production path: native https. return await new Promise((resolve, reject) => { const opts = { hostname: API_BASE, port: 443, path: urlPath, method, headers: { 'Authorization': 'Bearer ' + this.apiToken, 'Accept': 'application/json', 'User-Agent': 'DashCaddy/1.14.9 (+tailscale-coord)', }, timeout: DEFAULT_TIMEOUT_MS, }; let payload = null; if (body !== undefined) { payload = Buffer.from(JSON.stringify(body), 'utf8'); opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Length'] = payload.length; } const req = https.request(opts, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, body: raw, }); }); }); req.on('timeout', () => { req.destroy(new Error('timeout')); }); req.on('error', (e) => { reject(new TailscaleCoordError('Network error: ' + e.message, { code: 'network_error' })); }); if (payload) req.write(payload); req.end(); }).then(_parseResponse); } // ---------- public methods ---------- /** * Cheap liveness + token validity check. Hits * GET /api/v2/tailnet/-/devices * and derives the tailnet name from the `MagicDNSSuffix` field on the * first device. Returns an object with { domain, deviceCount }. * Throws TailscaleCoordError with code=unauthorized on bad token. * * (Earlier versions hit /preferences — that endpoint was retired by * Tailscale in 2026. /devices is the next-lightest read endpoint that * still exists.) */ async ping({ skipCache = false } = {}) { const cacheKey = 'ping'; if (!skipCache) { const cached = this._cacheGet(cacheKey); if (cached !== undefined) return cached; } // When ping cache was stale but listDevices cache might still be fresh, // we still need a fresh device list to rebuild the ping response — so // always force-skip the listDevices cache here. const devs = await this.listDevices({ skipCache: true }); // The Tailscale API puts the magic-DNS suffix on the `name` field, e.g. // `dns2-sami.tail3e209.ts.net`. Pull the last 3 components to extract // `tail3e209.ts.net`. Falls back to magicDNSSuffix if a future API // version exposes it explicitly. const firstDev = devs && devs[0]; let domain = null; if (firstDev) { const name = firstDev.name || ''; // Find the `ts.net` suffix and grab it + the segment before it. // Real tailnet suffixes are `tailXXXXX.ts.net` (3 components) or the // user's custom domain (could be 2+). Use a regex that captures // ".ts.net" or the last 2+ dot-separated parts of name. const m = name.match(/([a-z0-9-]+\.ts\.net)$/i); if (m) domain = m[1]; else if (firstDev.magicDNSSuffix) domain = firstDev.magicDNSSuffix; } const result = { domain, deviceCount: devs.length }; this._cacheSet(cacheKey, result, TTL_PING_MS); return result; } /** * List all devices in the tailnet. Returns the `devices` array from * GET /api/v2/tailnet/-/devices * Cached for TTL_DEVICES_MS. */ async listDevices({ skipCache = false } = {}) { const cacheKey = 'devices:list'; if (!skipCache) { const cached = this._cacheGet(cacheKey); if (cached !== undefined) return cached; } const data = await this._request('GET', '/tailnet/-/devices'); const devices = data.devices || []; this._cacheSet(cacheKey, devices, TTL_DEVICES_MS); return devices; } /** * Get one device by ID. NOT cached — call sites already have the device * list locally and want fresh data. */ async getDevice(deviceId) { if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' }); const data = await this._request('GET', '/device/' + encodeURIComponent(deviceId)); return data; } /** * Delete a device from the tailnet. Invalidates device caches. * DELETE /api/v2/device/{deviceId} * Returns { success: true } on 200. */ async deleteDevice(deviceId) { if (!deviceId) throw new TailscaleCoordError('deviceId required', { code: 'bad_input' }); await this._request('DELETE', '/device/' + encodeURIComponent(deviceId)); this._cacheInvalidate('devices:'); return { success: true }; } /** * Create a pre-auth key. Used by the share-invite flow (Phase 2). * POST /api/v2/tailnet/-/keys * Body fields accepted by Tailscale (only the ones we use): * - reusable: bool, default false * - ephemeral: bool, default false * - preauthorized: bool, default true (device joins without admin approval) * - tags: string[], e.g. ['tag:guest-plex'] * - expirySeconds: int, max 7776000 (90 days) * - description: string, free-form * Returns the full response: { id, key, created, expires, ... }. * The `key` field is shown to the user EXACTLY ONCE. * Invalidates keys:list cache. */ async createAuthKey(opts = {}) { const body = { reusable: opts.reusable !== undefined ? !!opts.reusable : false, ephemeral: opts.ephemeral !== undefined ? !!opts.ephemeral : false, preauthorized: opts.preauthorized !== undefined ? !!opts.preauthorized : true, }; if (Array.isArray(opts.tags) && opts.tags.length > 0) body.tags = opts.tags; if (typeof opts.description === 'string') body.description = opts.description; if (Number.isInteger(opts.expirySeconds) && opts.expirySeconds > 0) { body.expirySeconds = Math.min(opts.expirySeconds, 7776000); } const data = await this._request('POST', '/tailnet/-/keys', { body }); this._cacheInvalidate('keys:'); return data; } /** * List pre-auth keys. Note: the response includes metadata (id, created, * expires, description, capabilities) but never the secret value. * GET /api/v2/tailnet/-/keys */ async listAuthKeys({ skipCache = false } = {}) { const cacheKey = 'keys:list'; if (!skipCache) { const cached = this._cacheGet(cacheKey); if (cached !== undefined) return cached; } const data = await this._request('GET', '/tailnet/-/keys'); const keys = data.keys || []; this._cacheSet(cacheKey, keys, TTL_LIST_MS); return keys; } /** * Delete a pre-auth key. Invalidates keys:list cache. * DELETE /api/v2/keys/{keyId} */ async deleteAuthKey(keyId) { if (!keyId) throw new TailscaleCoordError('keyId required', { code: 'bad_input' }); await this._request('DELETE', '/keys/' + encodeURIComponent(keyId)); this._cacheInvalidate('keys:'); return { success: true }; } /** * List tailnet users (the human accounts). * GET /api/v2/tailnet/-/users */ async listUsers({ skipCache = false } = {}) { const cacheKey = 'users:list'; if (!skipCache) { const cached = this._cacheGet(cacheKey); if (cached !== undefined) return cached; } const data = await this._request('GET', '/tailnet/-/users'); const users = data.users || []; this._cacheSet(cacheKey, users, TTL_LIST_MS); return users; } /** * Read the current ACL as a HuJSON string. NOT cached — admins editing * ACLs want fresh data on every click. */ async getAcl() { return await this._request('GET', '/tailnet/-/acl'); } /** * Replace the ACL entirely. Caller is responsible for merging/validating * the HuJSON. Body must be the raw ACL object (not stringified). */ async updateAcl(aclObject) { if (!aclObject || typeof aclObject !== 'object' || Array.isArray(aclObject)) { throw new TailscaleCoordError('ACL must be a non-array object', { code: 'bad_input' }); } return await this._request('POST', '/tailnet/-/acl', { body: aclObject }); } } function _parseResponse(res) { const { status, body, headers } = res; let parsed = body; const ct = (headers && headers['content-type']) || ''; if (body && (ct.includes('application/json') || body.startsWith('{') || body.startsWith('['))) { try { parsed = JSON.parse(body); } catch (_e) { /* leave as string */ } } if (status >= 200 && status < 300) return parsed; // Extract retry-after if present const retryAfter = headers && (headers['retry-after'] || headers['Retry-After']); const message = (parsed && parsed.message) || (typeof parsed === 'string' ? parsed : 'HTTP ' + status); throw new TailscaleCoordError('Tailscale API ' + status + ': ' + message, { status, body: parsed, retryAfter, }); } module.exports = { TailscaleCoordClient, TailscaleCoordError, // For tests: a factory that builds a new instance. Most call sites use the // singleton via context, but tests + scripts that want isolation can use // this directly. create: (opts) => new TailscaleCoordClient(opts), };