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('');
+ });
+});