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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
97947bffde
commit
8154f25bb9
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user