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.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user