Send each verified backup to a destination plugin, encrypted with age (#325) #1071

Merged
tiagoagueda merged 1 commit from 325-backup-destinations into main 2026-10-28 15:33:54 +00:00
Owner

A copy of the instance on the same disk is no help the day the disk goes, and the core gave
a plugin no way to receive one: no kind was about backups, and the only instance-level
plugin settings were the transports'. This adds that kind and everything around it.

The kind: backup-destination, entry-point group postulo.backups, with
BackupDestinationPlugin (config_fields, test, send, list, delete; validate optional, as
for a connected plugin), BackupArchive, BackupManifest and BackupRef on
postulo.plugins.api. It is ungoverned, like a transport, and wired into GROUPS, LABELS and
TONES. postulo.plugins.testing publishes assert_is_a_backup_destination, which sends,
lists and deletes one archive through a destination: what retention relies on.

Settings: a trio on SiteSettings like the transports' (plugin name, JSON configuration,
secrets under the field key), drawn from the plugin's FieldSpecs on a page of its own under
Server settings -> Backups, secrets masked and kept when left blank, a log line naming who
changed the destination. A destination is refused without an age recipient.

Encryption: the core encrypts each archive with pyrage (MIT, abi3 wheels for linux amd64 and
aarch64, maintained; 1.2.0-1.2.2 had GHSA-47h8-jmp3-9f28, so the floor is 1.4) to an X25519
recipient, the only kind accepted: a plugin recipient names a program for age to run. Only
the public key is stored. The encrypted copy is a scratch file deleted after the send.

Sending: an archive verified into POSTULO_BACKUP_DIR (schedule, Back up now, or
manage.py backup unless --no-send) queues an OffsiteBackup row. The scheduler sends it
after its pass's own work, in the worker when there is one, as document copies are sent:
claim with a lease, retry with a doubling wait, six attempts, then an administrator's Send.
The upload runs inside a deadline that ends before the lease. Remote retention follows only
a confirmed send, keeps the newest N (its own number, the local one when blank, 0 for none)
and deletes only references the plugin's list() returned and Postulo itself sent. Local
retention never rotates an archive still waiting for the destination.

What staff see: Sent/Waiting/Failed (in the plugin's words) per archive, a Send button, a
failed send on Overview, and postulo_backup_offsite_last_success_timestamp_seconds and
postulo_backup_offsite_failures_total. Every new address is staff only and a 404 otherwise;
each send and remote delete is logged. THREAT-MODEL gains the destination as an attacker.

Closes #325

Notes for review

  • The maintainer's decisions (the issue's recommendations):
    • The destination is a transport-style trio on SiteSettings.
    • The core encrypts every archive to an age recipient before any destination sees it, and refuses a destination without one.
    • manage.py backup queues a send, with --no-send to skip.
  • Encryption: pyrage>=1.4,<2.
    • MIT, maintained, abi3 wheels for linux amd64 and aarch64.
    • GHSA-47h8-jmp3-9f28 affects only 1.2.1–1.2.2, below the floor.
    • Only X25519 recipients are accepted.
  • Sending:
    • Modelled on document copies: a 4-hour lease, a doubling wait from 10 minutes, and 6 attempts until staff press Send.
    • The upload runs under a deadline 15 minutes inside the lease.
    • Without a worker, at most one archive is sent per pass, after the pass's own work.
  • Decisions the issue left open:
    • Remote retention deletes only what list() returned and Postulo itself sent, so a destination's other files are never touched.
    • Local retention keeps an archive the destination has not received yet.
    • Changing the destination clears its settings, and queued rows for the old one are no longer sent.
  • No destination plugin ships with this. postulo/postulo-dav#6 is the first.
  • Migration: core 0039_backup_destination.
  • Wiki: Writing a plugin, Backups and your data and Health, metrics and logs are applied when this merges.

🤖 Generated with Claude Code

