Backups push automatically; the token is only for restore

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>
This commit is contained in:
Brandon Li
2026-09-22 14:08:03 -05:00
co-authored by Claude Fable 5
parent 8154f25bb9
commit ba8bd50da0
4 changed files with 27 additions and 22 deletions
+2 -2
View File
@@ -347,8 +347,8 @@ export default function SettingsPage() {
<div> <div>
<p className="text-sm font-medium text-slate-800">Backup token</p> <p className="text-sm font-medium text-slate-800">Backup token</p>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Enables encrypted settings + credentials backups to the master Backups push automatically — this token (the master&apos;s
(its INSTANCE_BACKUP_TOKEN). Blank = backups off. INSTANCE_BACKUP_TOKEN) is only needed to restore one.
</p> </p>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
{backupStatus.at {backupStatus.at
+17 -14
View File
@@ -5,9 +5,11 @@
* instance's uuid. * instance's uuid.
* *
* This is the deliberate exception to "credentials never leave the machine": * 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 * the payload goes over HTTPS to /api/instances/backup and is AES-encrypted
* AES-encrypted at rest on the master. It never rides the unauthenticated * at rest on the master. It never rides the control socket. Pushing needs no
* control socket. * 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 * Push policy: on bootstrap, then every POLL_INTERVAL, but only when the
* content actually changed (hash comparison) — plus an immediate debounced * content actually changed (hash comparison) — plus an immediate debounced
@@ -85,19 +87,20 @@ function contentHash(payload: BackupPayload): string {
.digest('hex'); .digest('hex');
} }
function preconditions(): { masterUrl: string; id: string; token: string } | null { function preconditions(): { masterUrl: string; id: string } | null {
const masterUrl = getSetting('master_dashboard_url'); 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 // 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. // 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'); const id = getSetting('instance_id');
if (!masterUrl || !token || !id) return null; if (!masterUrl || !id) return null;
return { masterUrl, id, token }; return { masterUrl, id };
} }
export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> { export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> {
const pre = preconditions(); const pre = preconditions();
if (!pre) return { ok: false, skipped: true, error: 'master url, backup token or instance uuid not set' }; if (!pre) return { ok: false, skipped: true, error: 'master url or instance uuid not set' };
const payload = collectBackup(); const payload = collectBackup();
const hash = contentHash(payload); const hash = contentHash(payload);
@@ -113,10 +116,7 @@ export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'content-type': 'application/json' },
'content-type': 'application/json',
authorization: `Bearer ${pre.token}`,
},
body: JSON.stringify({ body: JSON.stringify({
id: pre.id, id: pre.id,
name: getSetting('instance_name') ?? '', name: getSetting('instance_name') ?? '',
@@ -151,7 +151,10 @@ export function scheduleBackupPush(): void {
export function startBackupSync(): void { export function startBackupSync(): void {
stopBackupSync(); stopBackupSync();
const tick = async () => { const tick = async () => {
await pushBackup(); // 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. // Jitter so a fleet restarting together doesn't push in lockstep.
g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000)); g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000));
}; };
+3 -2
View File
@@ -197,8 +197,9 @@ seedSetting.run('twocaptcha_api_key', '');
// Blank falls back to the proxy hardcoded in app/api/autobuyer/captcha; `none` // Blank falls back to the proxy hardcoded in app/api/autobuyer/captcha; `none`
// solves without one. // solves without one.
seedSetting.run('twocaptcha_proxy', ''); seedSetting.run('twocaptcha_proxy', '');
// Bearer for pushing/pulling the encrypted settings+credentials backup on the // Bearer for PULLING an encrypted settings+credentials backup back from the
// master (its INSTANCE_BACKUP_TOKEN). Blank = backups off. // master (its INSTANCE_BACKUP_TOKEN) — pushing needs no token and happens
// automatically. Blank = restore unavailable from this machine's UI.
seedSetting.run('master_backup_token', ''); seedSetting.run('master_backup_token', '');
// Routes broker traffic (TopstepX, Tradovate, Yahoo quotes) through a proxy: // Routes broker traffic (TopstepX, Tradovate, Yahoo quotes) through a proxy:
// http(s):// or socks5(h)://, user:pass ok. The master link stays direct on // http(s):// or socks5(h)://, user:pass ok. The master link stays direct on
+5 -4
View File
@@ -86,10 +86,11 @@ read -r -p " Master dashboard URL [$DEFAULT_MASTER]: " MASTER_URL
MASTER_URL="${MASTER_URL:-$DEFAULT_MASTER}" MASTER_URL="${MASTER_URL:-$DEFAULT_MASTER}"
read -r -p " Instance name [$(hostname)]: " INSTANCE read -r -p " Instance name [$(hostname)]: " INSTANCE
INSTANCE="${INSTANCE:-$(hostname)}" INSTANCE="${INSTANCE:-$(hostname)}"
read -r -p " Backup token (blank = no master backups): " BACKUP_TOKEN # Backups push automatically; the token is only needed to pull one back.
RESTORE_FROM="" read -r -p " Restore a dead instance? Its uuid (blank = fresh install): " RESTORE_FROM
if [ -n "$BACKUP_TOKEN" ]; then BACKUP_TOKEN=""
read -r -p " Restore a dead instance? Its uuid (blank = fresh install): " RESTORE_FROM if [ -n "$RESTORE_FROM" ]; then
read -r -p " Backup token (the master's INSTANCE_BACKUP_TOKEN): " BACKUP_TOKEN
fi fi
echo echo
info "installing to: $TARGET" info "installing to: $TARGET"