Files
dashcaddy/dashcaddy-api/routes/tailscale-admin.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

257 lines
9.1 KiB
JavaScript

/**
* Tailscale admin & settings routes
*
* Two distinct surfaces, both gated by DashCaddy's TOTP auth:
*
* GET /api/v1/tailscale/settings
* Returns { configured, tailnetName, deviceCount, keyValidatedAt }
* NEVER returns the raw API token.
*
* PUT /api/v1/tailscale/settings
* body: { apiToken: 'tskey-api-...' }
* Validates by pinging /api/v2/tailnet/-/preferences. On success,
* stores the token encrypted and writes tailscale-config.json metadata.
* Returns the same shape as GET (without the token).
*
* DELETE /api/v1/tailscale/settings
* Clears the stored token and metadata.
*
* POST /api/v1/tailscale/settings/test
* body: { apiToken?: 'tskey-api-...' } // optional; defaults to stored
* Pings Tailscale with the given token (or stored one) and returns
* { valid: bool, tailnetName?, error? }. Does NOT save anything.
*
* GET /api/v1/tailscale/admin/devices
* Lists all devices in the tailnet via the coord API. 503 if not configured.
*
* GET /api/v1/tailscale/admin/users
* Lists tailnet users.
*
* GET /api/v1/tailscale/admin/keys
* Lists pre-auth keys (metadata only, never the secret).
*
* POST /api/v1/tailscale/admin/keys
* body: { reusable?, ephemeral?, preauthorized?, tags?, description?, expirySeconds? }
* Creates a new pre-auth key. Returns { id, key } — the `key` is the
* ONLY time the secret is available, callers must show it to the user
* immediately and not store it.
*
* DELETE /api/v1/tailscale/admin/keys/:id
* Revokes a pre-auth key.
*
* DELETE /api/v1/tailscale/admin/devices/:id
* Revokes a device from the tailnet.
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
module.exports = function({
tailscaleCoord,
asyncHandler,
log,
logError: _logError,
}) {
const router = express.Router();
// ---------- Settings ----------
router.get('/settings', asyncHandler(
// eslint-disable-next-line require-await
async (req, res) => {
const meta = tailscaleCoord.loadMetadata();
if (!meta.configured) {
return ok(res, { configured: false });
}
return ok(res, {
configured: true,
tailnetName: meta.tailnetName || null,
deviceCount: typeof meta.deviceCount === 'number' ? meta.deviceCount : null,
keyValidatedAt: meta.keyValidatedAt || null,
lastUsedAt: meta.lastUsedAt || null,
});
}));
router.put('/settings', asyncHandler(async (req, res) => {
const token = req.body && req.body.apiToken;
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
}
// Validate before storing
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
let prefs;
try {
prefs = await client.ping();
} catch (e) {
if (e instanceof TailscaleCoordError) {
if (e.code === 'unauthorized') {
return errorResponse(res, 401, 'Tailscale rejected this API token (401 unauthorized)');
}
return errorResponse(res, 502, 'Tailscale API error: ' + e.message);
}
throw e;
}
// Get device count for the metadata
let deviceCount = null;
try {
const devs = await client.listDevices();
deviceCount = devs.length;
} catch (_e) { /* non-fatal */ }
// Persist token (encrypted) + metadata (plaintext)
await tailscaleCoord.setApiToken(token);
tailscaleCoord.saveMetadata({
configured: true,
tailnetName: prefs.domain || null,
deviceCount,
keyValidatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
});
if (log && log.info) log.info('tailscale-coord', 'API token configured', { tailnetName: prefs.domain, deviceCount });
return ok(res, {
configured: true,
tailnetName: prefs.domain || null,
deviceCount,
keyValidatedAt: new Date().toISOString(),
});
}));
router.delete('/settings', asyncHandler(async (req, res) => {
await tailscaleCoord.setApiToken(null);
tailscaleCoord.saveMetadata({ configured: false });
if (log && log.info) log.info('tailscale-coord', 'API token cleared');
return ok(res, { configured: false });
}));
router.post('/settings/test', asyncHandler(async (req, res) => {
const token = (req.body && req.body.apiToken) || null;
const client = await tailscaleCoord.getClient();
if (token) {
// Caller provided a fresh token to test — don't save it
client.setApiToken(token);
}
if (!client.isConfigured()) {
return ok(res, { valid: false, error: 'No Tailscale API token configured' });
}
try {
const prefs = await client.ping({ skipCache: true });
return ok(res, { valid: true, tailnetName: prefs.domain });
} catch (e) {
if (e instanceof TailscaleCoordError && e.code === 'unauthorized') {
return ok(res, { valid: false, error: 'Tailscale rejected the token (unauthorized)' });
}
return ok(res, { valid: false, error: e.message });
}
}));
// ---------- Admin: devices ----------
router.get('/admin/devices', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
try {
const devices = await client.listDevices();
return ok(res, { devices, count: devices.length });
} catch (e) {
if (e instanceof TailscaleCoordError && e.code === 'unauthorized') {
return errorResponse(res, 401, 'Tailscale rejected the configured token');
}
throw e;
}
}));
router.delete('/admin/devices/:id', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const id = req.params.id;
try {
await client.deleteDevice(id);
if (log && log.info) log.info('tailscale-coord', 'Device deleted', { deviceId: id });
return ok(res, { success: true, deviceId: id });
} catch (e) {
if (e instanceof TailscaleCoordError) {
if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized');
if (e.code === 'not_found') return errorResponse(res, 404, 'Device not found');
}
throw e;
}
}));
// ---------- Admin: users ----------
router.get('/admin/users', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const users = await client.listUsers();
return ok(res, { users, count: users.length });
}));
// ---------- Admin: pre-auth keys ----------
router.get('/admin/keys', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const keys = await client.listAuthKeys();
return ok(res, { keys, count: keys.length });
}));
router.post('/admin/keys', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const opts = req.body || {};
// Reject obviously-bad input early
if (opts.tags && !Array.isArray(opts.tags)) {
return errorResponse(res, 400, 'tags must be an array of strings');
}
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
}
try {
const result = await client.createAuthKey(opts);
if (log && log.info) log.info('tailscale-coord', 'Auth key created', { id: result.id, description: opts.description, tags: opts.tags });
return ok(res, result);
} catch (e) {
if (e instanceof TailscaleCoordError) {
if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized');
return errorResponse(res, 502, 'Tailscale API error: ' + e.message);
}
throw e;
}
}));
router.delete('/admin/keys/:id', asyncHandler(async (req, res) => {
const client = await tailscaleCoord.getClient();
if (!client.isConfigured()) {
return errorResponse(res, 503, 'Tailscale API token not configured');
}
const id = req.params.id;
try {
await client.deleteAuthKey(id);
if (log && log.info) log.info('tailscale-coord', 'Auth key deleted', { keyId: id });
return ok(res, { success: true, keyId: id });
} catch (e) {
if (e instanceof TailscaleCoordError) {
if (e.code === 'unauthorized') return errorResponse(res, 401, 'Unauthorized');
if (e.code === 'not_found') return errorResponse(res, 404, 'Key not found');
}
throw e;
}
}));
return router;
};