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.
257 lines
9.1 KiB
JavaScript
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;
|
|
}; |