Skip to content

feat(backup): encrypted off-site copy over SFTP, set up in the admin panel - #180

Merged
sinashamsizadeh merged 27 commits into
mainfrom
feat/offsite-sftp-dumps
Oct 8, 2026
Merged

sinashamsizadeh merged 27 commits into
mainfrom
feat/offsite-sftp-dumps

Conversation

@sinashamsizadeh

@sinashamsizadeh sinashamsizadeh commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

No copy of the data leaves the server. OFFSITE_BACKUP_TARGET is not set on the production server, and the only
target types were rsync: (needs a full shell on the destination) and dir: (a mounted path). The owner chose an
SFTP account as the destination (Google Drive was dropped: Google restricts business services in Iran). Off-site
destinations were also never pruned. Today, losing the server loses everything. Part of Daneshbonyan re-evaluation
track 7 (evaluator reason 4: database maturity).

Design doc

docs/features/db-maturity/RESEARCH.md section 6 item 10 and work-breakdown row H, ADR-028 in
docs/engineering/DECISIONS.md. This PR is the DUMP half of row H. The WAL half (pgBackRest repo2 over SFTP) comes
with the pgBackRest unit.

What it does:

  • New sftp:user@host:/path target in app/services/backup_offsite.py: system sftp in batch mode, fixed argv, no
    shell, key login only, strict host-key checking against a known_hosts file.
  • The dump is encrypted to a gpg PUBLIC key before upload (fingerprint checked; a private key file is refused). Only
    the .gpg file leaves the server. The private key exists only on paper, so the server cannot read its own copies.
  • The manifest offsite block records the sha256 of the encrypted file; the remote size is checked.
  • Remote files are named <database>.<backup id>.dump.gpg; prune keeps the newest OFFSITE_REMOTE_KEEP and only
    ever deletes this install's own files (also for dir:), so two installs sharing a path cannot delete each other's
    copies. Leftover .part files of this install are cleaned too. rsync: is documented as not pruned.
  • Failure stays non-fatal, as before.
  • Admin page: "هیچ نسخه‌ای بیرون از سرور نیست" when no target is set; otherwise the last off-site result.
  • Five settings (OFFSITE_SFTP_IDENTITY_FILE, OFFSITE_SFTP_KNOWN_HOSTS, OFFSITE_GPG_PUBLIC_KEY,
    OFFSITE_GPG_FINGERPRINT, OFFSITE_REMOTE_KEEP), all empty by default: nothing changes on an install that sets nothing.
  • Runbook (Persian): make the gpg key pair off the server, print the private key to paper, set up the SFTP account
    and host key, one path per install, and restore after server loss. It says plainly that no destination exists yet.

Admin-panel destination (H2, product owner decision 2026-10-08)

The operator now sets the SFTP destination in the admin panel (Infrastructure > Backups), not only by env:
host, port, username, remote path, a password OR a private key, and the server's host-key fingerprint (pinned,
never auto-accepted). Settings table -> env -> default, the same pattern as app/services/sms.py. The password and
key are stored with secure_store (enc: tokens) and never returned to the browser, logs or audit entries; the page
shows only "saved", with replace or clear. A "Test connection" button checks the pinned host key, writes and removes a
small test file, and answers in plain Persian (one reason per failure, including a short refusal after wrong tries).
A destination counts as ready only when encryption is ready too, so the page never says "ready" while copies would
fail. Admin-only (verify_admin), CSRF prefix, per-admin rate limit on the test, audit-logged. The password reaches
ssh through SSH_ASKPASS, the key through a 0600 temp file that is always removed: nothing secret on argv.
The off-site copy now also writes its manifest key under the same lock as verify() and the restore drill.
This branch also merges main after #179, so the drill section and the SFTP card share the Backups page.

