Matches the master's tokenless-push change: pushBackup no longer waits for master_backup_token — every instance with a uuid backs up on its own, and the 5-minute tick force-pushes so a poisoned master copy self-heals. The token now only gates pulling a backup back (restore), so setup-linux.sh asks for it only when restoring and the Settings help text says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
/**
|
|
* Pushes this machine's full configuration — settings, broker credentials,
|
|
* instruments, manual assignments — to the master, so a dead VPS can be
|
|
* replaced by restoring the backup (lib/backup-restore.ts) and adopting this
|
|
* instance's uuid.
|
|
*
|
|
* This is the deliberate exception to "credentials never leave the machine":
|
|
* the payload goes over HTTPS to /api/instances/backup and is AES-encrypted
|
|
* at rest on the master. It never rides the control socket. Pushing needs no
|
|
* token — every instance backs up automatically once it has its uuid; only
|
|
* PULLING a backup back out (lib/backup-restore.ts) is token-gated, because
|
|
* that is the direction credentials come out in the clear.
|
|
*
|
|
* Push policy: on bootstrap, then every POLL_INTERVAL, but only when the
|
|
* content actually changed (hash comparison) — plus an immediate debounced
|
|
* push whenever settings or credentials are edited (scheduleBackupPush, called
|
|
* from the mutation routes).
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
getSetting,
|
|
setSetting,
|
|
listSettings,
|
|
listCredentials,
|
|
getInstruments,
|
|
listAllAssignments,
|
|
type FirmCredentialRow,
|
|
type AssignmentRow,
|
|
} from './db';
|
|
|
|
export interface BackupPayload {
|
|
v: 1;
|
|
savedAt: string;
|
|
settings: Record<string, string>;
|
|
credentials: FirmCredentialRow[];
|
|
instruments: { symbol: string; enabled: boolean }[];
|
|
assignments: AssignmentRow[];
|
|
}
|
|
|
|
const BACKUP_PATH = '/api/instances/backup';
|
|
const POLL_INTERVAL_MS = 5 * 60_000;
|
|
const DEBOUNCE_MS = 5_000;
|
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
|
|
const g = globalThis as typeof globalThis & {
|
|
__backupSyncTimer?: ReturnType<typeof setTimeout>;
|
|
__backupDebounceTimer?: ReturnType<typeof setTimeout>;
|
|
__backupLastHash?: string;
|
|
};
|
|
|
|
// Bookkeeping that describes this process run, not the configuration worth
|
|
// restoring. Excluded from the hash so a heartbeat doesn't look like a change
|
|
// worth pushing. (The restore side has its own skip-set; these still travel in
|
|
// the payload so a restore debugger can see them.)
|
|
const VOLATILE_KEYS = new Set([
|
|
'runner_heartbeat',
|
|
'catalog_last_sync_at',
|
|
'catalog_last_error',
|
|
'backup_last_push_at',
|
|
'backup_last_error',
|
|
]);
|
|
|
|
export function collectBackup(): BackupPayload {
|
|
return {
|
|
v: 1,
|
|
savedAt: new Date().toISOString(),
|
|
settings: listSettings(),
|
|
credentials: listCredentials(),
|
|
instruments: getInstruments().map((i) => ({ symbol: i.symbol, enabled: Boolean(i.enabled) })),
|
|
assignments: listAllAssignments(),
|
|
};
|
|
}
|
|
|
|
/** Stable digest of everything restorable, so unchanged config never pushes. */
|
|
function contentHash(payload: BackupPayload): string {
|
|
const settings = Object.entries(payload.settings)
|
|
.filter(([k]) => !VOLATILE_KEYS.has(k))
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
return createHash('sha256')
|
|
.update(JSON.stringify({
|
|
settings,
|
|
credentials: payload.credentials,
|
|
instruments: payload.instruments,
|
|
assignments: payload.assignments,
|
|
}))
|
|
.digest('hex');
|
|
}
|
|
|
|
function preconditions(): { masterUrl: string; id: string } | null {
|
|
const masterUrl = getSetting('master_dashboard_url');
|
|
// No uuid yet = the master has never said welcome; there is nothing stable
|
|
// to key the backup by, so wait for the first hello to complete. No token
|
|
// needed: pushes are open on the master (only PULLING a backup is
|
|
// token-gated), so every instance backs up automatically.
|
|
const id = getSetting('instance_id');
|
|
if (!masterUrl || !id) return null;
|
|
return { masterUrl, id };
|
|
}
|
|
|
|
export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> {
|
|
const pre = preconditions();
|
|
if (!pre) return { ok: false, skipped: true, error: 'master url or instance uuid not set' };
|
|
|
|
const payload = collectBackup();
|
|
const hash = contentHash(payload);
|
|
if (!force && hash === g.__backupLastHash) return { ok: true, skipped: true };
|
|
|
|
let url: URL;
|
|
try {
|
|
url = new URL(BACKUP_PATH, pre.masterUrl);
|
|
} catch {
|
|
return { ok: false, error: `master_dashboard_url is not a URL: ${pre.masterUrl}` };
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: pre.id,
|
|
name: getSetting('instance_name') ?? '',
|
|
payload,
|
|
}),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.json().then((b) => b?.error).catch(() => '');
|
|
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
g.__backupLastHash = hash;
|
|
setSetting('backup_last_push_at', new Date().toISOString());
|
|
setSetting('backup_last_error', '');
|
|
console.log('[backup] pushed to master');
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error('[backup] push failed:', error);
|
|
setSetting('backup_last_error', error);
|
|
return { ok: false, error };
|
|
}
|
|
}
|
|
|
|
/** Debounced push for the mutation routes: a settings PATCH or credential PUT
|
|
* lands on the master seconds later instead of at the next poll. */
|
|
export function scheduleBackupPush(): void {
|
|
if (g.__backupDebounceTimer) clearTimeout(g.__backupDebounceTimer);
|
|
g.__backupDebounceTimer = setTimeout(() => { void pushBackup(); }, DEBOUNCE_MS);
|
|
}
|
|
|
|
export function startBackupSync(): void {
|
|
stopBackupSync();
|
|
const tick = async () => {
|
|
// Forced: pushes need no token, so anyone who reaches the master and
|
|
// knows this uuid could overwrite our backup there. Re-asserting the
|
|
// real state every tick bounds that poisoning window to 5 minutes.
|
|
await pushBackup(true);
|
|
// Jitter so a fleet restarting together doesn't push in lockstep.
|
|
g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000));
|
|
};
|
|
void tick();
|
|
}
|
|
|
|
export function stopBackupSync(): void {
|
|
if (g.__backupSyncTimer) {
|
|
clearTimeout(g.__backupSyncTimer);
|
|
g.__backupSyncTimer = undefined;
|
|
}
|
|
}
|