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.
405 lines
15 KiB
JavaScript
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),
|
|
}; |