Tests

  • Oracle written by the reviewing agent from the task contract before it saw the code
    (test_offsite_sftp_oracle.py, not committed): on the base 6 failed + 32 errors / 4 passed; on this branch 42/42.
    It caught 3 deliberate mutations (a broad prune pattern, StrictHostKeyChecking=no, a missing fingerprint check).
  • Committed: tests/test_backup_offsite.py, tests/test_backup_offsite_admin.py, and tests/test_offsite_sftp_live.py,
    which runs against a throwaway SFTP-only container (atmoz/sftp) and PostgreSQL 16: the upload is encrypted
    (pg_restore --list fails on it), the private key decrypts it to the exact original, a wrong fingerprint uploads
    nothing, prune keeps exactly N, a wrong host key is refused, and two installs on one path keep each other's files.
  • New CI job offsite-sftp runs the live test on GitHub.
  • H2 oracle (written first by the reviewing agent, 110 cases incl. live password/key/host-key-mismatch): on the H1
    base 104 failed / 6 passed; on H2 110/110. Live tests against a real SFTP container (password, key, pinned and
    wrong host key, a real sshd temporary refusal): 19 passed. Real browser on the app with PostgreSQL: both page
    sections render, no console errors.
  • Not run: anything against a real SFTP destination (none exists yet).

Security

No plain dump ever leaves the server. sftp and gpg run with a fixed argv and no shell; the sftp batch text only
holds safe characters; host keys are checked strictly; login is key-only; the server holds only the public gpg key.
The database name used in remote file names is validated (unsafe names fail the copy). The admin line is behind the
existing verify_admin door. Throwaway keys in tests are generated in a temp dir and never committed.

AI assistance

