Repository navigation
feat(backup): encrypted off-site copy over SFTP, set up in the admin panel - #180
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 8, 2026 03:22
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
No copy of the data leaves the server.
OFFSITE_BACKUP_TARGETis not set on the production server, and the onlytarget types were
rsync:(needs a full shell on the destination) anddir:(a mounted path). The owner chose anSFTP 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:
sftp:user@host:/pathtarget inapp/services/backup_offsite.py: systemsftpin batch mode, fixed argv, noshell, key login only, strict host-key checking against a known_hosts file.
the
.gpgfile leaves the server. The private key exists only on paper, so the server cannot read its own copies.offsiteblock records the sha256 of the encrypted file; the remote size is checked.<database>.<backup id>.dump.gpg; prune keeps the newestOFFSITE_REMOTE_KEEPand onlyever deletes this install's own files (also for
dir:), so two installs sharing a path cannot delete each other'scopies. Leftover
.partfiles of this install are cleaned too.rsync:is documented as not pruned.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.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 andkey are stored with
secure_store(enc:tokens) and never returned to the browser, logs or audit entries; the pageshows 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 reachesssh 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
(
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).tests/test_backup_offsite.py,tests/test_backup_offsite_admin.py, andtests/test_offsite_sftp_live.py,which runs against a throwaway SFTP-only container (
atmoz/sftp) and PostgreSQL 16: the upload is encrypted(
pg_restore --listfails on it), the private key decrypts it to the exact original, a wrong fingerprint uploadsnothing, prune keeps exactly N, a wrong host key is refused, and two installs on one path keep each other's files.
offsite-sftpruns the live test on GitHub.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.
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_admindoor. 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 anddocs; reviewer
ops-t1-rev(Opus 5.5) wrote the oracle first and reviewed two rounds (round 1: 1 medium, twoinstalls 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