Files
autofirmer-expanded/lib/backup-sync.ts
T
Brandon LiandClaude Fable 5 8154f25bb9 Backups to master, replacement-VPS restore, broker proxy, multi-instance
Four features:

- Backup: lib/backup-sync.ts pushes the full settings table and firm
  credentials (the deliberate exception to "credentials never leave the
  machine") to the master's bearer-gated /api/instances/backup — every
  5 minutes when the content hash changed, within seconds of a settings
  or credential edit, and on the new backup-now command. Enabled by the
  master_backup_token setting.

- Restore: a dead VPS's replacement pulls the backup and adopts its
  uuid/name, so roster, payouts and card links carry on. Pre-boot via
  seed-settings.js --restore-from (setup-linux.sh prompts for it) or
  the Settings page's restore picker (lib/backup-restore.ts). Stop the
  old machine first; the master 409s a live-name rename.

- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
  REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
  lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
  The master link, reporter, catalog and backup push stay direct, so a
  dead proxy cannot take down the control channel. Restart to apply.

- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
  runs N instances; scripts/instances.json (per-device, gitignored)
  drives start-all/update-check, and add-instance.sh / remove-instance.sh
  manage the extra PM2 processes. Absent registry = single-instance
  behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
  extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 13:55:35 -05:00

167 lines
5.9 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 the bearer-gated /api/instances/backup and is
* AES-encrypted at rest on the master. It never rides the unauthenticated
* control socket.
*
* 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; token: string } | null {
const masterUrl = getSetting('master_dashboard_url');
const token = getSetting('master_backup_token');
// 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.
const id = getSetting('instance_id');
if (!masterUrl || !token || !id) return null;
return { masterUrl, id, token };
}
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, backup token 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',
authorization: `Bearer ${pre.token}`,
},
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 () => {
await pushBackup();
// 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;
}
}