chore(backup): offsite on the CVM host (SFTP) + proven restore drill

- Offsite backup is now live: restic over SFTP to the CVM host via a scoped
  ~/.ssh/config alias (no host/key in tracked files); restic.env (600, outside
  the repo) holds the repo URL + generated password. Verified: first push
  dedupes to ~32 MiB and 'restic check --read-data-subset=10%' is clean.
- scripts/drill-relay.mjs: throwaway in-memory NIP-01 relay (ws) for restore
  drills. Ran the loop end-to-end against the real archive: 114/114 events
  restored, 0 missing.

Scripts/docs only — the deploy timer skips this (no Netlify credits).
This commit is contained in:
SkateSpots 2026-09-28 13:16:53 +07:00
parent d9ad9e9b29
commit c89d32d92e
3 changed files with 104 additions and 5 deletions

View file

@ -688,12 +688,24 @@ Rules:
- `scripts/restore-relay.mjs <archive.jsonl.gz> --dry-run` re-verifies every
signature; `--to wss://relay` republishes. There is **no default target** —
restoring is deliberate, and the mistake to avoid is publishing a snapshot back
to the live relay. No full restore drill has been run yet (needs a throwaway
relay target).
to the live relay. Use `scripts/drill-relay.mjs` as the throwaway target.
- Offsite: `scripts/backup-relay.sh` runs the archive, then `restic backup` +
`forget --prune` **if** `~/.config/skatespots/restic.env` exists (template:
`scripts/restic.env.example`). **Not configured yet — local copies only**, so
the archive currently survives relay loss but not disk loss.
`scripts/restic.env.example`). **Configured (2026-09-28):** the **CVM host over
SFTP**, repo `sftp:skatespots-cvm:/home/ubuntu/skatespots-backups/relay`, via an
`~/.ssh/config` alias `skatespots-cvm` (HostName/User/IdentityFile from
`~/.config/skatespots/cvm.env`) so no key/host lives in tracked files.
`~/.config/skatespots/restic.env` (600, outside the repo) holds the repo URL +
the generated `RESTIC_PASSWORD` — **that password is required to restore; keep a
copy in a password manager**. A second offsite (Cloudflare R2) can be added by
pointing the same file at an S3 repo.
- **Restore drill (proven 2026-09-28):** `node scripts/drill-relay.mjs [port]`
starts a throwaway in-memory NIP-01 relay (`ws`, no persistence); then
`node scripts/restore-relay.mjs <archive> --to ws://localhost:<port>` and query
it back. Latest run: **114/114 events restored, 0 missing**. Re-run after any
archive-format change, or quarterly.
- `restic check --read-data-subset=10%` was clean after the first offsite push
(do this monthly to catch bitrot, not just existence).
- Weekly systemd user timer: `scripts/install-archive-timer.sh` copies
`scripts/systemd/skatespots-archive.{service,timer}` into `~/.config/systemd/user`
and enables them. Logs: `journalctl --user -u skatespots-archive`.

78
scripts/drill-relay.mjs Normal file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
// Throwaway in-memory NIP-01 relay for RESTORE DRILLS.
//
// node scripts/drill-relay.mjs [port] # default 7777
//
// Proves the backup loop end-to-end without touching production or any third
// party: start this, `node scripts/restore-relay.mjs <archive> --to
// ws://localhost:7777`, then query it back and assert every archived event
// arrived. Deliberately minimal — no persistence, no auth, no relay policy.
import { WebSocketServer } from 'ws';
const PORT = Number(process.argv[2] || 7777);
const events = new Map();
function matches(event, filter) {
if (filter.ids && !filter.ids.includes(event.id)) return false;
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
if (filter.since && event.created_at < filter.since) return false;
if (filter.until && event.created_at > filter.until) return false;
for (const key of Object.keys(filter)) {
if (!key.startsWith('#')) continue;
const tag = key.slice(1);
const wanted = filter[key];
if (!Array.isArray(wanted)) continue;
if (!wanted.some((v) => event.tags.some(([n, x]) => n === tag && x === v))) return false;
}
return true;
}
const wss = new WebSocketServer({ port: PORT });
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
const [type] = msg;
if (type === 'EVENT') {
const event = msg[1];
if (event?.id) {
events.set(event.id, event);
ws.send(JSON.stringify(['OK', event.id, true, '']));
} else {
ws.send(JSON.stringify(['OK', '', false, 'invalid']));
}
return;
}
if (type === 'REQ') {
const sub = msg[1];
const filters = msg.slice(2);
const out = new Map();
for (const filter of filters) {
const matched = [...events.values()]
.filter((e) => matches(e, filter))
.sort((a, b) => b.created_at - a.created_at);
const limited = typeof filter.limit === 'number' ? matched.slice(0, filter.limit) : matched;
for (const e of limited) out.set(e.id, e);
}
for (const e of out.values()) ws.send(JSON.stringify(['EVENT', sub, e]));
ws.send(JSON.stringify(['EOSE', sub]));
}
});
});
console.log(`[drill-relay] ws://localhost:${PORT} — in-memory, throwaway`);
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`[drill-relay] stopping (${events.size} events held)`);
wss.close();
process.exit(0);
});
}

View file

@ -5,7 +5,16 @@
# Any restic backend works. Examples:
# Backblaze B2: RESTIC_REPOSITORY=b2:my-bucket:skatespots/relay
# S3 / R2: RESTIC_REPOSITORY=s3:https://<account>.r2.cloudflarestorage.com/<bucket>
# SFTP (host you already run): RESTIC_REPOSITORY=sftp:user@host:/srv/skatespots-backups
# SFTP directly: RESTIC_REPOSITORY=sftp:user@host:/srv/skatespots-backups
# SFTP via an ~/.ssh/config alias (keeps the key path out of this file):
# Host my-backup
# HostName <host>
# User <user>
# IdentityFile ~/.ssh/backup_key
# → RESTIC_REPOSITORY=sftp:my-backup:/path/to/repo
#
# Configured here: the CVM host over SFTP using the `skatespots-cvm` ssh alias
# (see AGENTS.md → Relay archive).
#
# One-time init, from this machine:
# set -a; . ~/.config/skatespots/restic.env; set +a