From 47e9c4faf7f73bff9988bf64763d1e25ad34425a Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 17:11:40 -0400 Subject: [PATCH] feat: Tailscale sync jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a background job system that polls the Tailscale API on a configurable interval and syncs tailscale status and IPs to instances by hostname match. - New config table (key/value) in SQLite for persistent server-side settings - New server/jobs.js: runTailscaleSync + restartJobs scheduler - GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET - POST /api/jobs/tailscale/run — immediate manual sync - Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status Co-Authored-By: Claude Sonnet 4.6 --- index.html | 25 +++++++++++++++++ js/ui.js | 58 +++++++++++++++++++++++++++++++++++++++ server/db.js | 17 ++++++++++++ server/jobs.js | 63 ++++++++++++++++++++++++++++++++++++++++++ server/routes.js | 43 +++++++++++++++++++++++++++++ server/server.js | 2 ++ tests/api.test.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++ tests/db.test.js | 30 ++++++++++++++++++++ 8 files changed, 308 insertions(+) create mode 100644 server/jobs.js diff --git a/index.html b/index.html index 33b3e4c..11ce947 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,31 @@ +
+
Tailscale Sync
+

Sync Tailscale status and IPs by matching device hostnames to instance names.

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/js/ui.js b/js/ui.js index 4916263..2ed3e0d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -353,6 +353,7 @@ function openSettingsModal() { } } sel.value = getTimezone(); + loadTailscaleSettings(); document.getElementById('settings-modal').classList.add('open'); } @@ -424,3 +425,60 @@ document.getElementById('tz-select').addEventListener('change', e => { if (m) renderDetailPage(parseInt(m[1], 10)); else renderDashboard(); }); + +// ── Tailscale Settings ──────────────────────────────────────────────────────── + +async function loadTailscaleSettings() { + try { + const res = await fetch('/api/config'); + if (!res.ok) return; + const cfg = await res.json(); + document.getElementById('ts-enabled').checked = cfg.tailscale_enabled === '1'; + document.getElementById('ts-tailnet').value = cfg.tailscale_tailnet ?? ''; + document.getElementById('ts-api-key').value = cfg.tailscale_api_key ?? ''; + document.getElementById('ts-poll').value = cfg.tailscale_poll_minutes || '15'; + _updateTsStatus(cfg.tailscale_last_run_at, cfg.tailscale_last_result); + } catch { /* silent */ } +} + +function _updateTsStatus(lastRun, lastResult) { + const el = document.getElementById('ts-status'); + if (!lastRun) { el.textContent = 'Never run'; return; } + el.textContent = `Last run: ${fmtDateFull(lastRun)} — ${lastResult || '—'}`; +} + +async function saveTailscaleSettings() { + const body = { + tailscale_enabled: document.getElementById('ts-enabled').checked ? '1' : '0', + tailscale_tailnet: document.getElementById('ts-tailnet').value.trim(), + tailscale_api_key: document.getElementById('ts-api-key').value, + tailscale_poll_minutes: document.getElementById('ts-poll').value || '15', + }; + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + showToast(res.ok ? 'Tailscale settings saved' : 'Failed to save settings', res.ok ? 'success' : 'error'); +} + +async function runTailscaleNow() { + const btn = document.getElementById('ts-run-btn'); + btn.disabled = true; + btn.textContent = 'Running…'; + try { + const res = await fetch('/api/jobs/tailscale/run', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + showToast(`Sync complete — ${data.updated} updated`, 'success'); + _updateTsStatus(new Date().toISOString(), `ok: ${data.updated} updated of ${data.total}`); + } else { + showToast(data.error ?? 'Sync failed', 'error'); + } + } catch { + showToast('Sync failed', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Now'; + } +} diff --git a/server/db.js b/server/db.js index 645f852..77e01b6 100644 --- a/server/db.js +++ b/server/db.js @@ -53,6 +53,11 @@ function createSchema() { changed_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_history_vmid ON instance_history(vmid); + + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ); `); } @@ -187,6 +192,18 @@ export function getAllHistory() { return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); } +export function getConfig(key, defaultVal = '') { + const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key); + return row ? row.value : defaultVal; +} + +export function setConfig(key, value) { + db.prepare( + `INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(key, String(value)); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/jobs.js b/server/jobs.js new file mode 100644 index 0000000..7737812 --- /dev/null +++ b/server/jobs.js @@ -0,0 +1,63 @@ +import { getInstances, updateInstance, getConfig, setConfig } from './db.js'; + +const TAILSCALE_API = 'https://api.tailscale.com/api/v2'; + +let _interval = null; + +export async function runTailscaleSync() { + const apiKey = getConfig('tailscale_api_key'); + const tailnet = getConfig('tailscale_tailnet'); + if (!apiKey || !tailnet) throw new Error('Tailscale not configured'); + + const res = await fetch( + `${TAILSCALE_API}/tailnet/${encodeURIComponent(tailnet)}/devices`, + { headers: { Authorization: `Bearer ${apiKey}` } } + ); + if (!res.ok) throw new Error(`Tailscale API ${res.status}`); + + const { devices } = await res.json(); + + // hostname -> first 100.x.x.x address + const tsMap = new Map( + devices.map(d => [d.hostname, (d.addresses ?? []).find(a => a.startsWith('100.')) ?? '']) + ); + + const instances = getInstances(); + let updated = 0; + + for (const inst of instances) { + const tsIp = tsMap.get(inst.name); // undefined = not in Tailscale + const matched = tsIp !== undefined; + + const newTailscale = matched ? 1 : (inst.tailscale === 1 ? 0 : inst.tailscale); + const newIp = matched ? tsIp : (inst.tailscale === 1 ? '' : inst.tailscale_ip); + + if (newTailscale !== inst.tailscale || newIp !== inst.tailscale_ip) { + // Strip db-generated columns — node:sqlite rejects unknown named parameters + const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst; + updateInstance(inst.vmid, { ...instData, tailscale: newTailscale, tailscale_ip: newIp }); + updated++; + } + } + + return { updated, total: instances.length }; +} + +export function restartJobs() { + if (_interval) { clearInterval(_interval); _interval = null; } + if (getConfig('tailscale_enabled') !== '1') return; + + const mins = parseInt(getConfig('tailscale_poll_minutes', '15'), 10); + const ms = Math.max(1, Number.isFinite(mins) ? mins : 15) * 60_000; + + _interval = setInterval(async () => { + try { + const r = await runTailscaleSync(); + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `ok: ${r.updated} updated of ${r.total}`); + } catch (e) { + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `error: ${e.message}`); + } + }, ms); +} diff --git a/server/routes.js b/server/routes.js index eb77abf..319092d 100644 --- a/server/routes.js +++ b/server/routes.js @@ -2,7 +2,9 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, + getConfig, setConfig, } from './db.js'; +import { runTailscaleSync, restartJobs } from './jobs.js'; export const router = Router(); @@ -12,6 +14,12 @@ const VALID_STATES = ['deployed', 'testing', 'degraded']; const VALID_STACKS = ['production', 'development']; const SERVICE_KEYS = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda']; +const CONFIG_KEYS = [ + 'tailscale_api_key', 'tailscale_tailnet', 'tailscale_poll_minutes', + 'tailscale_enabled', 'tailscale_last_run_at', 'tailscale_last_result', +]; +const REDACTED = '**REDACTED**'; + function validate(body) { const errors = []; if (!body.name || typeof body.name !== 'string' || !body.name.trim()) @@ -160,3 +168,38 @@ router.delete('/instances/:vmid', (req, res) => { handleDbError('DELETE /api/instances/:vmid', e, res); } }); + +// GET /api/config +router.get('/config', (_req, res) => { + const cfg = {}; + for (const key of CONFIG_KEYS) { + const val = getConfig(key); + cfg[key] = (key === 'tailscale_api_key' && val) ? REDACTED : val; + } + res.json(cfg); +}); + +// PUT /api/config +router.put('/config', (req, res) => { + for (const key of CONFIG_KEYS) { + if (!(key in (req.body ?? {}))) continue; + if (key === 'tailscale_api_key' && req.body[key] === REDACTED) continue; + setConfig(key, req.body[key]); + } + try { restartJobs(); } catch (e) { console.error('PUT /api/config restartJobs', e); } + res.json({ ok: true }); +}); + +// POST /api/jobs/tailscale/run +router.post('/jobs/tailscale/run', async (req, res) => { + if (!getConfig('tailscale_api_key') || !getConfig('tailscale_tailnet')) + return res.status(400).json({ error: 'Tailscale not configured' }); + try { + const result = await runTailscaleSync(); + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `ok: ${result.updated} updated of ${result.total}`); + res.json(result); + } catch (e) { + handleDbError('POST /api/jobs/tailscale/run', e, res); + } +}); diff --git a/server/server.js b/server/server.js index 5f83e90..b1a05e4 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,7 @@ import helmet from 'helmet'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { router } from './routes.js'; +import { restartJobs } from './jobs.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT ?? 3000; @@ -47,5 +48,6 @@ app.use((err, _req, res, _next) => { // Boot — only when run directly, not when imported by tests if (process.argv[1] === fileURLToPath(import.meta.url)) { + restartJobs(); app.listen(PORT, () => console.log(`catalyst on :${PORT}`)); } diff --git a/tests/api.test.js b/tests/api.test.js index 2e9d8f5..45951f4 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -453,3 +453,73 @@ describe('error handling — unexpected DB failures', () => { ) }) }) + +// ── GET /api/config ─────────────────────────────────────────────────────────── + +describe('GET /api/config', () => { + it('returns 200 with all config keys', async () => { + const res = await request(app).get('/api/config') + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('tailscale_enabled') + expect(res.body).toHaveProperty('tailscale_api_key') + expect(res.body).toHaveProperty('tailscale_poll_minutes') + }) + + it('returns empty string for api key when not set', async () => { + expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('') + }) + + it('masks api key as **REDACTED** when set', async () => { + await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' }) + expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**') + }) +}) + +// ── PUT /api/config ─────────────────────────────────────────────────────────── + +describe('PUT /api/config', () => { + it('saves config and returns ok', async () => { + const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' }) + expect(res.status).toBe(200) + expect(res.body.ok).toBe(true) + }) + + it('does not overwrite api key when **REDACTED** is sent', async () => { + await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' }) + await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' }) + expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key') + }) +}) + +// ── POST /api/jobs/tailscale/run ────────────────────────────────────────────── + +describe('POST /api/jobs/tailscale/run', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('returns 400 when not configured', async () => { + const res = await request(app).post('/api/jobs/tailscale/run') + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/not configured/i) + }) + + it('updates matching instance and returns count', async () => { + await request(app).put('/api/config').send({ + tailscale_api_key: 'tskey-test', + tailscale_tailnet: 'example.com', + }) + await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 }) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }), + })) + + const res = await request(app).post('/api/jobs/tailscale/run') + expect(res.status).toBe(200) + expect(res.body.updated).toBe(1) + + const inst = await request(app).get('/api/instances/100') + expect(inst.body.tailscale).toBe(1) + expect(inst.body.tailscale_ip).toBe('100.64.0.2') + }) +}) diff --git a/tests/db.test.js b/tests/db.test.js index fee0400..e754633 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -3,6 +3,7 @@ import { _resetForTest, getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, + getConfig, setConfig, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -269,3 +270,32 @@ describe('test environment boot isolation', () => { expect(getInstances()).toEqual([]); }); }); + +// ── getConfig / setConfig ───────────────────────────────────────────────────── + +describe('getConfig / setConfig', () => { + it('returns defaultVal when key does not exist', () => { + expect(getConfig('missing', 'fallback')).toBe('fallback'); + }); + + it('returns empty string by default', () => { + expect(getConfig('missing')).toBe(''); + }); + + it('stores and retrieves a value', () => { + setConfig('tailscale_api_key', 'tskey-test'); + expect(getConfig('tailscale_api_key')).toBe('tskey-test'); + }); + + it('overwrites an existing key', () => { + setConfig('tailscale_enabled', '0'); + setConfig('tailscale_enabled', '1'); + expect(getConfig('tailscale_enabled')).toBe('1'); + }); + + it('config is cleared by _resetForTest', () => { + setConfig('tailscale_api_key', 'tskey-test'); + _resetForTest(); + expect(getConfig('tailscale_api_key')).toBe(''); + }); +}); -- 2.39.5