Files
Catalyst/server/jobs.js
josh efa1750cac fix: base64-encode Patchmon Basic auth credentials server-side
Patchmon's API uses standard RFC 7617 Basic auth — `Basic base64(token_key:token_secret)`. The handler was sending the api_token field verbatim, so it only worked if the user had manually base64-encoded the credential. After a Patchmon upgrade, the sync started returning HTML (the SPA, served when auth is rejected) and failing with "Unexpected token '<'" on JSON.parse.

Now: if the token contains ':' (raw key:secret), encode it server-side; otherwise pass through unchanged for backward compatibility. UI gets a placeholder hint showing the expected format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:33:41 -04:00

157 lines
6.1 KiB
JavaScript

import { getJobs, getJob, getInstances, updateInstance, createJobRun, completeJobRun } from './db.js';
// ── Handlers ──────────────────────────────────────────────────────────────────
const TAILSCALE_API = 'https://api.tailscale.com/api/v2';
async function tailscaleSyncHandler(cfg) {
const { api_key, tailnet } = cfg;
if (!api_key || !tailnet) throw new Error('Tailscale not configured — set API key and tailnet');
const res = await fetch(
`${TAILSCALE_API}/tailnet/${encodeURIComponent(tailnet)}/devices`,
{ headers: { Authorization: `Bearer ${api_key}` } }
);
if (!res.ok) throw new Error(`Tailscale API ${res.status}`);
const { devices } = await res.json();
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);
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) {
const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst;
updateInstance(inst.vmid, { ...instData, tailscale: newTailscale, tailscale_ip: newIp });
updated++;
}
}
return { summary: `${updated} updated of ${instances.length}` };
}
// ── Patchmon Sync ─────────────────────────────────────────────────────────────
async function patchmonSyncHandler(cfg) {
const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token');
// Accept raw "key:secret" (recommended) or a pre-encoded base64 string.
// ":" cannot appear in a valid base64 string, so it's a reliable discriminator.
const credential = api_token.includes(':')
? Buffer.from(api_token).toString('base64')
: api_token;
const res = await fetch(api_url, {
headers: { Authorization: `Basic ${credential}` },
});
if (!res.ok) throw new Error(`Patchmon API ${res.status}`);
const data = await res.json();
const items = Array.isArray(data) ? data : (data.hosts ?? data.data ?? []);
const hostSet = new Set(
items.map(h => (typeof h === 'string' ? h : (h.name ?? h.hostname ?? h.host ?? '')))
.filter(Boolean)
);
const instances = getInstances();
let updated = 0;
for (const inst of instances) {
const newPatchmon = hostSet.has(inst.name) ? 1 : 0;
if (newPatchmon !== inst.patchmon) {
const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst;
updateInstance(inst.vmid, { ...instData, patchmon: newPatchmon });
updated++;
}
}
return { summary: `${updated} updated of ${instances.length}` };
}
// ── Semaphore Sync ────────────────────────────────────────────────────────────
async function semaphoreSyncHandler(cfg) {
const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Semaphore not configured — set API URL and token');
const res = await fetch(api_url, {
headers: { Authorization: `Bearer ${api_token}` },
});
if (!res.ok) throw new Error(`Semaphore API ${res.status}`);
const data = await res.json();
// Inventory is an Ansible INI string; extract bare hostnames
const hostSet = new Set(
(data.inventory ?? '').split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('[') && !l.startsWith('#') && !l.startsWith(';'))
.map(l => l.split(/[\s=]/)[0])
.filter(Boolean)
);
const instances = getInstances();
let updated = 0;
for (const inst of instances) {
const newSemaphore = hostSet.has(inst.name) ? 1 : 0;
if (newSemaphore !== inst.semaphore) {
const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst;
updateInstance(inst.vmid, { ...instData, semaphore: newSemaphore });
updated++;
}
}
return { summary: `${updated} updated of ${instances.length}` };
}
// ── Registry ──────────────────────────────────────────────────────────────────
const HANDLERS = {
tailscale_sync: tailscaleSyncHandler,
patchmon_sync: patchmonSyncHandler,
semaphore_sync: semaphoreSyncHandler,
};
// ── Public API ────────────────────────────────────────────────────────────────
export async function runJob(jobId) {
const job = getJob(jobId);
if (!job) throw new Error('Job not found');
const handler = HANDLERS[job.key];
if (!handler) throw new Error(`No handler for '${job.key}'`);
const cfg = JSON.parse(job.config || '{}');
const runId = createJobRun(jobId);
try {
const result = await handler(cfg);
completeJobRun(runId, 'success', result.summary ?? '');
return result;
} catch (e) {
completeJobRun(runId, 'error', e.message);
throw e;
}
}
const _intervals = new Map();
export async function runJobsOnCreate() {
for (const job of getJobs()) {
const cfg = JSON.parse(job.config || '{}');
if (cfg.run_on_create) {
try { await runJob(job.id); } catch (e) { console.error(`runJobsOnCreate job ${job.id}:`, e); }
}
}
}
export function restartJobs() {
for (const iv of _intervals.values()) clearInterval(iv);
_intervals.clear();
for (const job of getJobs()) {
if (!job.enabled) continue;
const ms = Math.max(1, job.schedule || 15) * 60_000;
const id = job.id;
_intervals.set(id, setInterval(() => runJob(id).catch(() => {}), ms));
}
}