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>
167 lines
5.9 KiB
TypeScript
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;
|
|
}
|
|
}
|