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 <noreply@anthropic.com>
206 lines
7.2 KiB
JavaScript
206 lines
7.2 KiB
JavaScript
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();
|
|
|
|
// ── Validation ────────────────────────────────────────────────────────────────
|
|
|
|
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())
|
|
errors.push('name is required');
|
|
if (!Number.isInteger(body.vmid) || body.vmid < 1)
|
|
errors.push('vmid must be a positive integer');
|
|
if (!VALID_STATES.includes(body.state))
|
|
errors.push(`state must be one of: ${VALID_STATES.join(', ')}`);
|
|
if (!VALID_STACKS.includes(body.stack))
|
|
errors.push(`stack must be one of: ${VALID_STACKS.join(', ')}`);
|
|
const ip = (body.tailscale_ip ?? '').trim();
|
|
if (ip && !/^(\d{1,3}\.){3}\d{1,3}$/.test(ip))
|
|
errors.push('tailscale_ip must be a valid IPv4 address or empty');
|
|
return errors;
|
|
}
|
|
|
|
function handleDbError(context, e, res) {
|
|
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' });
|
|
if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' });
|
|
console.error(context, e);
|
|
res.status(500).json({ error: 'internal server error' });
|
|
}
|
|
|
|
function normalise(body) {
|
|
const row = {
|
|
name: (body.name ?? '').trim(),
|
|
state: body.state,
|
|
stack: body.stack,
|
|
vmid: body.vmid,
|
|
tailscale_ip: (body.tailscale_ip ?? '').trim(),
|
|
hardware_acceleration: body.hardware_acceleration ? 1 : 0,
|
|
};
|
|
for (const svc of SERVICE_KEYS) row[svc] = body[svc] ? 1 : 0;
|
|
return row;
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/instances/stacks — must be declared before /:vmid
|
|
router.get('/instances/stacks', (_req, res) => {
|
|
res.json(getDistinctStacks());
|
|
});
|
|
|
|
// GET /api/instances
|
|
router.get('/instances', (req, res) => {
|
|
const { search, state, stack } = req.query;
|
|
res.json(getInstances({ search, state, stack }));
|
|
});
|
|
|
|
// GET /api/instances/:vmid/history
|
|
router.get('/instances/:vmid/history', (req, res) => {
|
|
const vmid = parseInt(req.params.vmid, 10);
|
|
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
|
|
if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' });
|
|
res.json(getInstanceHistory(vmid));
|
|
});
|
|
|
|
// GET /api/instances/:vmid
|
|
router.get('/instances/:vmid', (req, res) => {
|
|
const vmid = parseInt(req.params.vmid, 10);
|
|
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
|
|
|
|
const instance = getInstance(vmid);
|
|
if (!instance) return res.status(404).json({ error: 'instance not found' });
|
|
|
|
res.json(instance);
|
|
});
|
|
|
|
// POST /api/instances
|
|
router.post('/instances', (req, res) => {
|
|
const errors = validate(req.body);
|
|
if (errors.length) return res.status(400).json({ errors });
|
|
|
|
try {
|
|
const data = normalise(req.body);
|
|
createInstance(data);
|
|
const created = getInstance(data.vmid);
|
|
res.status(201).json(created);
|
|
} catch (e) {
|
|
handleDbError('POST /api/instances', e, res);
|
|
}
|
|
});
|
|
|
|
// PUT /api/instances/:vmid
|
|
router.put('/instances/:vmid', (req, res) => {
|
|
const vmid = parseInt(req.params.vmid, 10);
|
|
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
|
|
if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' });
|
|
|
|
const errors = validate(req.body);
|
|
if (errors.length) return res.status(400).json({ errors });
|
|
|
|
try {
|
|
const data = normalise(req.body);
|
|
updateInstance(vmid, data);
|
|
res.json(getInstance(data.vmid));
|
|
} catch (e) {
|
|
handleDbError('PUT /api/instances/:vmid', e, res);
|
|
}
|
|
});
|
|
|
|
// GET /api/export
|
|
router.get('/export', (_req, res) => {
|
|
const instances = getInstances();
|
|
const history = getAllHistory();
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`);
|
|
res.json({ version: 2, exported_at: new Date().toISOString(), instances, history });
|
|
});
|
|
|
|
// POST /api/import
|
|
router.post('/import', (req, res) => {
|
|
const { instances, history = [] } = req.body ?? {};
|
|
if (!Array.isArray(instances)) {
|
|
return res.status(400).json({ error: 'body must contain an instances array' });
|
|
}
|
|
const errors = [];
|
|
for (const [i, row] of instances.entries()) {
|
|
const errs = validate(normalise(row));
|
|
if (errs.length) errors.push({ index: i, errors: errs });
|
|
}
|
|
if (errors.length) return res.status(400).json({ errors });
|
|
try {
|
|
importInstances(instances.map(normalise), Array.isArray(history) ? history : []);
|
|
res.json({ imported: instances.length });
|
|
} catch (e) {
|
|
console.error('POST /api/import', e);
|
|
res.status(500).json({ error: 'internal server error' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/instances/:vmid
|
|
router.delete('/instances/:vmid', (req, res) => {
|
|
const vmid = parseInt(req.params.vmid, 10);
|
|
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
|
|
|
|
const instance = getInstance(vmid);
|
|
if (!instance) return res.status(404).json({ error: 'instance not found' });
|
|
if (instance.stack !== 'development')
|
|
return res.status(422).json({ error: 'only development instances can be deleted' });
|
|
|
|
try {
|
|
deleteInstance(vmid);
|
|
res.status(204).end();
|
|
} catch (e) {
|
|
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);
|
|
}
|
|
});
|