DC-043: tailscale coordination API client + admin/settings routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
Krystie
2026-07-06 21:28:03 -07:00
parent d04238621f
commit 6fb4f9b169
7 changed files with 1937 additions and 0 deletions
+61
View File
@@ -8,6 +8,8 @@ const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session');
const NotificationManager = require('../managers/notification-manager');
const tailscaleManager = require('../managers/tailscale-manager');
const { TailscaleCoordClient } = require('../managers/tailscale-coord');
const fs = require('fs');
/**
* Assemble the full application context
@@ -96,6 +98,34 @@ function assembleContext({
config: siteConfig
});
// --- Tailscale coordination API client --------------------------------------
// Reads the API token from credentialManager on every call (not cached on
// the client) so that PUT /api/v1/tailscale/settings takes effect
// immediately without restarting the process. The metadata file
// tailscale-config.json stores non-secret state (tailnet name, last
// validation time, device count) so we don't have to hit the API just to
// answer "is this configured?" in the UI.
function loadTailscaleMetadata() {
try {
if (TAILSCALE_CONFIG_FILE && fs.existsSync(TAILSCALE_CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(TAILSCALE_CONFIG_FILE, 'utf8'));
}
} catch (_e) { /* corrupt file → treat as unconfigured */ }
return { configured: false };
}
function saveTailscaleMetadata(meta) {
if (!TAILSCALE_CONFIG_FILE) return;
try {
fs.writeFileSync(TAILSCALE_CONFIG_FILE, JSON.stringify(meta, null, 2), 'utf8');
} catch (e) {
log.error('tailscale-coord', 'Failed to write tailscale-config.json', { error: e.message });
}
}
async function getCoordClient() {
const tok = await credentialManager.retrieve('tailscale.coord.apiToken');
return new TailscaleCoordClient({ apiToken: tok || null });
}
// Assemble flat context (temporary - routes still expect this)
// Note: tailscale interface detection lives in src/utilities/network-detector.js
// (DC-031). The empty `tailscale` stub previously wired here was dead code
@@ -122,6 +152,37 @@ function assembleContext({
stopSyncTimer: tailscaleManager.stopSyncTimer,
syncAPI: tailscaleManager.syncAPI,
},
// Tailscale coordination API client — talk to api.tailscale.com for
// device management, pre-auth key creation, ACL reads/writes, and user
// listing. Distinct from the local tailscaleManager above (which reads
// the local tailscaled daemon). The API token is stored encrypted via
// credentialManager and re-read on every call so settings changes take
// effect without process restart.
tailscaleCoord: {
// Returns a fresh client each call — cheap (just a Map + token lookup),
// and guarantees the latest token is used.
getClient: getCoordClient,
// Metadata helpers — read/write tailscale-config.json
loadMetadata: loadTailscaleMetadata,
saveMetadata: saveTailscaleMetadata,
// Storage helpers — wraps credentialManager so route code doesn't
// need to know the key naming convention.
setApiToken: async (token) => {
if (token) {
await credentialManager.store('tailscale.coord.apiToken', token, {
description: 'Tailscale coordination API token',
source: 'settings-ui',
});
} else {
await credentialManager.delete('tailscale.coord.apiToken');
}
},
hasApiToken: async () => {
const tok = await credentialManager.retrieve('tailscale.coord.apiToken');
return !!tok;
},
},
// App and config
app,