Written by Claude agents in a Herdr team: implementer ops-t1-impl (Opus 5.5) wrote the code, tests, CI job and
docs; reviewer ops-t1-rev (Opus 5.5) wrote the oracle first and reviewed two rounds (round 1: 1 medium, two
installs on one path could delete each other's copies, fixed); final reviewer drill-t1-final (Opus 5.5, xhigh)
reviewed H1, then the whole PR after H2 and the merge (accepted; its lows fixed, including moving the off-site
manifest write under the lock); H2 implementer and reviewer ran at xhigh effort (auth and secrets); lead foreman7 (Opus 5.5)
re-ran the oracle on the base and on each patch. Log entry in docs/engineering/AI_ASSISTANCE_LOG.md.

Human review

sinashamsizadeh and others added 24 commits October 2, 2026 16:29
Five new settings, all empty by default, so an install that sets nothing
behaves exactly as before: OFFSITE_SFTP_IDENTITY_FILE,
OFFSITE_SFTP_KNOWN_HOSTS, OFFSITE_GPG_PUBLIC_KEY, OFFSITE_GPG_FINGERPRINT
and OFFSITE_REMOTE_KEEP. The code that reads them follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OFFSITE_BACKUP_TARGET=sftp:user@host[:port]:/path encrypts the dump to a
gpg PUBLIC key (fingerprint checked, a private key file refused, no keyring
import, a throwaway --homedir per copy), uploads only the .gpg file with
`sftp -b -` (fixed argv, StrictHostKeyChecking=yes, key login only), checks
the remote size, records the encrypted sha256, and keeps the newest
OFFSITE_REMOTE_KEEP copies. dir: targets get the same prune rule. Only names
this module writes are ever deleted. Failures stay non-fatal.

last_result_view() gives the admin page the newest manifest's off-site
state without the target string or the error text.

Unit tests use real gpg with throwaway keys and a fake sftp that runs the
batch against a temp directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tests/test_offsite_sftp_live.py starts atmoz/sftp:alpine and postgres:16 with
docker run and proves: the upload is encrypted (pg_restore --list fails on
it, the phone number in the data is absent, the plain dump lists), the
paper key restores the exact dump, a wrong fingerprint uploads nothing,
prune keeps exactly N and leaves a foreign file, and a wrong host key is
refused. It skips without Docker unless OFFSITE_SFTP_LIVE=1.

New CI job offsite-sftp runs it on ubuntu-24.04 with OFFSITE_SFTP_LIVE=1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With no target set, the page now says in one red line that no copy exists
outside the server. With a target, it shows the newest backup's off-site
result: time, and copied or failed. The list endpoint carries only a state,
a time and the backup id; the target string and the error text stay on the
server. The change to the three shared files is a few lines, because unit
B2 edits them too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r loss

DEPLOYMENT_RUNBOOK.md (Persian): today no destination exists, so losing the
server loses everything. Then: making the gpg key pair off the server, the
paper key and how to test it, the SFTP-only account, checking every host
key, the settings, and restore after losing the server (download, decrypt
with the paper key, pg_restore with the panel's flags). deploy/README.md
lists the settings. One AI assistance log entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ssh reads UserKnownHostsFile as a whitespace-separated list of files, so a
path with a space would quietly point it at two other paths. The copy now
fails with a clear message and nothing is uploaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This server runs two installs. If both pointed at one destination path,
prune matched any pg_<id> name, so each install would delete the other's
copies. sftp: and dir: copies are now named <database>.<backup id>.dump.gpg
(or .dump), the database taken from DATABASE_URL the same way pg_backup does,
and prune only matches this install's own prefix. Names without a prefix
(written before this change) are never deleted. A database name that is not
safe in a file name fails the copy. rsync: names are unchanged (no prune).

Tests: two installs sharing one dir: path and one sftp: path (fake and a
real SFTP server) each prune only their own copies; legacy names survive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The runbook, .env.example and deploy/README.md now give the new copy name
(padyar_<slug>.pg_<id>.dump.gpg), say prune only deletes this install's own
copies and never the older unprefixed ones, and ask for one destination
path per install (/upload/<slug>) as a second guard, with the one-time mkdir.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion

A cut or timed-out upload leaves <db>.<id>.dump.gpg.part behind and nothing
removed it. After the next verified upload, the prune batch also removes
.part files with this install's own prefix. Another install's .part and
unprefixed names are never touched, and nothing is removed after a failed
upload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssword off argv

A manual "new backup" only dumps; the off-site copy runs after a successful
verify. Runbook step 5 now says to press «بررسی سلامت» on that row and then
expect "last copy succeeded" (the nightly run verifies, so it copies).

The restore command passed DATABASE_URL, password included, on pg_restore's
command line, which `ps` shows to every user. It now calls the app's own
pg_backup._conn_parts() and _env(), so the password travels in PGPASSWORD
like the app's own restore, and says why. Also rewraps one docstring line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
H1 read the destination only from env vars and files on the server, so an
operator needed a shell on the server to set it. The destination now lives
in the settings table (offsite_sftp_* keys) and is served by three
admin-only endpoints under /admin/api/infra/backups/offsite-settings
(read, save, test).

- A host saved in the panel wins over OFFSITE_BACKUP_TARGET. An empty host
  removes the panel destination and the env target works as before.
- The password and the private key are stored as enc: tokens and never
  leave the server: responses carry only password_saved/private_key_saved.
- The host key is pinned: ssh-keyscan, keep only the key whose SHA256
  fingerprint is the saved pin, then StrictHostKeyChecking=yes against that
  one key. No match, or no pin, means no connection.
- No secret on argv: a key is a 0600 temp file for the run; a password goes
  through SSH_ASKPASS, with -o BatchMode=no before -b (sftp -b turns
  BatchMode on, which turns password login off).
- The connection test uses the saved settings, runs the same put, chmod,
  rename, ls and rm steps a copy takes on one small file, gives one plain
  reason per failure, is rate limited per admin and audited.
- The H1 test files pin DB_PATH to a temp file, since the copy now reads
  the settings table first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A throwaway atmoz/sftp container that allows password and key login, with
sshd's PerSourcePenalties turned off by an /etc/sftp.d hook (OpenSSH 9.8+
refuses an address once more than 15 s of penalty builds up, and the deny
cases would trip it). Through the real endpoints it proves: password login
and key login (with and without the key's final newline) pass and leave no
test file; pinning the rsa key works as well as the ed25519 one; a pin that
matches no key is refused with no login attempt in the server log; a wrong
password, an unknown key and an unwritable path each get their own reason;
the nightly copy lands encrypted at the panel destination and decrypts to
the exact original; no response or log row holds the password or the key.

The offsite-sftp CI job runs the new file with OFFSITE_SFTP_LIVE=1, so a
missing docker fails it instead of skipping (SPEC-H2 Amendment 3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One card at the bottom of the page, linked from the "no copy off the
server" line: host, port, user, folder, a two-button switch for password or
private key, the server's fingerprint, one help line per field, one save
button and a "test connection" button.

- The secret is write-only: the page shows "saved" and the secret inputs
  are empty after every load and save. Only the chosen method's secret is
  sent.
- "Test connection" tests the SAVED settings, so it waits while the form
  has unsaved changes and says why. The result is one plain sentence.
- A refused save shows the server's sentence and keeps what was typed.
- A separate JS module (infra_backups_offsite.js), so the H1 page script
  only gains one export (reloadBackups) for the status line.

Browser tests (async Playwright) cover these states and a 375 px phone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… fallback

- Runbook: two ways to set the destination, the panel first. The gpg pair
  stays in .env either way. The panel needs one host-key fingerprint of any
  type; env still needs a known_hosts file with every key. The connection
  test, the rate limit, how to remove the panel destination, and what
  PerSourcePenalties looks like after a few wrong passwords.
- Keep the destination details (host, user, password or key, fingerprint)
  off the server, next to the paper key: the panel stores them in the
  database that is lost with the server, and a restore needs them.
- deploy/README.md, .env.example and the app/config.py comment say a panel
  destination wins over OFFSITE_BACKUP_TARGET.
- AI log entry (human review: pending).
- One em dash in a re-wrapped backup_offsite.py docstring line, replaced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OpenSSH looks a port-22 host up without brackets, so a `[host]:22` line
would turn every connection into a host-key error. The router docstring now
says it accepts no LOCAL filesystem path; the off-site form takes a folder
on the remote SFTP server, limited to letters, digits and . _ - /.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A mutation that dropped the "path must start with /" check still got a 400
(the path pattern refuses it too), so only the vaguer sentence changed.
Pin the exact sentence per field, so the operator is always told which
field is wrong and how.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After two wrong-password tests, the right password got "server
unreachable, check the address and port". OpenSSH 9.8+ (PerSourcePenalties)
refuses an address once more than 15 s of penalty has built up (5 s per
failed login, 1 s per connection that never logs in): it accepts the TCP
connection and closes it before its banner. ssh-keyscan then finds no key,
and the plain connect in _why_no_keys saw the close as "unreachable".

Now an accepted connection closed (or reset) with no banner, and an sftp
run that ends with kex_exchange_identification, give their own plain
sentence: wait a minute and try again, and ask the provider if it stays.

Review round 1, item 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A test that timed out after the put, or lost the connection mid-batch
(exit 255), left .padyar-connection-test-<hex> (or its .part) on the
destination: only a refused chmod, rename, ls or rm got a cleanup.

Now a best-effort `-rm` of both names runs on a new connection whenever
the batch may have written: after a timeout once the batch started, and
after any non-zero exit except the endings that happen before login
(wrong password or key, wrong host key, refused, connect error). A failed
put gets one too, since a cut put can leave a short .part. The batch runs
with the hard budget minus CLEANUP_TIMEOUT, so the cleanup still fits
inside TEST_TIMEOUT. The cleanup's own result is never the answer: the
operator reads the real reason.

Review round 1, item 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The connection test passed and the page said "configured" while every
nightly copy failed for a missing OFFSITE_GPG_* pair: the test sends no
dump, so it never touched encryption.

- backup_offsite._check_key() is the key check _encrypt already ran (one
  readable PUBLIC key, exactly one, the named fingerprint), moved into its
  own function. encryption_problem() runs it with the env pair and a short
  timeout, and returns "" or an English reason that names settings only.
- last_result_view(): an sftp: target whose key check fails is
  {"configured": false, "state": "not_ready"}, never "configured".
  rsync: and dir: targets are not affected.
- try_connection(): a working connection without encryption is ok=false
  with its own sentence; a failed connection without encryption names
  both problems. The English reason goes to the audit row only.

Review round 1, item 3 (server side; the page follows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The status line has a fourth state: a destination is set, but copies
  cannot be encrypted on this server, so no copy is off the server. Red,
  in plain words.
- The destination card shows its own red warning in that state (told by
  the backups list through initBackups({onOffsiteState})), and asks for
  the encryption key to be installed.
- The card's own line no longer says "configured" in green: it says the
  destination is saved, in neutral colour. Readiness is the status line's
  job, because only it knows about encryption.

Review round 1, item 3 (page side).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The live test against a penalised sshd (10.3) showed fix 2 matched my
fake, not the server: sshd writes "Not allowed at this time" instead of
its banner and closes, and the sftp client prints only "Connection closed
by <host> port <port>" (no kex_exchange_identification line). Both were
still read as "unreachable".

- _why_no_keys: a first line of "Not allowed at this time" or "Exceeded
  MaxStartups", or a close with no line, is a temporary refusal.
- _classify: exit 255 with "Connection closed by", no batch echo and no
  "Permission denied" is the same refusal at the sftp step (it happens when
  the scan's own connections push the penalty over the limit).
- That ending is also "before login", so it gets no cleanup try. Checked
  live first: sftp echoes each batch command line by line, so no echo
  means no command ran.
- Controls: a server that greets with another protocol is still "check
  the address and port" (unreachable, or timeout when it holds the
  connection, since ssh-keyscan then waits past its own -T).

Review round 1, item 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A batch cut by a timeout after a REAL put and chmod leaves no file: the
  cleanup runs for real on a new password login.
- A working connection without the gpg key is not ok, and the backups list
  says not_ready.
- A second container with PerSourcePenalties ON (authfail 30 s, so one
  wrong login passes the 15 s threshold every run): after a wrong
  password, the right one gets the "wait and try again" sentence, and the
  sshd log shows the penalty.
- The client fixture now installs the throwaway gpg key, since a test is
  ok only when copies can also be encrypted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fusal

The runbook said that after a few wrong passwords the test reports "could
not connect"; it now has its own sentence, and the runbook says what sshd
sends then ("Not allowed at this time"). It also says the test checks the
gpg key on the server, and that the status line says "not ready" instead
of "configured" without it. One line in the H2 AI log entry for the review
round (human review still pending).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sinashamsizadeh
sinashamsizadeh marked this pull request as ready for review October 8, 2026 03:22
sinashamsizadeh and others added 3 commits October 8, 2026 10:02
Brings in #179 (B2, nightly restore drill). Both PRs added a section to
Infrastructure > Backups; both are kept, in one layout.

- app/routers/backups.py: both import sets, both constant groups, and
  both list fields: `latest_drill` (B2) and `offsite` (H1). `manifests`
  stays defined on SQLite too, since the off-site view reads it. Every
  route of both sides is kept: the drill route and the three
  offsite-settings routes.
- templates/admin/infra_backups.html: the status card reads in pipeline
  order: schedule, the drill box (can this backup be restored?), then the
  off-site line in a box of the same style (is a copy off the server?),
  with its link to the SFTP settings card at the bottom of the page.
- static/admin/js/infra_backups.js: load() renders the off-site line, the
  rows with the drill column, and the drill status. The off-site line's
  bottom margin goes from mb-3 to mb-1 so its link sits inside the box.

AI_ASSISTANCE_LOG.md and DEPLOYMENT_RUNBOOK.md merged without conflict;
every added line of both sides is present (checked line by line).
backup_offsite._record() still writes the manifest outside
pg_backup._update_manifest; left as is, a separate follow-up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
backup_offsite._record() wrote the `offsite` key with its own read and
replace and no lock, while verify() and the restore drill write through
pg_backup._update_manifest (exclusive lock, fresh read, own keys only).
An off-site write landing while one of them was between its read and its
write was erased by that writer's stale copy, and the reverse could happen
too.

_record() now calls pg_backup._update_manifest(backup_id, {"offsite": ...}).
It still updates the caller's dict, still skips a backup deleted meanwhile
(no lock file is created in a folder without a manifest), and still never
raises.

Test first: the other writer (drill, then verify) is paused after its read
under the lock while the off-site result is recorded. Before the fix the
`offsite` key was lost (KeyError); now both keys survive.

pg_backup._update_manifest's docstring now says every writer goes through
it (SPEC-H2 Amendment 5: that sentence only). The unused json import in
backup_offsite.py goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The H1 entry said the live test had 4 tests; tests/test_offsite_sftp_live.py
has 5. One line in the H2 entry for moving _record() under the manifest
lock (human review still pending).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sinashamsizadeh sinashamsizadeh changed the title feat(backup): encrypted off-site copy of the dumps over SFTP feat(backup): encrypted off-site copy over SFTP, set up in the admin panel Oct 8, 2026
@sinashamsizadeh
sinashamsizadeh merged commit a38acb6 into main Oct 8, 2026
10 checks passed
@sinashamsizadeh
sinashamsizadeh deleted the feat/offsite-sftp-dumps branch October 8, 2026 07:19
sinashamsizadeh added a commit that referenced this pull request Oct 8, 2026
Brings in main up to a38acb6 (#180, off-site SFTP copies and their
admin panel). Conflicts, all additive, both sides kept:
- .env.example and app/config.py: #180's OFFSITE_SFTP_* / OFFSITE_GPG_* /
  OFFSITE_REMOTE_KEEP settings first (they continue the off-site block),
  then BACKUP_KEEP_OTHER.
- static/admin/js/infra_backups.js: load() now calls both
  renderSchedule(data.schedule, data.keep_other) and
  renderOffsite(data.offsite).
The router merged on its own: the list response carries both keep_other
and offsite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant