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:
parent
d9ad9e9b29
commit
c89d32d92e
3 changed files with 104 additions and 5 deletions
20
AGENTS.md
20
AGENTS.md
|
|
@ -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
78
scripts/drill-relay.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue