/** * 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; };