diff --git a/AGENTS.md b/AGENTS.md index 04fab24..21834d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -688,12 +688,24 @@ Rules: - `scripts/restore-relay.mjs --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 --to ws://localhost:` 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`. diff --git a/scripts/drill-relay.mjs b/scripts/drill-relay.mjs new file mode 100644 index 0000000..2d57c8b --- /dev/null +++ b/scripts/drill-relay.mjs @@ -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 --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); + }); +} diff --git a/scripts/restic.env.example b/scripts/restic.env.example index f18b9fc..74eeb5f 100644 --- a/scripts/restic.env.example +++ b/scripts/restic.env.example @@ -5,7 +5,16 @@ # Any restic backend works. Examples: # Backblaze B2: RESTIC_REPOSITORY=b2:my-bucket:skatespots/relay # S3 / R2: RESTIC_REPOSITORY=s3:https://.r2.cloudflarestorage.com/ -# 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 +# 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