A copy of the instance on the same disk is no help the day the disk goes, and the core gave a plugin no way to receive one: no kind was about backups, and the only instance-level plugin settings were the transports'. This adds that kind and everything around it. The kind: `backup-destination`, entry-point group `postulo.backups`, with `BackupDestinationPlugin` (config_fields, test, send, list, delete; validate optional, as for a connected plugin), `BackupArchive`, `BackupManifest` and `BackupRef` on `postulo.plugins.api`. It is ungoverned, like a transport, and wired into GROUPS, LABELS and TONES. `postulo.plugins.testing` publishes `assert_is_a_backup_destination`, which sends, lists and deletes one archive through a destination: what retention relies on. Settings: a trio on SiteSettings like the transports' (plugin name, JSON configuration, secrets under the field key), drawn from the plugin's FieldSpecs on a page of its own under Server settings -> Backups, secrets masked and kept when left blank, a log line naming who changed the destination. A destination is refused without an age recipient. Encryption: the core encrypts each archive with pyrage (MIT, abi3 wheels for linux amd64 and aarch64, maintained; 1.2.0-1.2.2 had GHSA-47h8-jmp3-9f28, so the floor is 1.4) to an X25519 recipient, the only kind accepted: a plugin recipient names a program for age to run. Only the public key is stored. The encrypted copy is a scratch file deleted after the send. Sending: an archive verified into POSTULO_BACKUP_DIR (schedule, Back up now, or `manage.py backup` unless --no-send) queues an OffsiteBackup row. The scheduler sends it after its pass's own work, in the worker when there is one, as document copies are sent: claim with a lease, retry with a doubling wait, six attempts, then an administrator's Send. The upload runs inside a deadline that ends before the lease. Remote retention follows only a confirmed send, keeps the newest N (its own number, the local one when blank, 0 for none) and deletes only references the plugin's list() returned and Postulo itself sent. Local retention never rotates an archive still waiting for the destination. What staff see: Sent/Waiting/Failed (in the plugin's words) per archive, a Send button, a failed send on Overview, and postulo_backup_offsite_last_success_timestamp_seconds and postulo_backup_offsite_failures_total. Every new address is staff only and a 404 otherwise; each send and remote delete is logged. THREAT-MODEL gains the destination as an attacker. Closes #325 ## Notes for review - **The maintainer's decisions (the issue's recommendations):** - The destination is a transport-style trio on `SiteSettings`. - The core encrypts every archive to an age recipient before any destination sees it, and refuses a destination without one. - `manage.py backup` queues a send, with `--no-send` to skip. - **Encryption:** `pyrage>=1.4,<2`. - MIT, maintained, abi3 wheels for linux amd64 and aarch64. - GHSA-47h8-jmp3-9f28 affects only 1.2.1–1.2.2, below the floor. - Only X25519 recipients are accepted. - **Sending:** - Modelled on document copies: a 4-hour lease, a doubling wait from 10 minutes, and 6 attempts until staff press *Send*. - The upload runs under a deadline 15 minutes inside the lease. - Without a worker, at most one archive is sent per pass, after the pass's own work. - **Decisions the issue left open:** - Remote retention deletes only what `list()` returned **and** Postulo itself sent, so a destination's other files are never touched. - Local retention keeps an archive the destination has not received yet. - Changing the destination clears its settings, and queued rows for the old one are no longer sent. - **No destination plugin ships with this.** postulo/postulo-dav#6 is the first. - **Migration:** `core 0039_backup_destination`. - **Wiki:** *Writing a plugin*, *Backups and your data* and *Health, metrics and logs* are applied when this merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Send each verified backup to a destination plugin, encrypted with age (#325)
Some checks failed
CI / Browser tests (Chromium) (push) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (push) Has been cancelled
Every Python / Unit tests (Python 3.12) (push) Has been cancelled
Every Python / Unit tests (Python 3.13) (push) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (push) Has been cancelled
CI / Database tests on PostgreSQL (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
a63b47d31f
A copy of the instance on the same disk is no help the day the disk goes, and the core gave
a plugin no way to receive one: no kind was about backups, and the only instance-level
plugin settings were the transports'. This adds that kind and everything around it.

The kind: `backup-destination`, entry-point group `postulo.backups`, with
`BackupDestinationPlugin` (config_fields, test, send, list, delete; validate optional, as
for a connected plugin), `BackupArchive`, `BackupManifest` and `BackupRef` on
`postulo.plugins.api`. It is ungoverned, like a transport, and wired into GROUPS, LABELS and
TONES. `postulo.plugins.testing` publishes `assert_is_a_backup_destination`, which sends,
lists and deletes one archive through a destination: what retention relies on.

Settings: a trio on SiteSettings like the transports' (plugin name, JSON configuration,
secrets under the field key), drawn from the plugin's FieldSpecs on a page of its own under
Server settings -> Backups, secrets masked and kept when left blank, a log line naming who
changed the destination. A destination is refused without an age recipient.

Encryption: the core encrypts each archive with pyrage (MIT, abi3 wheels for linux amd64 and
aarch64, maintained; 1.2.0-1.2.2 had GHSA-47h8-jmp3-9f28, so the floor is 1.4) to an X25519
recipient, the only kind accepted: a plugin recipient names a program for age to run. Only
the public key is stored. The encrypted copy is a scratch file deleted after the send.

Sending: an archive verified into POSTULO_BACKUP_DIR (schedule, Back up now, or
`manage.py backup` unless --no-send) queues an OffsiteBackup row. The scheduler sends it
after its pass's own work, in the worker when there is one, as document copies are sent:
claim with a lease, retry with a doubling wait, six attempts, then an administrator's Send.
The upload runs inside a deadline that ends before the lease. Remote retention follows only
a confirmed send, keeps the newest N (its own number, the local one when blank, 0 for none)
and deletes only references the plugin's list() returned and Postulo itself sent. Local
retention never rotates an archive still waiting for the destination.

What staff see: Sent/Waiting/Failed (in the plugin's words) per archive, a Send button, a
failed send on Overview, and postulo_backup_offsite_last_success_timestamp_seconds and
postulo_backup_offsite_failures_total. Every new address is staff only and a 404 otherwise;
each send and remote delete is logged. THREAT-MODEL gains the destination as an attacker.

Closes #325

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda force-pushed 325-backup-destinations from a63b47d31f
Some checks failed
CI / Browser tests (Chromium) (push) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (push) Has been cancelled
Every Python / Unit tests (Python 3.12) (push) Has been cancelled
Every Python / Unit tests (Python 3.13) (push) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (push) Has been cancelled
CI / Database tests on PostgreSQL (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
to abc968c2e8
Some checks failed
CI / Database tests on PostgreSQL (push) Successful in 4m11s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m28s
CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m28s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m59s
CI / Security audit (push) Successful in 1m4s
Every Python / Unit tests (Python 3.13) (push) Successful in 13m44s
CI / Browser tests (Chromium) (push) Failing after 54m36s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
2026-10-27 22:40:29 +00:00
Compare
tiagoagueda force-pushed 325-backup-destinations from abc968c2e8
Some checks failed
CI / Database tests on PostgreSQL (push) Successful in 4m11s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m28s
CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m28s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m59s
CI / Security audit (push) Successful in 1m4s
Every Python / Unit tests (Python 3.13) (push) Successful in 13m44s
CI / Browser tests (Chromium) (push) Failing after 54m36s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
to a8fda0cd0b
Some checks failed
CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m29s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 3m14s
CI / Database tests on PostgreSQL (push) Successful in 4m12s
CI / Security audit (push) Successful in 1m6s
CI / Browser tests (Chromium) (push) Successful in 34m31s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m35s
Every Python / Unit tests (Python 3.13) (push) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
2026-10-28 07:44:54 +00:00
Compare
tiagoagueda force-pushed 325-backup-destinations from a8fda0cd0b
Some checks failed
CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m29s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 3m14s
CI / Database tests on PostgreSQL (push) Successful in 4m12s
CI / Security audit (push) Successful in 1m6s
CI / Browser tests (Chromium) (push) Successful in 34m31s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m35s
Every Python / Unit tests (Python 3.13) (push) Has been cancelled
CI / Unit tests and coverage (Python 3.14) (pull_request) Has been cancelled
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Has been cancelled
CI / Database tests on PostgreSQL (pull_request) Has been cancelled
CI / Security audit (pull_request) Has been cancelled
CI / Browser tests (Chromium) (pull_request) Has been cancelled
to ffec2c933f
Some checks failed
CI / Unit tests and coverage (Python 3.14) (push) Successful in 13m31s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 3m9s
CI / Database tests on PostgreSQL (push) Successful in 4m11s
CI / Security audit (push) Successful in 1m5s
CI / Browser tests (Chromium) (push) Successful in 33m0s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m43s
Every Python / Unit tests (Python 3.13) (push) Successful in 13m57s
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 12m59s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m49s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m8s
CI / Security audit (pull_request) Successful in 1m1s
CI / Browser tests (Chromium) (pull_request) Failing after 31m42s
2026-10-28 15:30:20 +00:00
Compare
tiagoagueda deleted branch 325-backup-destinations 2026-10-28 15:33:57 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo!1071
No description provided.