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,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { applyBackup, fetchBackup, fetchBackupList } from '@/lib/backup-restore';
|
||||
|
||||
/**
|
||||
* Restore-from-master for the settings page.
|
||||
*
|
||||
* The browser talks to this route and this route talks to the master with the
|
||||
* machine's own stored master_dashboard_url + master_backup_token, so the
|
||||
* token never reaches the browser.
|
||||
*
|
||||
* GET — the available backups, for the picker.
|
||||
* POST { id } — pull that backup and apply it, adopting its identity.
|
||||
* The process must be restarted afterwards; the response says so.
|
||||
*/
|
||||
|
||||
function config(): { masterUrl: string; token: string } | NextResponse {
|
||||
const masterUrl = getSetting('master_dashboard_url');
|
||||
const token = getSetting('master_backup_token');
|
||||
if (!masterUrl) return NextResponse.json({ error: 'master_dashboard_url is not set' }, { status: 400 });
|
||||
if (!token) return NextResponse.json({ error: 'master_backup_token is not set' }, { status: 400 });
|
||||
return { masterUrl, token };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const cfg = config();
|
||||
if (cfg instanceof NextResponse) return cfg;
|
||||
|
||||
try {
|
||||
const backups = await fetchBackupList(cfg.masterUrl, cfg.token);
|
||||
return NextResponse.json({ backups });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const cfg = config();
|
||||
if (cfg instanceof NextResponse) return cfg;
|
||||
|
||||
let id = '';
|
||||
try {
|
||||
const body = await req.json();
|
||||
id = typeof body?.id === 'string' ? body.id.trim() : '';
|
||||
} catch { /* falls through to the check below */ }
|
||||
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const payload = await fetchBackup(cfg.masterUrl, cfg.token, id);
|
||||
const restored = applyBackup(payload);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
restored,
|
||||
savedAt: payload.savedAt,
|
||||
adopted: {
|
||||
name: payload.settings.instance_name ?? '',
|
||||
id: payload.settings.instance_id ?? '',
|
||||
},
|
||||
// Live platform clients, the master link's hello and the proxy all
|
||||
// predate the restore — a restart is the only honest way in.
|
||||
restartRequired: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { setCredentials, clearCredentials } from '@/lib/db';
|
||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** The platform login for one firm. This is the only firm data this machine
|
||||
* owns — everything else comes from the master catalog, and these credentials
|
||||
* never go the other way.
|
||||
* owns — everything else comes from the master catalog. The one path these
|
||||
* credentials travel is the encrypted, bearer-gated backup to the master
|
||||
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||
* control socket.
|
||||
*
|
||||
* Tradovate firms need username + password. TopstepX firms need the API key;
|
||||
* username is optional for now (the loginKey call still wants it, so the
|
||||
@@ -36,6 +39,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
setCredentials(id, firm.name, username, password, apiKey);
|
||||
// Picks up the new login immediately rather than at the next restart.
|
||||
reconcileClients();
|
||||
scheduleBackupPush();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[PUT /api/firms/:id/credentials]', err);
|
||||
@@ -47,5 +51,6 @@ export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const cleared = clearCredentials(id);
|
||||
removeClient(id);
|
||||
scheduleBackupPush();
|
||||
return NextResponse.json({ ok: cleared });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -12,6 +13,8 @@ export async function GET() {
|
||||
// Read-only on purpose (not in VALID_KEYS): the master issues this uuid,
|
||||
// and editing it by hand would detach the instance from its person.
|
||||
result.instance_id = getSetting('instance_id');
|
||||
result.backup_last_push_at = getSetting('backup_last_push_at');
|
||||
result.backup_last_error = getSetting('backup_last_error');
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
@@ -27,6 +30,10 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// The edit should reach the master's backup within seconds, not at the
|
||||
// next poll.
|
||||
scheduleBackupPush();
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message ?? 'Failed to save settings' }, { status: 500 });
|
||||
|
||||
+170
-1
@@ -19,6 +19,17 @@ interface AppSettings {
|
||||
instance_name: string | null;
|
||||
instance_id: string | null;
|
||||
trading_hours: string | null;
|
||||
master_backup_token: string | null;
|
||||
proxy_url: string | null;
|
||||
backup_last_push_at: string | null;
|
||||
backup_last_error: string | null;
|
||||
}
|
||||
|
||||
interface BackupMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -38,6 +49,12 @@ export default function SettingsPage() {
|
||||
const [instanceId, setInstanceId] = useState<string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [backupToken, setBackupToken] = useState<string>('');
|
||||
const [proxyUrl, setProxyUrl] = useState<string>('');
|
||||
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
|
||||
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
|
||||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||||
const [restoreMsg, setRestoreMsg] = useState<string>('');
|
||||
|
||||
const fetchCatalog = () => {
|
||||
fetch('/api/firm-catalog')
|
||||
@@ -80,6 +97,13 @@ export default function SettingsPage() {
|
||||
if (s.instance_id != null) {
|
||||
setInstanceId(s.instance_id);
|
||||
}
|
||||
if (s.master_backup_token != null) {
|
||||
setBackupToken(s.master_backup_token);
|
||||
}
|
||||
if (s.proxy_url != null) {
|
||||
setProxyUrl(s.proxy_url);
|
||||
}
|
||||
setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null });
|
||||
});
|
||||
|
||||
// Load cached contracts; if cache is empty, auto-resolve
|
||||
@@ -134,13 +158,61 @@ export default function SettingsPage() {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName }),
|
||||
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }),
|
||||
});
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
async function loadRestoreList() {
|
||||
setRestoreBusy(true);
|
||||
setRestoreMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/backup/restore');
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||||
setRestoreList(body.backups ?? []);
|
||||
} catch (err) {
|
||||
setRestoreMsg(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setRestoreBusy(false);
|
||||
}
|
||||
|
||||
async function restoreBackup(b: BackupMeta) {
|
||||
const warning =
|
||||
`Restore "${b.name}" (saved ${new Date(b.savedAt).toLocaleString()})?\n\n` +
|
||||
'This OVERWRITES this machine\'s settings and firm credentials and adopts ' +
|
||||
'that instance\'s identity on the master.\n\n' +
|
||||
'Make sure the old machine is STOPPED first — two live instances with one ' +
|
||||
'identity will fight over the connection.';
|
||||
if (!window.confirm(warning)) return;
|
||||
setRestoreBusy(true);
|
||||
setRestoreMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/backup/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: b.id }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||||
setRestoreMsg(
|
||||
`Restored ${body.restored.settings} settings and ${body.restored.credentials} credential(s) as ` +
|
||||
`"${body.adopted.name}". Restart the autotrader now (pm2 restart) to finish.`,
|
||||
);
|
||||
setRestoreList(null);
|
||||
} catch (err) {
|
||||
setRestoreMsg(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setRestoreBusy(false);
|
||||
}
|
||||
|
||||
/** >24h old backups get flagged — a dead VPS's last push may be stale. */
|
||||
function isStale(savedAt: string): boolean {
|
||||
return Date.now() - new Date(savedAt).getTime() > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -271,6 +343,103 @@ export default function SettingsPage() {
|
||||
{syncing ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">Backup token</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Enables encrypted settings + credentials backups to the master
|
||||
(its INSTANCE_BACKUP_TOKEN). Blank = backups off.
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{backupStatus.at
|
||||
? `Last backed up ${new Date(backupStatus.at).toLocaleString()}`
|
||||
: 'Never backed up'}
|
||||
</p>
|
||||
{backupStatus.error && (
|
||||
<p className="text-xs text-red-600 mt-0.5">{backupStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="shared token"
|
||||
value={backupToken}
|
||||
onChange={(e) => setBackupToken(e.target.value)}
|
||||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">Restore from master</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Replace a dead VPS: pull its backup, overwrite this machine
|
||||
and adopt its identity. Stop the old machine first.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadRestoreList}
|
||||
disabled={restoreBusy}
|
||||
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{restoreBusy ? 'Working…' : restoreList ? 'Refresh list' : 'List backups'}
|
||||
</button>
|
||||
</div>
|
||||
{restoreMsg && (
|
||||
<p className="text-xs mt-2 text-slate-600">{restoreMsg}</p>
|
||||
)}
|
||||
{restoreList && (
|
||||
<div className="mt-3 border border-slate-200 rounded-lg divide-y divide-slate-100">
|
||||
{restoreList.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-slate-400 italic">No backups on the master yet</p>
|
||||
) : restoreList.map((b) => (
|
||||
<div key={b.id} className="flex items-center justify-between px-3 py-2">
|
||||
<div>
|
||||
<span className="text-sm font-mono text-slate-800">{b.name}</span>
|
||||
<span className={`text-xs ml-2 ${isStale(b.savedAt) ? 'text-amber-600 font-semibold' : 'text-slate-400'}`}>
|
||||
saved {new Date(b.savedAt).toLocaleString()}
|
||||
{isStale(b.savedAt) ? ' — over a day old' : ''}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300 ml-2 font-mono">{b.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => restoreBackup(b)}
|
||||
disabled={restoreBusy}
|
||||
className="text-sm px-3 py-1 rounded-lg font-medium bg-red-50 hover:bg-red-100 text-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Network ── */}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Network
|
||||
</h2>
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">Broker proxy</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Routes broker traffic (TopstepX, Tradovate, quotes) through a proxy —
|
||||
http://, https:// or socks5:// with optional user:pass. Use socks5h://
|
||||
to resolve DNS on the proxy. The master connection stays direct.
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
Takes effect after a restart (pm2 restart).
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. socks5://user:pass@1.2.3.4:1080"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
|
||||
Reference in New Issue
Block a user