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.
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
Reference in New Issue
Block a user