/** * 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. * * # DC-080 input validation * * Three coupled gaps in the route layer pre-fix: * * (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but had * no length cap — body-parser limit was the only ceiling. A 1 MB * string starting with `tskey-api-` would be `.trim()`-ed, sent to * Tailscale's /devices endpoint, and waste server-side CPU on a * request that will always 401. * (b) POST /settings/test accepted `apiToken` from the body with NO * validation at all. The PUT route's prefix check is bypassed on * the test path — an operator could submit any string and have the * container ping Tailscale's API with it (low impact, but inconsistent * with PUT and surfaces fingerprinting via the 401 timing). * (c) POST /admin/keys validated `tags` as Array but NOT per-element * type — `tags: ['tag:guest', null, 123, {injection: true}]` would be * forwarded to Tailscale verbatim. Tailscale's API is JSON-strict * and would 400 the request, but the bad shape reached the wire. * Similarly `description` had no length cap (Tailscale caps at 120 * chars per their docs). * * All three are gated by TOTP — this is a logged-in-operator / phished- * session threat surface, not anonymous-unauth. The fix is defense-in- * depth: a bug in the auth path (TOTP bypass, session theft, future * route handler trust-boundary drift) should not turn these endpoints * into a "submit anything and forward to Tailscale" relay. */ const express = require('express'); const { ok, errorResponse } = require('../src/utils/responses'); const { TailscaleCoordError } = require('../src/managers/tailscale-coord'); // DC-080: shared validation helpers for the Tailscale admin surface. // Tailscale API tokens follow the form `tskey--` where // `` is one of a small set of values (`api`, `auth`, `partner`, // `cli`). Real tokens observed in the wild are 40..80 chars; we cap at // 256 to leave headroom for future Tailscale key formats without giving // an unbounded buffer to validate+forward. const TAILSCALE_TOKEN_PREFIX = 'tskey-api-'; const TAILSCALE_TOKEN_MAX_LEN = 256; const TAG_KEY_MAX_LEN = 64; const TAGS_MAX_LEN = 32; const DESCRIPTION_MAX_LEN = 120; // Tailscale tags are lowercased identifiers with optional colons // (e.g. `tag:server`, `tag:guest-plex`). Reject whitespace, CR/LF, // control chars, JSON metacharacters, and any character that could // enable header-injection through the Tailscale coord client. // // DC-080 round-2 polish: Tailscale's tag spec requires `tag:` followed by // ≥1 identifier char — bare `tag:` (empty name) is rejected by their API. // We split the pattern in two so the error message names which form failed // instead of dumping a generic regex. const TAG_KEY_RE = /^tag:[a-z0-9][a-z0-9_-]{0,62}$/; function _validateApiToken(token, fieldName = 'apiToken') { if (typeof token !== 'string' || !token) { return `${fieldName} is required and must be a string`; } if (!token.startsWith(TAILSCALE_TOKEN_PREFIX)) { return `${fieldName} must start with ${TAILSCALE_TOKEN_PREFIX}`; } if (token.length > TAILSCALE_TOKEN_MAX_LEN) { return `${fieldName} exceeds maximum length of ${TAILSCALE_TOKEN_MAX_LEN} characters`; } return null; } function _validateTags(tags) { if (tags === undefined || tags === null) return null; if (!Array.isArray(tags)) { return 'tags must be an array of strings'; } if (tags.length > TAGS_MAX_LEN) { return `tags exceeds maximum length of ${TAGS_MAX_LEN} entries`; } for (let i = 0; i < tags.length; i += 1) { const t = tags[i]; if (typeof t !== 'string' || !t) { return `tags[${i}] must be a non-empty string`; } if (t.length > TAG_KEY_MAX_LEN) { return `tags[${i}] exceeds maximum length of ${TAG_KEY_MAX_LEN} characters`; } if (!TAG_KEY_RE.test(t)) { return `tags[${i}] must match ${TAG_KEY_RE} (lowercase alnum + :_-)`; } } return null; } function _validateDescription(description) { if (description === undefined || description === null) return null; if (typeof description !== 'string') { return 'description must be a string'; } if (description.length > DESCRIPTION_MAX_LEN) { return `description exceeds maximum length of ${DESCRIPTION_MAX_LEN} characters`; } return null; } // Exported for direct unit testing in __tests__/routes/tailscale-admin.test.js // (the validator functions are otherwise unreachable from outside the factory // closure; direct tests assert edge cases without supertest overhead). const _validators = { validateApiToken: _validateApiToken, validateTags: _validateTags, validateDescription: _validateDescription, TAILSCALE_TOKEN_PREFIX, TAILSCALE_TOKEN_MAX_LEN, TAG_KEY_MAX_LEN, TAGS_MAX_LEN, DESCRIPTION_MAX_LEN, TAG_KEY_RE, }; 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; // DC-080: validate prefix + length cap. The pre-fix code only checked // the prefix — a 1 MB string starting with `tskey-api-` would have been // sent to Tailscale's /devices endpoint and wasted server-side CPU // before the inevitable 401. const tokenErr = _validateApiToken(token); if (tokenErr) return errorResponse(res, 400, tokenErr); // 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; // DC-080: validate any caller-provided token before it reaches the // Tailscale API. Pre-fix the test endpoint accepted any string — the // PUT route's prefix check did NOT extend to this path. An operator // could submit arbitrary junk and the container would still call // /devices on the Tailscale API with it (DoS-reflection + fingerprint // timing for a future attacker probing whether this API token format // is accepted at all). if (token !== null && token !== undefined) { const tokenErr = _validateApiToken(token); if (tokenErr) return errorResponse(res, 400, tokenErr); } 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. // DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`. // A `tags: ['tag:guest', null, 123, {injection: true}]` payload would // be forwarded to Tailscale verbatim — Tailscale's API is JSON-strict // and would 400 the request, but the bad shape reached the wire and // would silently pass through the dashboard's JSON.stringify() flow. const tagsErr = _validateTags(opts.tags); if (tagsErr) return errorResponse(res, 400, tagsErr); const descErr = _validateDescription(opts.description); if (descErr) return errorResponse(res, 400, descErr); 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; }; // DC-080: validators exported for direct unit testing in // __tests__/routes/tailscale-admin.test.js — the route factory closes // over the same functions, so the validators are exercised end-to-end via // supertest AND in isolation here. module.exports._validators = _validators;