feat: Tailscale sync jobs
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>
This commit is contained in:
63
server/jobs.js
Normal file
63
server/jobs.js
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user