Files
dashcaddy/dashcaddy-api/src/managers/tailscale-coord.js
T
Krystie 6fb4f9b169
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-043: tailscale coordination API client + admin/settings routes
Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).

src/managers/tailscale-coord.js — new module:
  * Ping, list/get/delete devices, create/list/delete pre-auth keys,
    list users, get/update ACL
  * 5min read cache, 60s device-list cache, 1hr ping cache
  * Cache invalidation on writes
  * Graceful {configured:false} when no token
  * TailscaleCoordError class with code mapping (unauthorized, not_found,
    rate_limited, server_error)
  * fetchImpl injection point for tests; native https in production
  * 45 unit tests covering all paths

src/context/index.js — new tailscaleCoord namespace:
  * getClient() lazy-builds a fresh client each call (token re-read from
    credentialManager so settings changes take effect without restart)
  * loadMetadata/saveMetadata for tailscale-config.json
  * setApiToken/hasApiToken wrappers around credentialManager

routes/tailscale-admin.js — new routes:
  * GET    /api/v1/tailscale/settings         — config status, never the token
  * PUT    /api/v1/tailscale/settings         — validate + store encrypted
  * DELETE /api/v1/tailscale/settings         — wipe token + metadata
  * POST   /api/v1/tailscale/settings/test    — ping without saving
  * GET    /api/v1/tailscale/admin/devices    — full device list
  * DELETE /api/v1/tailscale/admin/devices/:id — revoke device
  * GET    /api/v1/tailscale/admin/users      — tailnet users
  * GET    /api/v1/tailscale/admin/keys       — pre-auth key metadata
  * POST   /api/v1/tailscale/admin/keys       — create pre-auth key (returns secret ONCE)
  * DELETE /api/v1/tailscale/admin/keys/:id   — revoke pre-auth key
  * 29 route integration tests with supertest

src/app.js — wired the new router into the /api/v1/tailscale mount.

BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
            Note: deliberately no token auto-rotation — Tailscale API keys
            don't auto-renew, and silently re-issuing admin credentials
            would erode the audit-trail checkpoint that token expiry
            provides.

Tests: 1167 -> 1212 (+45), all green. Lint clean.
2026-07-06 21:28:03 -07:00

405 lines
15 KiB
JavaScript

/**
* 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 <apiToken>
* 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<cacheKey, { expiresAt: number, value: any }>
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
// "<segment>.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),
};