Compare commits

...

10 Commits

Author SHA1 Message Date
dillard 373d7e0424 Screenshots of 1.1.0: a schedule per location, a pinned restore point with a note, passphrase slots, the Bin
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
2026-11-08 16:12:07 +01:00
dillard c5024d3ddc Version 1.1.0: changelog, README status and roadmap 2026-11-08 13:02:28 +01:00
dillard 345b1d545e Keep the list of backups within the page: long text wraps instead of scrolling 2026-11-08 12:28:23 +01:00
dillard 98067f7746 Roadmap: what 1.1.0 brings, parallel uploads in 1.2.0 2026-11-08 12:11:15 +01:00
dillard cd951af1a1 Test the update from a released version on a server that was backing up 2026-11-08 11:58:36 +01:00
dillard cf9cf50286 Replace a backup key that was never used by the key of existing backups
A key made by mistake on a server that is being restored left no way back: key:import and the
passphrase route refused because a key existed, and nothing removed it. As long as the key never
protected anything (no location created or adopted with it, no location or run in the database),
occ backup:key:import --replace-unused-key and occ backup:target:add --replace-unused-key replace
it, after the new key was opened. A key that was used is never replaced, also after its location
was removed. The refusal names the option, and all administrators are notified of a replacement.
2026-11-08 11:53:55 +01:00
dillard 2b21596b3a Plan: speed measurements, parallel uploads planned for 1.2.0 2026-11-08 11:37:13 +01:00
dillard 953312bf44 Read files for a backup in pieces of 4 MiB instead of 8 KiB
PHP reads a file 8 KiB at a time by default. On a disk where every
request waits, such as a network or cloud volume, that made reading five
times slower: on the test server 5 MiB/s instead of 27 MiB/s. Without
PHP's read buffer every read of a blob is one request to the disk. The
same for packs on a local location.
2026-11-07 12:44:57 +01:00
dillard 569077fa69 Restore without the recovery kit: the key from the location, with a passphrase
On a server without a backup key, backup:target:add finds the backups at
the location and takes their key from its config with one of the old
server's passphrases. Backups then wait until a new recovery kit is
confirmed. The deletion delay and the repository id are now stored on the
location, sealed with the key, so the delay survives a restore without a
kit and --repair-config can write a lost config again from the kit.

The key page warns against creating a new key when restoring a server.
New test: tests/integration/recovery-scenarios.sh, and a no-kit mode in
full-restore-roundtrip.sh.
2026-11-07 12:22:38 +01:00
dillard 51a4a67bc4 Heavy background jobs of other apps pause while a backup runs
Reserved in Nextcloud's job list, so no app is turned off and a job that is running goes on.
At most 6 hours in a row, then they get an hour; Nextcloud ends a reservation by itself after
12 hours, should the end of a pause never come. On by default, with a setting to turn it off.
2026-11-07 01:56:44 +01:00
34 changed files with 1381 additions and 84 deletions
+23 -1
View File
@@ -11,7 +11,7 @@ Each heading starts with that `<version>`, once: the app store shows a release t
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
shown under 1.0.0. The git tag of a release candidate goes after the date. shown under 1.0.0. The git tag of a release candidate goes after the date.
## [Unreleased] ## [1.1.0] - Unreleased
### Added ### Added
- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it - Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it
@@ -25,8 +25,30 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
- All folders get their original modification time back on a restore, not only the empty ones, from - All folders get their original modification time back on a restore, not only the empty ones, from
the first backup made with this version. An older version of NG Backup can still read new restore the first backup made with this version. An older version of NG Backup can still read new restore
points. A restore through Nextcloud into a folder that is already there leaves its time alone. points. A restore through Nextcloud into a folder that is already there leaves its time alone.
- Heavy background jobs (Memories, Recognize, Preview Generator, full text search, the antivirus
background scan) pause while a backup runs, at most 6 hours in a row, then they get an hour. No app
is turned off, and a job that is running goes on. On by default; turn it off under Backups or with
`occ backup:pause-jobs off`.
- Disaster recovery without the recovery kit: on a new server without a backup key,
`occ backup:target:add` finds the backups on the location and takes their key from there with one
of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The
deletion delay is now also stored on the location, sealed with the key, from its first backup
with this version; a location backed up only by an older version gives the default of 7 days.
- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery
kit writes it again (for locations backed up by this version or newer). Before, the message said
this was possible, but it was not.
- The key page warns against creating a new key when you are restoring a server. A key made there
by mistake can still make way for the key of the backups, as long as it was never used (no
location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or
`occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that
protected a location is never replaced. All administrators are notified of a replacement.
### Changed ### Changed
- The list of backups stays within the page: a long label, note or error wraps onto more lines
instead of making the table scroll sideways.
- Reading files for a backup is much faster on disks where every request waits, such as network and
cloud volumes: files are read in pieces of 4 MiB instead of 8 KiB. On a test server the disk
delivered 27 MiB/s instead of 5 MiB/s.
- Locations are backed up one after the other: a backup that has to wait shows as queued. A restore - Locations are backed up one after the other: a backup that has to wait shows as queued. A restore
point has the time its backup really began, not the time it was queued. point has the time its backup really began, not the time it was queued.
- Choosing a time no longer starts a backup right away when that time has already passed today. - Choosing a time no longer starts a backup right away when that time has already passed today.
+20 -1
View File
@@ -579,7 +579,11 @@ in README "Known limitations":
mappen in de backup ontbreken, geeft voorbeelden van de paden en vraagt ze te hernoemen; de volgende mappen in de backup ontbreken, geeft voorbeelden van de paden en vraagt ze te hernoemen; de volgende
backup neemt ze mee. Alleen als Nextcloud-melding: de app mailt niet zelf, of meldingen gemaild worden backup neemt ze mee. Alleen als Nextcloud-melding: de app mailt niet zelf, of meldingen gemaild worden
is de keuze van de beheerder (besluit 2026-11-06). is de keuze van de beheerder (besluit 2026-11-06).
**Stand op branch `1.1.0` (2026-11-07):** klaar: betere melding, punten 4, 5, 6 en 8. Pin en notitie staan **Stand op branch `1.1.0` (2026-11-07):** klaar: betere melding, punten 2, 4, 5, 6 en 8, en de maptijden
(`files:scan` na een volledige restore: 0 "Updated" in de simulatie; opslag gepakt in het metarecord).
Punt 2 reserveert de jobs in `oc_jobs` met één tijdstempel, alleen wat Nextcloud op dat moment zou kunnen
starten; 6 uur aan, dan 1 uur vrij; bij een crash geeft Nextcloud de reservering na 12 uur zelf vrij;
de logregels staan op niveau info, dus standaard niet zichtbaar. Pin en notitie staan
in de catalogus, met een kopie in de database voor de lijsten. Een moment van vóór het instellen van in de catalogus, met een kopie in de database voor de lijsten. Een moment van vóór het instellen van
een schema start niets. Geen schema: geen melding "geen recente backup", wel een melding aan alle een schema start niets. Geen schema: geen melding "geen recente backup", wel een melding aan alle
beheerders bij het uitzetten en een herinnering, wekelijks zolang een andere locatie een schema heeft, beheerders bij het uitzetten en een herinnering, wekelijks zolang een andere locatie een schema heeft,
@@ -588,6 +592,13 @@ in README "Known limitations":
als reserve als `config` ontbreekt of niet opent. Vergt een andere volgorde van als reserve als `config` ontbreekt of niet opent. Vergt een andere volgorde van
`target:add` en sleutelimport (het gevoeligste pad), daarom niet in rc1. Daarna de `target:add` en sleutelimport (het gevoeligste pad), daarom niet in rc1. Daarna de
DR-test op PVE02 herhalen zonder kit. DR-test op PVE02 herhalen zonder kit.
**Gebouwd (2026-11-07):** `backup:target:add` op een server zonder sleutel kijkt eerst of de
locatie een repository heeft, vraagt dan een wachtwoordzin (of `--passphrase-file`), opent de
slots uit `config`, controleert de sleutel tegen de `check` en slaat hem pas dan op; geen
kitbevestiging, dus backups wachten op een nieuwe kit. Nieuw bestand `settings` op de locatie,
versleuteld met de hoofdsleutel en eenmalig geschreven (bij `target:add` en bij het begin van
een backup): de verwijdertermijn, anders de standaard 7 dagen. Getest: unit-tests en
`full-restore-roundtrip.sh 35-apache pgsql no-kit`. Nog open: de DR-test op PVE02 zonder kit.
2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten): 2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten):
Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch. Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch.
Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal
@@ -595,6 +606,14 @@ in README "Known limitations":
3. Snelheid: lezen en uploaden laten overlappen, meerdere bestanden tegelijk lezen, eventueel 3. Snelheid: lezen en uploaden laten overlappen, meerdere bestanden tegelijk lezen, eventueel
een eigen worker (`occ background-job:worker`). Gemeten op nc01: 55% wachten op de schijf, een eigen worker (`occ background-job:worker`). Gemeten op nc01: 55% wachten op de schijf,
33% op de SMB-upload, ~7% CPU. Vergt extra PHP-processen; botst mogelijk met uitgangspunt 1. 33% op de SMB-upload, ~7% CPU. Vergt extra PHP-processen; botst mogelijk met uitgangspunt 1.
**Gemeten 2026-11-07 op nc01 (alleen lezen):** de schijf (sdb, 2 TB, ROTA=1) haalt met `dd`
21 MiB/s met 1 stroom, 37 met 4, 55 met 8. PHP las 8 KiB per verzoek: 5,2 MiB/s. Met
`stream_set_read_buffer(0)` en blokken van 4 MiB: 26,8 MiB/s. Hulpprocessen die vooruitlezen
gaven daarbovenop niets (20 MiB/s, ze concurreren). Gebouwd: grote leesverzoeken (commit
"Read files ... 4 MiB"). Niet gebouwd: meerdere uploads tegelijk; SMB bundelt al per 1 MB
(icewind/smb), dus winst over een lange lijn vraagt meerdere packs tegelijk.
**Besluit 2026-11-08: meerdere uploads tegelijk in 1.2.0**, met een instelling (standaard 2)
zodat de beheerder het afstemt op zijn server en de omvang van zijn backup.
4. **Schema per locatie** (besluit 2026-10-19): in de instellingen van een locatie "uit", 4. **Schema per locatie** (besluit 2026-10-19): in de instellingen van een locatie "uit",
"dagelijks om HH:MM" of "elke N uur (vanaf HH:MM)". Nu is er één `schedule_time` voor alle "dagelijks om HH:MM" of "elke N uur (vanaf HH:MM)". Nu is er één `schedule_time` voor alle
locaties, één keer per dag. Bij de update wordt de huidige tijd het schema van elke locaties, één keer per dag. Bij de update wordt de huidige tijd het schema van elke
+9 -15
View File
@@ -7,7 +7,7 @@ installations and hosted Nextcloud. (Choosing SMB as a storage backend is the on
see "Storage backend experiences" below — that requirement comes from files_external, not from see "Storage backend experiences" below — that requirement comes from files_external, not from
ng_backup, and applies to any Nextcloud app that writes to an SMB share.) ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
**Status: 1.0.3, stable.** Disaster recovery has been tested on real installations (see "Tested **Status: 1.1.0, stable.** Disaster recovery has been tested on real installations (see "Tested
disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See `docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
`docs/DISASTER-RECOVERY.md`. `docs/DISASTER-RECOVERY.md`.
@@ -87,8 +87,9 @@ All backups of an installation are encrypted with one master key.
- The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and - The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and
off the location: an attacker who gets write access to the location can damage or delete its off the location: an attacker who gets write access to the location can damage or delete its
`config` file on purpose, and without the kit that makes every backup there unreadable. `config` file on purpose, and without the kit that makes every backup there unreadable.
- For a **restore on a new server** (disaster recovery) you currently need the **recovery kit** - For a **restore on a new server** (disaster recovery) **one passphrase** is enough: the new
and **one passphrase**. server takes the key from the location. With the **recovery kit** you can restore even when the
location's `config` file is damaged or gone.
- Planned: an option to not store the key on the location at all. Then the kit is the only copy, - Planned: an option to not store the key on the location at all. Then the kit is the only copy,
and losing it means losing every backup; that option will come with an explicit warning. and losing it means losing every backup; that option will come with an explicit warning.
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not - Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
@@ -170,6 +171,7 @@ Full restores onto a fresh server, following only [the guide](docs/DISASTER-RECO
| Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical | | Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical |
| The same, 1.0.2 | Hetzner Storage Box (SMB), Germany → Canada | PostgreSQL → PostgreSQL | 150,755 files restored in about 29 h (7-13 MiB/s), going on after a lost connection; sample identical, empty folders not restored ([#27](https://github.com/dillardblom/ng_backup/issues/27)) | | The same, 1.0.2 | Hetzner Storage Box (SMB), Germany → Canada | PostgreSQL → PostgreSQL | 150,755 files restored in about 29 h (7-13 MiB/s), going on after a lost connection; sample identical, empty folders not restored ([#27](https://github.com/dillardblom/ng_backup/issues/27)) |
| 1.0.3, small simulations of 2 GB: as planned, with the restore killed halfway, and with a disk that runs full | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files and all empty folders identical; users, a share and a calendar back | | 1.0.3, small simulations of 2 GB: as planned, with the restore killed halfway, and with a disk that runs full | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files and all empty folders identical; users, a share and a calendar back |
| 1.1.0, the same three simulations | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files identical in content, size and time, all empty folders back; users, a share and a calendar back |
| 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical | | 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical |
| The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical | | The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical |
| The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) | | The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) |
@@ -266,18 +268,9 @@ What is planned, without dates. Ideas and votes are welcome in the
[issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here, [issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here,
a 👍 on its issue helps decide what comes first. a 👍 on its issue helps decide what comes first.
**1.1.0** **1.2.0**
- Restore without the recovery kit: the backup key from the location itself, opened with one of - Faster backups over a long line: several uploads at the same time, with a setting
your passphrases. (2 by default) to suit the server and the size of the backup.
- A schedule per location, also "every N hours".
- Locations backed up one after the other instead of at the same time.
- All folders get their original modification time back on a restore, not only the empty ones.
- A note on a restore point that you can change afterwards.
- Pinning a restore point, so the retention policy never removes it (for example the one from
before an upgrade).
- An option to pause heavy background jobs (such as Memories, Recognize or the Preview
Generator) while a backup runs.
- Faster backups: reading and uploading at the same time.
**Later** **Later**
- A full restore onto the existing installation, without first installing a fresh Nextcloud, in a - A full restore onto the existing installation, without first installing a fresh Nextcloud, in a
@@ -336,6 +329,7 @@ On the backlog; know them before you rely on NG Backup:
| `backup:browse`, `backup:restore` | browse and restore | | `backup:browse`, `backup:restore` | browse and restore |
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash | | `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
| `backup:pin`, `backup:note` | keep a restore point, a note you can change later | | `backup:pin`, `backup:note` | keep a restore point, a note you can change later |
| `backup:pause-jobs` | whether heavy background jobs of other apps pause during a backup |
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) | | `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) | | `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
| `backup:key:import` | import a recovery kit on a fresh installation | | `backup:key:import` | import a recovery kit on a fresh installation |
+6 -5
View File
@@ -45,7 +45,7 @@ welcome: use the "Share your experience" link on the admin page.
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
you need help with restoring a backup after a crash.]]></description> you need help with restoring a backup after a crash.]]></description>
<version>1.0.3</version> <version>1.1.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="dillard.blom@ensembia.com">Dillard Blom</author> <author mail="dillard.blom@ensembia.com">Dillard Blom</author>
<namespace>NgBackup</namespace> <namespace>NgBackup</namespace>
@@ -56,10 +56,10 @@ you need help with restoring a backup after a crash.]]></description>
<website>https://github.com/dillardblom/ng_backup</website> <website>https://github.com/dillardblom/ng_backup</website>
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs> <bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
<repository type="git">https://github.com/dillardblom/ng_backup</repository> <repository type="git">https://github.com/dillardblom/ng_backup</repository>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.3/screenshots/1-backups.png</screenshot> <screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/1-backups.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.3/screenshots/2-restore.png</screenshot> <screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/2-restore.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.3/screenshots/3-settings.png</screenshot> <screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/3-settings.png</screenshot>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.3/screenshots/4-bin.png</screenshot> <screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/4-bin.png</screenshot>
<dependencies> <dependencies>
<php min-version="8.1"/> <php min-version="8.1"/>
<lib>sodium</lib> <lib>sodium</lib>
@@ -83,6 +83,7 @@ you need help with restoring a backup after a crash.]]></description>
<command>OCA\NgBackup\Command\ListSnapshots</command> <command>OCA\NgBackup\Command\ListSnapshots</command>
<command>OCA\NgBackup\Command\Browse</command> <command>OCA\NgBackup\Command\Browse</command>
<command>OCA\NgBackup\Command\Restore</command> <command>OCA\NgBackup\Command\Restore</command>
<command>OCA\NgBackup\Command\PauseJobs</command>
<command>OCA\NgBackup\Command\Pin</command> <command>OCA\NgBackup\Command\Pin</command>
<command>OCA\NgBackup\Command\Note</command> <command>OCA\NgBackup\Command\Note</command>
<command>OCA\NgBackup\Command\Prune</command> <command>OCA\NgBackup\Command\Prune</command>
+1
View File
@@ -25,6 +25,7 @@ return [
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'], ['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'], ['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'], ['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
['name' => 'AdminApi#setPauseHeavyJobs', 'url' => '/api/settings/pause-heavy-jobs', 'verb' => 'PUT'],
['name' => 'AdminApi#setTargetSchedule', 'url' => '/api/targets/{id}/schedule', 'verb' => 'PUT'], ['name' => 'AdminApi#setTargetSchedule', 'url' => '/api/targets/{id}/schedule', 'verb' => 'PUT'],
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'], ['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'], ['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'],
+31 -9
View File
@@ -5,7 +5,8 @@ Nextcloud back from an NG Backup location. Do them in this order.
## What you need ## What you need
- [ ] The **recovery kit** file (`ng_backup-kit.json`) and **one passphrase** of it. - [ ] **One passphrase** of the old server, and if you have it, the **recovery kit** file
(`ng_backup-kit.json`).
- [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder. - [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder.
- [ ] A new server with the **same Nextcloud major version** as the old one (for example 34), - [ ] A new server with the **same Nextcloud major version** as the old one (for example 34),
a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for
@@ -15,8 +16,10 @@ Nextcloud back from an NG Backup location. Do them in this order.
- [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by - [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by
hand). Keep their packages somewhere safe beforehand. hand). Keep their packages somewhere safe beforehand.
No kit? If the location still holds its `config` file, the key is there too, but this version No kit? Since version 1.1.0 the passphrase is enough: the key is also stored on the location (in
can only import it from a kit. Keep the kit off the server, next to the passphrase. its `config` file), locked with the same passphrases. Skip step 4. The kit is still the safer
copy: it helps when that file on the location is damaged. Keep it off the server, next to the
passphrase.
## Steps ## Steps
@@ -40,7 +43,8 @@ This keeps the new server quiet (no mail syncs, no notifications, no backups) un
**3. Install NG Backup** (the same version or newer) and enable it. **3. Install NG Backup** (the same version or newer) and enable it.
**4. Import the backup key.** Copy the kit to the new server so the web server user can read it. **4. Import the backup key** (with a kit; without one, go to step 5). Copy the kit to the new
server so the web server user can read it.
From your own computer: From your own computer:
@@ -67,13 +71,27 @@ the index and shows how far it is, for example
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... -o root=... --path=... occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... -o root=... --path=...
If it says "No backup key yet", the key is not imported. Run step 4: Without a kit (step 4 skipped), it finds the backups and asks for a passphrase of the old server;
you type it yourself. It then shows the key fingerprint and the deletion delay of the old server.
A wrong passphrase changes nothing: run the command again. Backups made before version 1.1.0 did
not store the deletion delay on the location; it is then set to 7 days.
occ backup:key:import /tmp/ng_backup-kit.json If it says that the location's **config file is gone**, the key is only in the recovery kit:
import it (step 4), then add the location with `--repair-config` at the end of the same command.
That writes the config again from the kit; it works for locations backed up by version 1.1.0 or
newer.
Do **not** run `backup:key:init`, even though the message suggests it: that makes a brand new key, If it says "No backup key yet, and no backups at this location", the connection details or
and your backups were encrypted with the old one. NG Backup then refuses the location ("created `--path` differ from the old server's. Check them; do **not** run `backup:key:init`: that makes a
with a different key"). brand new key, and your backups were encrypted with the old one. NG Backup then refuses the
location ("created with a different key").
**Made a new key by mistake** (with `backup:key:init` or on the page) before adding the location?
As long as that key was never used (no location added with it, no backup), it can make way for the
key of your backups. NG Backup says so when it refuses the location. Add `--replace-unused-key` to
`occ backup:key:import` (with the kit) or to `occ backup:target:add` (without the kit; it asks for
a passphrase of the old server). The passphrases and kit of the unused key then stop working. A key
that was used for a location is never replaced: its own backups depend on it.
**6. List the restore points** and pick one, normally the newest: **6. List the restore points** and pick one, normally the newest:
@@ -164,6 +182,10 @@ The command then ends with exit code 2 instead of 0. The same option exists for
**11. Make a backup of the new server** to the same location (`occ backup:run offsite`). The old **11. Make a backup of the new server** to the same location (`occ backup:run offsite`). The old
server must stay off: two servers must never write to the same location. server must stay off: two servers must never write to the same location.
Restored without a kit? Then backups wait until you have a new one: download it on the backup
page (or `occ backup:key:kit --output=...`), store it off the server and confirm it
(`occ backup:key:confirm`).
## Never ## Never
- Never let the old server write to the location again if it comes back. Switch its backups off - Never let the old server write to the location again if it comes back. Switch its backups off
+4
View File
@@ -157,6 +157,10 @@ occ backup:list offsite # the snapshots must be lis
occ backup:restore:full offsite <snapshot> occ backup:restore:full offsite <snapshot>
``` ```
Without the kit, leave out `backup:key:import`: `backup:target:add` finds the backups and takes
the key from the location with a passphrase (`--passphrase-file=/path/passphrase`, or it asks).
Backups then wait until a new kit is downloaded and confirmed.
The command refuses on an installation that already has more than one user, and asks before it The command refuses on an installation that already has more than one user, and asks before it
starts; `--force` skips both (also needed to rerun it after it stopped halfway). starts; `--force` skips both (also needed to rerun it after it stopped halfway).
+3
View File
@@ -67,6 +67,9 @@ final class LocalBackend implements IBackend {
if ($fh === false) { if ($fh === false) {
throw new BackendException('Not found: ' . $path); throw new BackendException('Not found: ' . $path);
} }
// Packs are read in large pieces: one request each, not 8 KiB at a time (see Repository::openForReading()).
stream_set_read_buffer($fh, 0);
stream_set_chunk_size($fh, 4 * 1048576);
return $fh; return $fh;
} }
+2
View File
@@ -33,6 +33,7 @@ final class BackupJob extends TimedJob {
private IAppConfig $appConfig, private IAppConfig $appConfig,
private LoggerInterface $logger, private LoggerInterface $logger,
private \OCA\NgBackup\Service\Schedule $schedule, private \OCA\NgBackup\Service\Schedule $schedule,
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
) { ) {
parent::__construct($time); parent::__construct($time);
// Well below the usual 5-minute cron interval: the last run is stamped a moment after cron // Well below the usual 5-minute cron interval: the last run is stamped a moment after cron
@@ -47,6 +48,7 @@ final class BackupJob extends TimedJob {
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0; $budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
$deadline = microtime(true) + $budget; $deadline = microtime(true) + $budget;
$this->startScheduled(); $this->startScheduled();
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
// One after the other: runs that read the same disks at the same time are not faster together. // One after the other: runs that read the same disks at the same time are not faster together.
foreach ($this->backups->runsInTurn() as $run) { foreach ($this->backups->runsInTurn() as $run) {
while (microtime(true) < $deadline && $run->getStatus() === 'running') { while (microtime(true) < $deadline && $run->getStatus() === 'running') {
+32 -4
View File
@@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\NgBackup\Command; namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -18,6 +19,7 @@ use Symfony\Component\Console\Question\Question;
final class KeyImport extends Command { final class KeyImport extends Command {
public function __construct( public function __construct(
private KeyService $keys, private KeyService $keys,
private TargetService $targets,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -27,11 +29,19 @@ final class KeyImport extends Command {
->setDescription('Disaster recovery: adopt the backup key from a recovery kit on a fresh installation') ->setDescription('Disaster recovery: adopt the backup key from a recovery kit on a fresh installation')
->addArgument('kit-file', InputArgument::REQUIRED, 'The recovery kit file (occ backup:key:kit on the original installation)') ->addArgument('kit-file', InputArgument::REQUIRED, 'The recovery kit file (occ backup:key:kit on the original installation)')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking') ->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'A new key was made here by mistake: replace it by the kit\'s key, if it was never used (no location, no backup)')
->setHelp(<<<'HELP' ->setHelp(<<<'HELP'
For a fresh installation only (occ backup:key:init must not have been run here yet). Reads one For a fresh installation only (occ backup:key:init must not have been run here yet). Reads one
of the kit's passphrase slots to recover the original master key, so occ backup:target:add opens of the kit's passphrase slots to recover the original master key, so occ backup:target:add opens
the existing repository on the location instead of creating a new, empty one. the existing repository on the location instead of creating a new, empty one.
A new key was made here by mistake before the location was added? As long as it was never used
(no location, no backup), --replace-unused-key replaces it by the kit's key. A key that was used
is never replaced.
No kit? Skip this command: occ backup:target:add then takes the key from the location with one
of the old server's passphrases. The kit still helps when the location's config file is damaged.
Example: Example:
occ backup:key:import /root/ng_backup-recovery-kit.json occ backup:key:import /root/ng_backup-recovery-kit.json
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=...
@@ -40,10 +50,24 @@ HELP);
} }
protected function execute(InputInterface $input, OutputInterface $output): int { protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->keys->isInitialized()) { $replace = (bool)$input->getOption('replace-unused-key');
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '). occ backup:key:import is only for a fresh installation.</error>'); if ($replace && !$this->keys->isInitialized()) {
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
return 1; return 1;
} }
if ($this->keys->isInitialized()) {
if (!$this->targets->keyIsUnused()) {
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . ') and it has been used for a backup location: it is never replaced.</error>');
$output->writeln('occ backup:key:import is for a server that is being restored.');
return 1;
}
if (!$replace) {
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '), but it was never used: no location, no backup.</error>');
$output->writeln('Made by mistake on a server that is being restored? Run this command again with --replace-unused-key:');
$output->writeln('the kit\'s key then takes its place, and the passphrases and recovery kit of the unused key stop working.');
return 1;
}
}
$kitFile = (string)$input->getArgument('kit-file'); $kitFile = (string)$input->getArgument('kit-file');
$raw = @file_get_contents($kitFile); $raw = @file_get_contents($kitFile);
if ($raw === false) { if ($raw === false) {
@@ -70,12 +94,16 @@ HELP);
$pass = (string)$helper->ask($input, $output, $q); $pass = (string)$helper->ask($input, $output, $q);
} }
try { try {
$this->keys->importRecoveryKit($kit, $pass); if ($replace) {
$this->targets->importKitReplacingUnusedKey($kit, $pass);
} else {
$this->keys->importRecoveryKit($kit, $pass);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getMessage() . '</error>');
return 1; return 1;
} }
$output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.'); $output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
// Read from the kit file, which is not authenticated beyond the wrapped key: show it so a // Read from the kit file, which is not authenticated beyond the wrapped key: show it so a
// wrong or altered value stands out. // wrong or altered value stands out.
$output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).'); $output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\HeavyJobPause;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class PauseJobs extends Command {
public function __construct(
private HeavyJobPause $pause,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:pause-jobs')
->setDescription('Show or set whether heavy background jobs (Memories, Recognize, Preview Generator, ...) pause while a backup runs')
->addArgument('setting', InputArgument::OPTIONAL, '"on" or "off"; omit to show the current one');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$setting = $input->getArgument('setting');
if ($setting !== null) {
if ($setting !== 'on' && $setting !== 'off') {
$output->writeln('<error>Use "on" or "off".</error>');
return 1;
}
$this->pause->setEnabled($setting === 'on');
}
$status = $this->pause->status();
$output->writeln('Pausing heavy background jobs during a backup: ' . ($status['enabled'] ? 'on' : 'off'));
if ($status['jobs'] !== []) {
$output->writeln('Paused now: ' . implode(', ', $status['jobs']));
}
return 0;
}
}
+117 -9
View File
@@ -13,7 +13,9 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class TargetAdd extends Command { final class TargetAdd extends Command {
public function __construct( public function __construct(
@@ -31,10 +33,25 @@ final class TargetAdd extends Command {
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)') ->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)') ->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup') ->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Disaster recovery without the recovery kit: read the passphrase from this file instead of asking')
->addOption('repair-config', null, InputOption::VALUE_NONE, 'The location\'s config file is gone: write it again with the key of this server or of the imported recovery kit (locations backed up by 1.1.0 or newer)')
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'Disaster recovery after a new key was made here by mistake: replace that key, if it was never used, by the key of the backups at this location (asks for a passphrase of the old server)')
->setHelp(<<<'HELP' ->setHelp(<<<'HELP'
The location is reached with files_external's own connection code, but it is never added as an The location is reached with files_external's own connection code, but it is never added as an
external storage: it does not appear in Files for anyone. Credentials are stored encrypted. external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
Disaster recovery without the recovery kit: on a server without a backup key, a location that
holds backups gives its key with one of the old server's passphrases. Run this command first
(no occ backup:key:import), then occ backup:list and occ backup:restore:full.
A new key was made here by mistake (occ backup:key:init or the web page) before the location
was added? As long as that key was never used (no location, no backup), --replace-unused-key
replaces it by the key of the backups. A key that was used is never replaced.
With the recovery kit: occ backup:key:import first; this command then asks for nothing more.
If the location's config file is gone, only the kit has the key: import it, then add the
location with --repair-config.
Examples: Examples:
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
@@ -43,13 +60,6 @@ HELP);
} }
protected function execute(InputInterface $input, OutputInterface $output): int { protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet.</error>');
$output->writeln('Restoring from a location that already has backups (disaster recovery)? Import the key from the recovery kit first:');
$output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json');
$output->writeln('Only for a new location without backups: create a new key with occ backup:key:init. A new key cannot read existing backups.');
return 1;
}
$options = []; $options = [];
foreach ($input->getOption('option') as $pair) { foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) { if (!str_contains($pair, '=')) {
@@ -63,24 +73,122 @@ HELP);
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>'); $output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
return 1; return 1;
} }
$name = (string)$input->getArgument('name');
$backend = (string)$input->getArgument('backend');
$auth = (string)$input->getOption('auth');
$path = (string)$input->getOption('path');
$passphrase = null;
$repair = (bool)$input->getOption('repair-config');
$replace = (bool)$input->getOption('replace-unused-key');
if ($replace) {
if ($repair) {
$output->writeln('<error>--replace-unused-key needs the config at the location; with the config gone only the kit helps: occ backup:key:import <kit file> --replace-unused-key, then --repair-config.</error>');
return 1;
}
if (!$this->keys->isInitialized()) {
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
return 1;
}
if (!$this->targets->keyIsUnused()) {
$output->writeln('<error>The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced.</error>');
return 1;
}
$output->writeln('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') was never used. It is replaced by the key of the backups at');
$output->writeln('this location, once a passphrase of the old server opens it. Its own passphrases and recovery kit then stop working.');
$passphrase = $this->passphrase($input, $output);
if ($passphrase === null) {
return 1;
}
} elseif (!$this->keys->isInitialized()) {
try {
$state = $this->targets->locationState($backend, $auth, $options, $path);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($state === TargetService::LOCATION_EMPTY) {
$output->writeln('<error>No backup key yet, and no backups at this location.</error>');
$output->writeln('Restoring a server (disaster recovery)? Check the connection details and --path: they must be the same as on the old server.');
$output->writeln('Only for a new location: create a new key first with occ backup:key:init. A new key cannot read existing backups.');
return 1;
}
if ($state === TargetService::LOCATION_DATA_WITHOUT_CONFIG) {
$output->writeln('<error>This location holds backups, but its config file is gone, and the key was stored in it.</error>');
$output->writeln('Now only the recovery kit has the key. Import it, then run this command again with --repair-config:');
$output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json');
$output->writeln('Do not run occ backup:key:init: a new key cannot read these backups.');
return 1;
}
if ($repair) {
$output->writeln('<error>The config at this location is there: leave out --repair-config.</error>');
return 1;
}
$output->writeln('No backup key on this server yet, and this location holds backups. Their key is stored there too,');
$output->writeln('locked with the passphrases of the old server: one of them unlocks it (the recovery kit is not needed).');
$passphrase = $this->passphrase($input, $output);
if ($passphrase === null) {
return 1;
}
}
// Adopting an existing repository reads its whole index first, which takes a while on a // Adopting an existing repository reads its whole index first, which takes a while on a
// large backup or a slow line; show how far it is instead of looking stuck. // large backup or a slow line; show how far it is instead of looking stuck.
$output->writeln('Connecting to the location; with existing backups this reads their index first ...'); $output->writeln('Connecting to the location; with existing backups this reads their index first ...');
try { try {
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, $r = $passphrase === null
(string)$input->getOption('path'), self::progress($output)); ? $this->targets->add($name, $backend, $auth, $options, $path, self::progress($output), $repair)
: $this->targets->addWithPassphrase($name, $backend, $auth, $options, $path, $passphrase, self::progress($output), $replace);
} catch (\OCA\NgBackup\Crypto\CryptoException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
if (!$this->keys->isInitialized() || $replace) {
$output->writeln('Nothing was changed. Run the command again to try another passphrase, or use the recovery kit (occ backup:key:import).');
}
return 1;
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($output->isDecorated()) { if ($output->isDecorated()) {
$output->write("\r\033[K"); // clear an unfinished progress line $output->write("\r\033[K"); // clear an unfinished progress line
} }
$output->writeln('<error>' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getMessage() . '</error>');
if (str_contains($e->getMessage(), 'different key') && !$replace) {
$output->writeln('The backup key here (fingerprint ' . $this->keys->fingerprint() . ') is not the one these backups were made with.');
$output->writeln('Restoring a server? The recovery kit must be the old server\'s: its fingerprint is in the kit file. Nothing was changed.');
}
return 1; return 1;
} }
if ($repair) {
$output->writeln('The config of the location was written again from the recovery kit.');
}
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(), $output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted')); $r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
if ($passphrase !== null) {
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
$output->writeln(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null
? 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the location).'
: 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (the default: the location has none stored, its backups were made before NG Backup 1.1.0).');
$output->writeln('Next: <info>occ backup:list</info> and <info>occ backup:restore:full</info>. Before backups run again, download a new recovery kit and confirm it (<info>occ backup:key:kit</info>, <info>occ backup:key:confirm</info>).');
}
return 0; return 0;
} }
private function passphrase(InputInterface $input, OutputInterface $output): ?string {
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$raw = @file_get_contents((string)$file);
if ($raw === false) {
$output->writeln('<error>Cannot read the passphrase file</error>');
return null;
}
return rtrim($raw, "\r\n");
}
if (!$input->isInteractive()) {
$output->writeln('<error>Give the passphrase with --passphrase-file, or run the command without --no-interaction to type it.</error>');
return null;
}
$q = (new Question('Passphrase of the old server (one of its slots): '))->setHidden(true)->setHiddenFallback(false);
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
return (string)$helper->ask($input, $output, $q);
}
/** /**
* Progress of reading the index: on a terminal one line that is updated in place, otherwise * Progress of reading the index: on a terminal one line that is updated in place, otherwise
* (a log, a pipe) a line every 30 seconds. The time left is estimated from the rate so far. * (a log, a pipe) a line every 30 seconds. The time left is estimated from the rate so far.
+8
View File
@@ -48,6 +48,7 @@ final class AdminApiController extends Controller {
private \OCA\NgBackup\Service\KeySlotService $slots, private \OCA\NgBackup\Service\KeySlotService $slots,
private \OCA\NgBackup\Service\PruneService $prune, private \OCA\NgBackup\Service\PruneService $prune,
private \OCA\NgBackup\Service\Schedule $schedule, private \OCA\NgBackup\Service\Schedule $schedule,
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
private \OCA\NgBackup\Service\CronHealth $cron, private \OCA\NgBackup\Service\CronHealth $cron,
) { ) {
parent::__construct(Application::APP_ID, $request); parent::__construct(Application::APP_ID, $request);
@@ -77,9 +78,15 @@ final class AdminApiController extends Controller {
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown), 'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS, 'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
'cron' => $this->cron->check(), 'cron' => $this->cron->check(),
'heavyJobs' => $this->heavyJobs->status(),
]); ]);
} }
public function setPauseHeavyJobs(bool $enabled): JSONResponse {
$this->heavyJobs->setEnabled($enabled);
return new JSONResponse(['enabled' => $enabled]);
}
/** Seconds of backup work one tick() does: well within a normal web request. */ /** Seconds of backup work one tick() does: well within a normal web request. */
public const TICK_SECONDS = 10.0; public const TICK_SECONDS = 10.0;
/** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */ /** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */
@@ -97,6 +104,7 @@ final class AdminApiController extends Controller {
} }
$deadline = microtime(true) + self::TICK_SECONDS; $deadline = microtime(true) + self::TICK_SECONDS;
$stepped = 0; $stepped = 0;
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
foreach ($this->backups->runsInTurn() as $run) { foreach ($this->backups->runsInTurn() as $run) {
if (microtime(true) >= $deadline) { if (microtime(true) >= $deadline) {
break; break;
+1 -1
View File
@@ -120,7 +120,7 @@ final class BackupRun {
if ($cur === null || $cur['path'] !== $logical) { if ($cur === null || $cur['path'] !== $logical) {
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version]; $cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
} }
$fh = @fopen($path, 'rb'); $fh = Repository::openForReading($path);
if ($fh === false) { if ($fh === false) {
self::recordUnreadable($state, $stats, $logical); self::recordUnreadable($state, $stats, $logical);
$state['cur'] = null; $state['cur'] = null;
+1
View File
@@ -71,6 +71,7 @@ final class Notifier implements INotifier {
$who = (string)($p['actor'] ?? ''); $who = (string)($p['actor'] ?? '');
$notification->setParsedSubject(match ($p['event'] ?? '') { $notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]), 'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'key_replaced' => $l->t('%1$s replaced the unused backup key %2$s by the key of existing backups, %3$s; download and confirm the new recovery kit', [$who, $p['old'] ?? '', $p['new'] ?? '']),
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']), 'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']), 'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
'schedule_off' => $l->t('%1$s turned off the schedule of backup location %2$s', [$who, $p['target'] ?? '']), 'schedule_off' => $l->t('%1$s turned off the schedule of backup location %2$s', [$who, $p['target'] ?? '']),
+116 -8
View File
@@ -18,6 +18,7 @@ use OCA\NgBackup\Crypto\StreamCipher;
* *
* Layout on the backend: * Layout on the backend:
* config plaintext JSON: format version, repository id, wrapped master key * config plaintext JSON: format version, repository id, wrapped master key
* settings encrypted JSON: settings fixed at installation (deletion delay)
* packs/xx/<packId> encrypted blobs + encrypted pack header * packs/xx/<packId> encrypted blobs + encrypted pack header
* index/<packId> encrypted list of the blobs in that pack * index/<packId> encrypted list of the blobs in that pack
* snapshots/<snapId> encrypted JSON lines: one meta line, then one line per file * snapshots/<snapId> encrypted JSON lines: one meta line, then one line per file
@@ -28,6 +29,7 @@ use OCA\NgBackup\Crypto\StreamCipher;
final class Repository { final class Repository {
public const FORMAT = 1; public const FORMAT = 1;
public const BLOB_SIZE = 4 * 1048576; public const BLOB_SIZE = 4 * 1048576;
private const SETTINGS = 'settings';
private const FLAG_DEFLATE = 1; private const FLAG_DEFLATE = 1;
/** Blobs from this size on get a quick compression test before the full compression. */ /** Blobs from this size on get a quick compression test before the full compression. */
private const DEFLATE_PROBE_FROM = 256 * 1024; private const DEFLATE_PROBE_FROM = 256 * 1024;
@@ -79,7 +81,7 @@ final class Repository {
// opens this data; refuse instead. // opens this data; refuse instead.
foreach (['index', 'snapshots', 'trash/snapshots', 'catalog'] as $dir) { foreach (['index', 'snapshots', 'trash/snapshots', 'catalog'] as $dir) {
if ($backend->listFiles($dir) !== []) { if ($backend->listFiles($dir) !== []) {
throw new RepositoryException("This location holds backup data ($dir/) but no repository config could be read: not creating a new repository over it. Check the connection and the folder; a missing config can be restored from the recovery kit."); throw new RepositoryException("This location holds backup data ($dir/) but no repository config could be read: not creating a new repository over it. Check the connection and the folder. If the config is really gone, occ backup:target:add with --repair-config writes it again with this server's key (on a new server: import the recovery kit first, occ backup:key:import).");
} }
} }
$id = bin2hex(random_bytes(16)); $id = bin2hex(random_bytes(16));
@@ -95,12 +97,7 @@ final class Repository {
/** @param ?callable(int, int): void $progress index files downloaded so far, and in all (see BlobIndex::load()) */ /** @param ?callable(int, int): void $progress index files downloaded so far, and in all (see BlobIndex::load()) */
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null, ?callable $progress = null): self { public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null, ?callable $progress = null): self {
$config = self::readConfig($backend); $config = self::readConfig($backend);
try { if (!self::configMatches($config, $keys)) {
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
} catch (\OCA\NgBackup\Crypto\CryptoException) {
$ok = false;
}
if (!$ok) {
throw new RepositoryException('This repository was created with a different key'); throw new RepositoryException('This repository was created with a different key');
} }
$repo = new self($backend, $keys, $config['id']); $repo = new self($backend, $keys, $config['id']);
@@ -196,6 +193,114 @@ final class Repository {
return $config; return $config;
} }
/**
* Open a local file to read it in whole blobs. PHP reads a file in pieces of 8 KiB by default;
* on a disk where each request waits (a network or cloud volume) that made reading five times
* slower: 5 MiB/s instead of 27 MiB/s on nc01. Without PHP's read buffer every fread() of a
* blob is one request to the disk.
*
* @return resource|false
*/
public static function openForReading(string $path) {
$fh = @fopen($path, 'rb');
if ($fh !== false) {
stream_set_read_buffer($fh, 0);
stream_set_chunk_size($fh, self::BLOB_SIZE);
}
return $fh;
}
private static function configMatches(array $config, KeyRing $keys): bool {
try {
return isset($config['check'], $config['id'])
&& (new StreamCipher($keys))->decryptString((string)base64_decode((string)$config['check'], true), 'config-check') === $config['id'];
} catch (\OCA\NgBackup\Crypto\CryptoException) {
return false;
}
}
/**
* The passphrase-wrapped key slots in the config at this location: disaster recovery without
* the recovery kit, where any one of their passphrases gives back the master key.
*/
public static function wrappedKeyAt(IBackend $backend): array {
$config = self::readConfig($backend);
if (!isset($config['key']) || !is_array($config['key'])) {
throw new RepositoryException('The repository config at this location holds no key');
}
return $config['key'];
}
/** Whether the repository at this location was created with this key. Does not read the index. */
public static function madeWithKey(IBackend $backend, KeyRing $keys): bool {
return self::configMatches(self::readConfig($backend), $keys);
}
/**
* Settings of the installation that never change (the deletion delay), sealed with the
* master key so that, unlike the config and the recovery kit, nobody can alter them unseen.
* Empty when the location has none yet (made before 1.1.0, until its next backup) or they
* cannot be read.
*
* @return array<string, mixed>
*/
public static function settingsAt(IBackend $backend, KeyRing $keys): array {
try {
if (!$backend->exists(self::SETTINGS)) {
return [];
}
$fh = $backend->get(self::SETTINGS);
$raw = (string)stream_get_contents($fh);
fclose($fh);
$settings = json_decode((new StreamCipher($keys))->decryptString($raw, 'settings'), true, 16, JSON_THROW_ON_ERROR);
return is_array($settings) ? $settings : [];
} catch (BackendException|\OCA\NgBackup\Crypto\CryptoException|\JsonException) {
return [];
}
}
/**
* Write the settings once; a location that has them keeps them. The repository id goes with
* them: with it and the key from the recovery kit, a lost config can be written again.
*/
public function recordSettings(array $settings): void {
if ($this->backend->exists(self::SETTINGS)) {
return;
}
$settings['repository'] = $this->repositoryId;
self::putString($this->backend, self::SETTINGS, $this->cipher->encryptString(json_encode($settings, JSON_THROW_ON_ERROR), 'settings'));
}
/** Whether the location holds backup data, with or without a config. */
public static function holdsData(IBackend $backend): bool {
foreach (['index', 'snapshots', 'catalog'] as $dir) {
if ($backend->listFiles($dir) !== []) {
return true;
}
}
return false;
}
/**
* Write a lost config again, from the key (recovery kit) and the repository id in the
* sealed settings. Only where there is no config and the settings open with this key, so
* nothing that works is ever overwritten and a wrong key writes nothing.
*/
public static function repairConfig(IBackend $backend, KeyRing $keys, array $wrappedKey): void {
if ($backend->exists('config')) {
throw new RepositoryException('This location has a config: nothing to repair, leave out --repair-config');
}
$id = self::settingsAt($backend, $keys)['repository'] ?? null;
if (!is_string($id) || !preg_match('/^[0-9a-f]{32}$/', $id)) {
throw new RepositoryException($backend->exists(self::SETTINGS)
? 'The settings at this location do not open with this key: the recovery kit is of another server, or the settings are damaged. The config cannot be written again.'
: 'This location has no settings file (it was backed up only by NG Backup before 1.1.0), so its config cannot be written again.');
}
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check')), 'repaired' => true];
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
/** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */ /** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */
public function updateWrappedKey(array $wrappedKey): void { public function updateWrappedKey(array $wrappedKey): void {
$config = self::readConfig($this->backend); $config = self::readConfig($this->backend);
@@ -1060,7 +1165,10 @@ final class Repository {
for ($attempt = 1; $attempt <= 3; $attempt++) { for ($attempt = 1; $attempt <= 3; $attempt++) {
$before = self::fileVersion($path); $before = self::fileVersion($path);
$blobs = []; $blobs = [];
$fh = fopen($path, 'rb'); $fh = self::openForReading($path);
if ($fh === false) {
throw new RepositoryException('Cannot read ' . $path);
}
while (($data = self::readFull($fh, self::BLOB_SIZE)) !== '') { while (($data = self::readFull($fh, self::BLOB_SIZE)) !== '') {
$blobs[] = $this->storeBlob($data, $packs, $stats); $blobs[] = $this->storeBlob($data, $packs, $stats);
} }
+2
View File
@@ -225,6 +225,8 @@ final class BackupService {
if (!isset($state['dump'])) { if (!isset($state['dump'])) {
// It may have been queued for hours: the restore point has the time of its data. // It may have been queued for hours: the restore point has the time of its data.
$state['files']['started'] = gmdate('c'); $state['files']['started'] = gmdate('c');
// Locations from before 1.1.0 get their sealed settings at their next backup.
$this->targets->recordSettings($repo);
} }
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_')); $dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
$dbStats = $state['dbStats'] ?? []; $dbStats = $state['dbStats'] ?? [];
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAppConfig;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
/**
* Keeps heavy background jobs of other apps from running while a backup reads the same disks, by
* reserving them in Nextcloud's job list: no app is turned off, and a job that is running goes on.
* Nextcloud itself gives up on a reservation after 12 hours, so a pause that is never ended, because
* cron or this app is gone, ends by itself. It lasts at most MAX_SECONDS, then the jobs get REST_SECONDS.
*/
final class HeavyJobPause {
public const ENABLED_KEY = 'pause_heavy_jobs';
private const SINCE_KEY = 'heavy_jobs_paused_since';
private const REST_KEY = 'heavy_jobs_rest_until';
public const MAX_SECONDS = 6 * 3600;
public const REST_SECONDS = 3600;
/** Nextcloud runs a job again this long after it was reserved. */
private const RESERVATION_SECONDS = 12 * 3600;
/** Class, or the start of a class name ending in a backslash => what to call it */
private const JOBS = [
'OCA\\Memories\\' => 'Memories',
'OCA\\Files_Antivirus\\BackgroundJob\\BackgroundScanner' => 'Antivirus background scan',
'OCA\\Recognize\\' => 'Recognize',
'OCA\\PreviewGenerator\\' => 'Preview Generator',
'OCA\\FullTextSearch\\' => 'Full text search',
];
public const NOTHING = 'nothing';
public const PAUSE = 'pause';
public const RELEASE = 'release';
public const REST = 'rest';
public function __construct(
private IDBConnection $db,
private IAppConfig $appConfig,
private LoggerInterface $logger,
) {
}
/** @return self::NOTHING|self::PAUSE|self::RELEASE|self::REST */
public static function decide(bool $enabled, bool $backupRunning, int $since, int $restUntil, int $now): string {
if ($since > 0 && $now - $since >= self::MAX_SECONDS) {
return self::REST;
}
if ($enabled && $backupRunning && $now >= $restUntil) {
return self::PAUSE;
}
return $since > 0 ? self::RELEASE : self::NOTHING;
}
/** Call regularly: pauses or ends the pause as needed. A problem here never stops a backup. */
public function sync(bool $backupRunning): void {
try {
$this->apply($backupRunning, time());
} catch (\Throwable $e) {
$this->logger->warning('NG Backup: pausing heavy background jobs failed: ' . $e->getMessage(), ['exception' => $e]);
}
}
private function apply(bool $backupRunning, int $now): void {
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
$restUntil = $this->appConfig->getValueInt(Application::APP_ID, self::REST_KEY, 0);
switch (self::decide($this->enabled(), $backupRunning, $since, $restUntil, $now)) {
case self::PAUSE:
if ($since === 0) {
$since = $now;
$this->appConfig->setValueInt(Application::APP_ID, self::SINCE_KEY, $since);
$this->logger->info('NG Backup: heavy background jobs paused while a backup runs');
}
$this->reserve($since, $now); // also the jobs that were added or running until now
break;
case self::REST:
$this->appConfig->setValueInt(Application::APP_ID, self::REST_KEY, $now + self::REST_SECONDS);
$this->release($since);
$this->logger->info('NG Backup: heavy background jobs get their turn for an hour during a long backup');
break;
case self::RELEASE:
$this->release($since);
$this->logger->info('NG Backup: heavy background jobs run again');
break;
}
}
private function reserve(int $stamp, int $now): void {
$qb = $this->db->getQueryBuilder();
$qb->update('jobs')
->set('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT))
->where($qb->expr()->lte('reserved_at', $qb->createNamedParameter($now - self::RESERVATION_SECONDS, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$qb->executeStatement();
}
/** Only our own reservations: a job that Nextcloud reserved at another moment is running. */
private function release(int $stamp): void {
$qb = $this->db->getQueryBuilder();
$qb->update('jobs')
->set('reserved_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$qb->executeStatement();
$this->appConfig->deleteKey(Application::APP_ID, self::SINCE_KEY);
}
/** @return \OCP\DB\QueryBuilder\ICompositeExpression */
private function isHeavy(IQueryBuilder $qb) {
$conditions = [];
foreach (array_keys(self::JOBS) as $class) {
$conditions[] = str_ends_with($class, '\\')
? $qb->expr()->like('class', $qb->createNamedParameter($this->db->escapeLikeParameter($class) . '%'))
: $qb->expr()->eq('class', $qb->createNamedParameter($class));
}
return $qb->expr()->orX(...$conditions);
}
public function enabled(): bool {
return $this->appConfig->getValueString(Application::APP_ID, self::ENABLED_KEY, '1') !== '0';
}
public function setEnabled(bool $enabled): void {
$this->appConfig->setValueString(Application::APP_ID, self::ENABLED_KEY, $enabled ? '1' : '0');
}
/** @return array{enabled: bool, since: int, jobs: list<string>} what is paused now */
public function status(): array {
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
$jobs = [];
if ($since > 0) {
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('class')->from('jobs')
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
->andWhere($this->isHeavy($qb));
$result = $qb->executeQuery();
while (($class = $result->fetchOne()) !== false) {
foreach (self::JOBS as $start => $name) {
if (str_starts_with((string)$class, $start)) {
$jobs[$name] = true;
}
}
}
$result->closeCursor();
}
return ['enabled' => $this->enabled(), 'since' => $since, 'jobs' => array_map('strval', array_keys($jobs))];
}
}
+66 -10
View File
@@ -30,6 +30,8 @@ final class KeyService {
private const KEY_DELETE_DELAY = 'delete_delay_days'; private const KEY_DELETE_DELAY = 'delete_delay_days';
private const KEY_KIT_VERSION = 'recovery_kit_version'; private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code'; private const KEY_KIT_CODE = 'recovery_kit_code';
/** Set once a location was created or adopted with this key: from then on it is never replaced. */
private const KEY_USED = 'master_key_used';
public const DEFAULT_DELETE_DELAY = 7; public const DEFAULT_DELETE_DELAY = 7;
/** /**
* The location holds the passphrase-wrapped key too, so whoever can read it can try * The location holds the passphrase-wrapped key too, so whoever can read it can try
@@ -83,28 +85,82 @@ final class KeyService {
* @param array<string, mixed> $kit as produced by recoveryKit() * @param array<string, mixed> $kit as produced by recoveryKit()
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase, or a damaged/foreign kit * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase, or a damaged/foreign kit
*/ */
public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase): void { public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase, bool $replaceUnused = false): void {
if ($this->isInitialized()) { if ($this->isInitialized() && !$replaceUnused) {
throw new \RuntimeException('A backup key already exists'); throw new \RuntimeException('A backup key already exists');
} }
if (!isset($kit['wrapped_key'])) { if (!isset($kit['wrapped_key'])) {
throw new \InvalidArgumentException('Not a NG Backup recovery kit file'); throw new \InvalidArgumentException('Not a NG Backup recovery kit file');
} }
$keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase); $keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, $this->discardUnused($keys, $replaceUnused);
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
$this->cached = $keys;
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($kit['wrapped_key'], JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
// Kits from before this field existed fall back to the default. // Kits from before this field existed fall back to the default.
$delay = (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY); $this->store($keys, $kit['wrapped_key'], (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY), (int)($kit['kit_version'] ?? 1));
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, ($delay >= 1 && $delay <= 365) ? $delay : self::DEFAULT_DELETE_DELAY, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, (int)($kit['kit_version'] ?? 1), true);
// Having and successfully using a real kit file is stronger proof than the normal // Having and successfully using a real kit file is stronger proof than the normal
// download-and-confirm flow it replaces. // download-and-confirm flow it replaces.
$this->confirmRecoveryKit('cli-import'); $this->confirmRecoveryKit('cli-import');
} }
/**
* Disaster recovery without the recovery kit: adopt the master key that a passphrase opened
* from the key slots in a location's config (TargetService::addWithPassphrase). There is no
* kit, so none is confirmed: backups wait until a new kit is downloaded and confirmed.
*
* @param ?int $deleteDelayDays from the location's sealed settings, null when it has none
*/
public function adoptKey(KeyRing $keys, array $wrappedKey, ?int $deleteDelayDays, bool $replaceUnused = false): void {
if ($this->isInitialized() && !$replaceUnused) {
throw new \RuntimeException('A backup key already exists');
}
$this->discardUnused($keys, $replaceUnused);
$this->store($keys, $wrappedKey, $deleteDelayDays ?? self::DEFAULT_DELETE_DELAY, 1);
}
/** Whether a location was ever created or adopted with this key (TargetService::keyIsUnused() also looks at the database). */
public function wasUsed(): bool {
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_USED, false, true);
}
public function markUsed(): void {
if (!$this->wasUsed()) {
$this->appConfig->setValueBool(Application::APP_ID, self::KEY_USED, true, true);
}
}
/**
* A key made by mistake on a server that is being restored: remove it, so the key of the
* backups can take its place. Only after the new key was opened with its passphrase, and
* only when the caller checked that the old key never protected anything
* (TargetService::keyIsUnused()). Its passphrases and recovery kit become worthless.
*/
private function discardUnused(KeyRing $replacement, bool $replaceUnused): void {
if (!$replaceUnused || !$this->isInitialized()) {
return;
}
if ($this->wasUsed()) {
throw new \RuntimeException('The backup key of this server has been used for a backup location: it is never replaced');
}
if ($this->fingerprintOf($replacement) === $this->fingerprint()) {
throw new \RuntimeException('This server already has this backup key: nothing to replace');
}
foreach ([self::KEY_MASTER, self::KEY_WRAPPED, self::KEY_CONFIRMED, self::KEY_FINGERPRINT, self::KEY_DELETE_DELAY,
self::KEY_KIT_VERSION, self::KEY_KIT_CODE, self::KEY_USED] as $key) {
$this->appConfig->deleteKey(Application::APP_ID, $key);
}
$this->cached = null;
}
private function store(KeyRing $keys, array $wrappedKey, int $deleteDelayDays, int $kitVersion): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
$this->cached = $keys;
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($wrappedKey, JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY,
($deleteDelayDays >= 1 && $deleteDelayDays <= 365) ? $deleteDelayDays : self::DEFAULT_DELETE_DELAY, true);
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, max(1, $kitVersion), true);
}
/** /**
* The master key and the recovery kit code are stored encrypted with Nextcloud's own * The master key and the recovery kit code are stored encrypted with Nextcloud's own
* instance secret (ICrypto), not with themselves; the wrapped key is not (it is sealed with * instance secret (ICrypto), not with themselves; the wrapped key is not (it is sealed with
+126 -8
View File
@@ -36,6 +36,7 @@ final class TargetService {
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor, private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
private SnapshotMapper $snapshots, private SnapshotMapper $snapshots,
private AlertService $alerts, private AlertService $alerts,
private \OCA\NgBackup\Db\RunMapper $runs,
) { ) {
} }
@@ -52,18 +53,31 @@ final class TargetService {
* @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all * @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all
* @return array{target: Target, created: bool} * @return array{target: Target, created: bool}
*/ */
public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null): array { public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null, bool $repairConfig = false): array {
$this->validateName($name); $this->validateName($name);
$this->requireUsableBackend($backend); $this->requireUsableBackend($backend);
$keys = $this->keys->keyRing(); $keys = $this->keys->keyRing();
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath); $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, !$repairConfig);
if ($storageBackend->exists('config')) { try {
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress); if ($repairConfig) {
$created = false; Repository::repairConfig($storageBackend, $keys, $this->keys->wrappedKey());
} else { }
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor); if ($storageBackend->exists('config')) {
$created = true; $repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress);
$created = false;
} else {
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
$created = true;
}
} catch (\OCA\NgBackup\Repository\RepositoryException $e) {
$otherKey = str_contains($e->getMessage(), 'different key') || str_contains($e->getMessage(), 'do not open with this key');
if ($otherKey && $this->keyIsUnused()) {
throw new \OCA\NgBackup\Repository\RepositoryException(rtrim($e->getMessage(), '.') . '. ' . self::UNUSED_KEY_HINT, 0, $e);
}
throw $e;
} }
$this->keys->markUsed();
$this->recordSettings($repo);
foreach ($this->mapper->findAll() as $existing) { foreach ($this->mapper->findAll() as $existing) {
if ($existing->getRepositoryId() === $repo->id()) { if ($existing->getRepositoryId() === $repo->id()) {
@@ -93,6 +107,110 @@ final class TargetService {
return ['target' => $target, 'created' => $created]; return ['target' => $target, 'created' => $created];
} }
public const UNUSED_KEY_HINT = 'The backup key of this server was never used (no location, no backup), so it can make way for the key of these backups: '
. 'with the old server\'s recovery kit, occ backup:key:import <kit file> --replace-unused-key; without the kit, '
. 'occ backup:target:add with the same details and --replace-unused-key (asks for a passphrase of the old server)';
/**
* A key that never protected anything: no location was created or adopted with it, and the
* database has no location and no run (installations from before the flag existed). Only such
* a key may make way for the key of existing backups, e.g. after a new key was made by
* mistake on a server that is being restored.
*/
public function keyIsUnused(): bool {
return $this->keys->isInitialized() && !$this->keys->wasUsed()
&& $this->mapper->findAll() === [] && $this->runs->countAll() === 0;
}
/**
* occ backup:key:import on a server whose own key was never used: the kit's key takes its place.
*
* @param array<string, mixed> $kit
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase: nothing changed
*/
public function importKitReplacingUnusedKey(array $kit, #[\SensitiveParameter] string $passphrase): void {
$old = $this->requireUnusedKey();
$this->keys->importRecoveryKit($kit, $passphrase, true);
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
}
/** @return string the fingerprint of the unused key */
private function requireUnusedKey(): string {
if (!$this->keys->isInitialized()) {
throw new \RuntimeException('There is no backup key here to replace');
}
if (!$this->keyIsUnused()) {
throw new \RuntimeException('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced');
}
return $this->keys->fingerprint();
}
public const LOCATION_REPOSITORY = 'repository';
public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config';
public const LOCATION_EMPTY = 'empty';
/**
* What these settings lead to: a repository, backup data whose config is gone, or nothing
* of NG Backup. Creates nothing.
*
* @return self::LOCATION_*
*/
public function locationState(string $backend, string $auth, array $options, string $basePath): string {
$this->requireUsableBackend($backend);
$storage = $this->buildBackend($backend, $auth, $options, $basePath, false);
if ($storage->exists('config')) {
return self::LOCATION_REPOSITORY;
}
return Repository::holdsData($storage) ? self::LOCATION_DATA_WITHOUT_CONFIG : self::LOCATION_EMPTY;
}
/**
* Disaster recovery without the recovery kit, on an installation without a backup key: take
* the key from the location's config with one of its passphrases, then add the location as
* usual. The key is only kept when it really is the one the repository was made with.
* With $replaceUnusedKey, a key this server has but never used makes way (keyIsUnused()).
*
* @param array<string, mixed> $options
* @param ?callable(int, int): void $progress
* @return array{target: Target, created: bool, deleteDelayDays: ?int} the deletion delay the location had stored, if any
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
*/
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
#[\SensitiveParameter] string $passphrase, ?callable $progress = null, bool $replaceUnusedKey = false): array {
$old = null;
if ($replaceUnusedKey) {
$old = $this->requireUnusedKey();
} elseif ($this->keys->isInitialized()) {
throw new \RuntimeException('A backup key already exists here');
}
$this->validateName($name);
$this->requireUsableBackend($backend);
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
if (!$storageBackend->exists('config')) {
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'");
}
$wrapped = Repository::wrappedKeyAt($storageBackend);
$keys = \OCA\NgBackup\Crypto\KeyRing::unwrapAny($wrapped, $passphrase);
if (!Repository::madeWithKey($storageBackend, $keys)) {
throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import <kit file>, then this command again.');
}
$delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null;
$delay = is_int($delay) ? $delay : null;
$this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey);
if ($old !== null) {
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
}
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay];
}
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
public function recordSettings(Repository $repo): void {
try {
$repo->recordSettings(['delete_delay_days' => $this->keys->deleteDelayDays()]);
} catch (\Throwable) {
}
}
/** /**
* Change how an existing location is reached (credentials, host, folder, even the connection * Change how an existing location is reached (credentials, host, folder, even the connection
* type) or its name. The new settings must lead to the same repository: the history stays * type) or its name. The new settings must lead to the same repository: the history stays
-1
View File
@@ -367,7 +367,6 @@
<code><![CDATA[$encrypted]]></code> <code><![CDATA[$encrypted]]></code>
<code><![CDATA[$encrypted]]></code> <code><![CDATA[$encrypted]]></code>
<code><![CDATA[$encrypted]]></code> <code><![CDATA[$encrypted]]></code>
<code><![CDATA[$fh]]></code>
<code><![CDATA[$plain]]></code> <code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code> <code><![CDATA[$plain]]></code>
<code><![CDATA[$plain]]></code> <code><![CDATA[$plain]]></code>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 172 KiB

+1
View File
@@ -34,6 +34,7 @@ export default {
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }), setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }), startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
tick: () => call('post', '/tick'), tick: () => call('post', '/tick'),
setPauseHeavyJobs: (enabled) => call('put', '/settings/pause-heavy-jobs', { enabled }),
setTargetSchedule: (id, schedule, timezone) => call('put', `/targets/${id}/schedule`, { schedule, timezone }), setTargetSchedule: (id, schedule, timezone) => call('put', `/targets/${id}/schedule`, { schedule, timezone }),
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`), snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }), setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }),
+24 -6
View File
@@ -6,6 +6,10 @@
<NcSettingsSection :name="t('ng_backup', 'Backups')" <NcSettingsSection :name="t('ng_backup', 'Backups')"
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')"> :description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
<LocationScheduleRow v-for="target in status.targets" :key="target.id" :target="target" @changed="emit('changed')" @error="error = $event" /> <LocationScheduleRow v-for="target in status.targets" :key="target.id" :target="target" @changed="emit('changed')" @error="error = $event" />
<NcCheckboxRadioSwitch :model-value="status.heavyJobs.enabled" @update:model-value="savePause">
{{ t('ng_backup', 'Pause heavy background jobs (Memories, Recognize, Preview Generator, full text search, antivirus scan) while a backup runs, at most 6 hours in a row') }}
</NcCheckboxRadioSwitch>
<p v-if="status.heavyJobs.jobs.length" class="ngb-muted">{{ t('ng_backup', 'Paused now: {jobs}', { jobs: status.heavyJobs.jobs.join(', ') }) }}</p>
<div class="ngb-row"> <div class="ngb-row">
<NcTextField v-model="label" class="ngb-label" :maxlength="100" :label="t('ng_backup', 'Label (optional)')" <NcTextField v-model="label" class="ngb-label" :maxlength="100" :label="t('ng_backup', 'Label (optional)')"
:placeholder="t('ng_backup', 'e.g. before the upgrade to Nextcloud 35')" /> :placeholder="t('ng_backup', 'e.g. before the upgrade to Nextcloud 35')" />
@@ -34,11 +38,11 @@
<td>{{ formatDateTime(run.startedAt) }}</td> <td>{{ formatDateTime(run.startedAt) }}</td>
<td>{{ targetName(run.targetId) }}</td> <td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td> <td>{{ run.kind }}</td>
<td>{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td> <td class="ngb-point">{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td> <td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td> <td class="ngb-num">{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td> <td class="ngb-num">{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td> <td class="ngb-num">{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -60,6 +64,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { n, t } from '@nextcloud/l10n' import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton' import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField' import NcTextField from '@nextcloud/vue/components/NcTextField'
@@ -101,6 +106,16 @@ const runStatus = (run) => {
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running') const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
async function savePause(enabled) {
error.value = ''
try {
await api.setPauseHeavyJobs(enabled)
emit('changed')
} catch (e) {
error.value = e.message
}
}
async function start(target) { async function start(target) {
error.value = '' error.value = ''
try { try {
@@ -116,9 +131,12 @@ async function start(target) {
<style scoped> <style scoped>
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; } .ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
.ngb-label { max-width: 360px; } .ngb-label { max-width: 360px; }
.ngb-table-wrap { overflow-x: auto; } .ngb-table-wrap { max-width: 90%; overflow-x: auto; } /* scrolls only on a really narrow screen */
.ngb-table { width: 100%; border-collapse: collapse; } .ngb-table { width: 100%; border-collapse: collapse; }
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); } .ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); white-space: normal; overflow-wrap: break-word; }
/* Text wraps; numbers stay on one line, the restore point keeps room for its label and note */
.ngb-table td.ngb-num { white-space: nowrap; }
.ngb-table td.ngb-point { min-width: 9em; }
.ngb-failed { color: var(--color-error-text); } .ngb-failed { color: var(--color-error-text); }
.ngb-muted { color: var(--color-text-maxcontrast); } .ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-done { color: var(--color-success-text); } .ngb-done { color: var(--color-success-text); }
+4 -1
View File
@@ -4,8 +4,11 @@
--> -->
<template> <template>
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')" <NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups. To restore on a new server you need the recovery kit and one of the passphrases.')"> :description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups. To restore on a new server you need one of the passphrases; the recovery kit also helps when the key on the backup location is damaged.')">
<!-- 1. Create the key --> <!-- 1. Create the key -->
<NcNoteCard v-if="!status.key.initialized" type="warning">
{{ t('ng_backup', 'Restoring a server after a crash? Do not create a key here: a new key cannot read your backups. Follow the disaster recovery guide instead (occ backup:target:add asks for one of your passphrases, or import the recovery kit with occ backup:key:import).') }}
</NcNoteCard>
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey"> <form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" /> <NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" /> <NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\CryptoException;
use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
/** Disaster recovery without the recovery kit: the location gives its key with a passphrase. */
class KeyFromLocationTest extends TestCase {
use TempDirTrait;
private string $dir;
private LocalBackend $backend;
private KeyRing $keys;
protected function setUp(): void {
$this->dir = $this->tempDir();
$this->backend = new LocalBackend($this->dir);
$this->keys = KeyRing::generate();
$slots = ['slots' => [
['slot' => 1, 'label' => 'A', 'created' => 0, 'key' => $this->keys->wrap('first passphrase')],
['slot' => 2, 'label' => 'B', 'created' => 0, 'key' => $this->keys->wrap('second passphrase')],
]];
Repository::initWithKey($this->backend, $this->keys, $slots)->recordSettings(['delete_delay_days' => 30]);
}
public function testEachPassphraseGivesTheKeyThatOpensTheBackups(): void {
foreach (['first passphrase', 'second passphrase'] as $passphrase) {
$keys = KeyRing::unwrapAny(Repository::wrappedKeyAt($this->backend), $passphrase);
$this->assertTrue(Repository::madeWithKey($this->backend, $keys));
$this->assertSame($this->keys->exportMasterKey(), $keys->exportMasterKey());
}
}
public function testAWrongPassphraseGivesNothing(): void {
$this->expectException(CryptoException::class);
KeyRing::unwrapAny(Repository::wrappedKeyAt($this->backend), 'not the passphrase');
}
public function testAnotherKeyIsNotTakenForTheRightOne(): void {
$this->assertFalse(Repository::madeWithKey($this->backend, KeyRing::generate()));
}
public function testTheSettingsAreSealedAndWrittenOnce(): void {
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
Repository::openWithKey($this->backend, $this->keys)->recordSettings(['delete_delay_days' => 1]);
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
$this->assertSame([], Repository::settingsAt($this->backend, KeyRing::generate()));
}
public function testAlteredSettingsAreNotBelieved(): void {
$file = $this->dir . '/settings';
$raw = file_get_contents($file);
$raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01";
file_put_contents($file, $raw);
$this->assertSame([], Repository::settingsAt($this->backend, $this->keys));
}
public function testTheKitWritesALostConfigAgainAndTheBackupsOpen(): void {
$src = $this->tempDir();
file_put_contents("$src/a", 'still here');
$repo = Repository::openWithKey($this->backend, $this->keys);
$id = $repo->id();
$snapshot = $repo->backupDirectory($src)['snapshot'];
unlink($this->dir . '/config');
$this->assertTrue(Repository::holdsData($this->backend));
Repository::repairConfig($this->backend, $this->keys, $this->keys->wrap('kit passphrase'));
$repo = Repository::openWithKey($this->backend, $this->keys);
$this->assertSame($id, $repo->id());
$this->assertSame([$snapshot], $repo->listSnapshots());
$out = $this->tempDir();
$repo->restore($snapshot, $out);
$this->assertSame('still here', file_get_contents("$out/a"));
$this->assertSame($this->keys->exportMasterKey(), KeyRing::unwrapAny(Repository::wrappedKeyAt($this->backend), 'kit passphrase')->exportMasterKey());
}
public function testAnotherKeyWritesNoConfig(): void {
unlink($this->dir . '/config');
$other = KeyRing::generate();
try {
Repository::repairConfig($this->backend, $other, $other->wrap('x'));
$this->fail('a config was written with another key');
} catch (\OCA\NgBackup\Repository\RepositoryException $e) {
$this->assertStringContainsString('another server', $e->getMessage());
}
$this->assertFileDoesNotExist($this->dir . '/config');
}
public function testAConfigThatIsThereIsNeverRepaired(): void {
$before = file_get_contents($this->dir . '/config');
try {
Repository::repairConfig($this->backend, $this->keys, $this->keys->wrap('x'));
$this->fail('an existing config was replaced');
} catch (\OCA\NgBackup\Repository\RepositoryException) {
}
$this->assertSame($before, file_get_contents($this->dir . '/config'));
}
public function testALocationWithoutSettingsHasNone(): void {
$other = new LocalBackend($this->tempDir());
Repository::initWithKey($other, $this->keys, $this->keys->wrap('x'));
$this->assertSame([], Repository::settingsAt($other, $this->keys));
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Service;
use OCA\NgBackup\Service\HeavyJobPause as P;
use PHPUnit\Framework\TestCase;
class HeavyJobPauseTest extends TestCase {
private const NOW = 1000000;
public function testDuringABackupTheyArePaused(): void {
$this->assertSame(P::PAUSE, P::decide(true, true, 0, 0, self::NOW));
$this->assertSame(P::PAUSE, P::decide(true, true, self::NOW - 60, 0, self::NOW), 'jobs added since then too');
}
public function testWithoutABackupNothingHappensOrThePauseEnds(): void {
$this->assertSame(P::NOTHING, P::decide(true, false, 0, 0, self::NOW));
$this->assertSame(P::RELEASE, P::decide(true, false, self::NOW - 60, 0, self::NOW));
}
public function testTurningItOffEndsThePause(): void {
$this->assertSame(P::NOTHING, P::decide(false, true, 0, 0, self::NOW));
$this->assertSame(P::RELEASE, P::decide(false, true, self::NOW - 60, 0, self::NOW));
}
public function testAfterSixHoursTheyGetTheirTurnAndThenPauseAgain(): void {
$this->assertSame(P::PAUSE, P::decide(true, true, self::NOW - P::MAX_SECONDS + 1, 0, self::NOW));
$this->assertSame(P::REST, P::decide(true, true, self::NOW - P::MAX_SECONDS, 0, self::NOW));
$rest = self::NOW + P::REST_SECONDS;
$this->assertSame(P::NOTHING, P::decide(true, true, 0, $rest, $rest - 1));
$this->assertSame(P::PAUSE, P::decide(true, true, 0, $rest, $rest));
}
}
+39 -5
View File
@@ -2,8 +2,10 @@
# Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a # Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a
# SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup # SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup
# location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7. # location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7.
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|salvage|salvage-files|to-pgsql|to-mysql|to-sqlite] # Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|salvage|salvage-files|to-pgsql|to-mysql|to-sqlite|no-kit]
# e.g. full-restore-roundtrip.sh 35-apache pgsql # e.g. full-restore-roundtrip.sh 35-apache pgsql
# With "no-kit" the recovery kit is gone: backup:target:add takes the key from the location with
# the passphrase, and backups wait until a new kit is confirmed.
# With "salvage-files" one pack is damaged and another is gone from the location. The restore must # With "salvage-files" one pack is damaged and another is gone from the location. The restore must
# stop and point at --salvage; run again as the guide says, it restores the database and every file # stop and point at --salvage; run again as the guide says, it restores the database and every file
# that can be read, and lists the rest (exit 2). NGB_FRT_DATA_MB sets the amount of data (default 200). # that can be read, and lists the rest (exit 2). NGB_FRT_DATA_MB sets the amount of data (default 200).
@@ -93,7 +95,10 @@ run_on ngb-frt-origin app:enable ng_backup >/dev/null
ok "origin instance up ($DB${LEGACY:+, NG Backup 0.9.7})" ok "origin instance up ($DB${LEGACY:+, NG Backup 0.9.7})"
docker exec ngb-frt-origin sh -c "printf 'full restore roundtrip passphrase 2026\n' > /tmp/pass" docker exec ngb-frt-origin sh -c "printf 'full restore roundtrip passphrase 2026\n' > /tmp/pass"
run_on ngb-frt-origin backup:key:init --passphrase-file=/tmp/pass --label=CI >/dev/null || fail "key init" DELAY_OPT=()
[ "$MODE" = no-kit ] && DELAY_OPT=(--delete-delay-days=21)
run_on ngb-frt-origin backup:key:init --passphrase-file=/tmp/pass --label=CI "${DELAY_OPT[@]}" >/dev/null || fail "key init"
ORIGIN_FP=$(run_on ngb-frt-origin backup:status | grep -o 'Key: [0-9a-f-]*' | cut -d' ' -f2)
run_on ngb-frt-origin backup:key:kit --output=/tmp/kit.json >/dev/null run_on ngb-frt-origin backup:key:kit --output=/tmp/kit.json >/dev/null
CODE=$(run_on ngb-frt-origin backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];') CODE=$(run_on ngb-frt-origin backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
run_on ngb-frt-origin backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm" run_on ngb-frt-origin backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
@@ -187,8 +192,29 @@ docker exec ngb-frt-restore chmod 644 /tmp/kit.json
docker cp /tmp/ngb-frt-pass ngb-frt-restore:/tmp/pass docker cp /tmp/ngb-frt-pass ngb-frt-restore:/tmp/pass
ok "fresh restore instance up" ok "fresh restore instance up"
run_on ngb-frt-restore backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass >/dev/null || fail "key import" if [ "$MODE" = no-kit ]; then
ADD_OUT=$(run_on ngb-frt-restore backup:target:add offsite "${LOCATION[@]}" --path=repo 2>&1) || { printf '%s\n' "$ADD_OUT"; fail "target add on the restore instance"; } # No recovery kit: backup:target:add takes the key from the location with a passphrase
docker exec ngb-frt-restore rm -f /tmp/kit.json
docker exec ngb-frt-restore sh -c "printf 'not the passphrase of the old server\n' > /tmp/wrongpass && chmod 644 /tmp/wrongpass"
OUT=$(run_on ngb-frt-restore backup:target:add offsite "${LOCATION[@]}" --path=repo --passphrase-file=/tmp/wrongpass 2>&1) \
&& { printf '%s\n' "$OUT"; fail "a wrong passphrase was accepted"; }
printf '%s\n' "$OUT" | grep -q "Nothing was changed" || { printf '%s\n' "$OUT"; fail "a wrong passphrase did not say that nothing was changed"; }
STATUS_OUT=$(run_on ngb-frt-restore backup:status 2>&1 || true)
printf '%s\n' "$STATUS_OUT" | grep -q "Key: not created" || { printf '%s\n' "$STATUS_OUT"; fail "a wrong passphrase left a key behind"; }
[ -z "$(run_on ngb-frt-restore backup:target:list 2>&1 | grep offsite)" ] || fail "a wrong passphrase left a location behind"
OUT=$(run_on ngb-frt-restore backup:target:add other "${LOCATION[@]}" --path=nothing-here --passphrase-file=/tmp/pass 2>&1) \
&& { printf '%s\n' "$OUT"; fail "a folder without backups was added without a key"; }
printf '%s\n' "$OUT" | grep -q "no backups at this location" || { printf '%s\n' "$OUT"; fail "a folder without backups did not say so"; }
ok "without a kit: a wrong passphrase and a folder without backups change nothing"
ADD_OUT=$(run_on ngb-frt-restore backup:target:add offsite "${LOCATION[@]}" --path=repo --passphrase-file=/tmp/pass 2>&1) \
|| { printf '%s\n' "$ADD_OUT"; fail "target add with the passphrase on the restore instance"; }
printf '%s\n' "$ADD_OUT" | grep -q "fingerprint $ORIGIN_FP" || { printf '%s\n' "$ADD_OUT"; fail "the key from the location is not the origin's ($ORIGIN_FP)"; }
printf '%s\n' "$ADD_OUT" | grep -q "21 days (from the location)" || { printf '%s\n' "$ADD_OUT"; fail "the deletion delay did not come from the location"; }
ok "without a kit: the key and the deletion delay came from the location"
else
run_on ngb-frt-restore backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass >/dev/null || fail "key import"
ADD_OUT=$(run_on ngb-frt-restore backup:target:add offsite "${LOCATION[@]}" --path=repo 2>&1) || { printf '%s\n' "$ADD_OUT"; fail "target add on the restore instance"; }
fi
LIST_OUT=$(run_on ngb-frt-restore backup:list offsite 2>&1) LIST_OUT=$(run_on ngb-frt-restore backup:list offsite 2>&1)
printf '%s\n' "$LIST_OUT" | grep -q "$SNAP" || { printf '%s\n' "$LIST_OUT" | grep -q "$SNAP" || {
printf 'target:add said:\n%s\nbackup:list said:\n%s\n' "$ADD_OUT" "$LIST_OUT" printf 'target:add said:\n%s\nbackup:list said:\n%s\n' "$ADD_OUT" "$LIST_OUT"
@@ -196,7 +222,8 @@ printf '%s\n' "$LIST_OUT" | grep -q "$SNAP" || {
docker exec ngb-frt-restore sh -c 'ls -la /mnt/shared/repo /mnt/shared/repo/snapshots' || true docker exec ngb-frt-restore sh -c 'ls -la /mnt/shared/repo /mnt/shared/repo/snapshots' || true
fail "the origin's snapshot is not listed after connecting the location" fail "the origin's snapshot is not listed after connecting the location"
} }
ok "recovery kit imported, existing repository adopted, snapshot visible" if [ "$MODE" = no-kit ]; then KEY_FROM="key from the location"; else KEY_FROM="recovery kit imported"; fi
ok "$KEY_FROM, existing repository adopted, snapshot visible"
if [ "$MODE" = salvage ]; then if [ "$MODE" = salvage ]; then
set +e set +e
@@ -371,6 +398,13 @@ if [ "$MODE" = sftp ]; then
ok "over SFTP: read ahead on connections of its own ($LOGINS SFTP logins during the restore), large file identical, no spool left" ok "over SFTP: read ahead on connections of its own ($LOGINS SFTP logins during the restore), large file identical, no spool left"
fi fi
if [ "$MODE" = no-kit ]; then
OUT=$(run_on ngb-frt-restore backup:run offsite -l post-restore-check 2>&1) && { printf '%s\n' "$OUT"; fail "a backup ran without a confirmed recovery kit"; }
printf '%s\n' "$OUT" | grep -q "Confirm the recovery kit" || { printf '%s\n' "$OUT"; fail "the refused backup does not point at the recovery kit"; }
CODE=$(run_on ngb-frt-restore backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
run_on ngb-frt-restore backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm a new kit after the restore"
ok "without a kit: backups wait for a new recovery kit to be confirmed"
fi
run_on ngb-frt-restore backup:run offsite -l post-restore-check >/dev/null || fail "a new backup after the restore fails" run_on ngb-frt-restore backup:run offsite -l post-restore-check >/dev/null || fail "a new backup after the restore fails"
ok "the restored instance can run a new backup of its own" ok "the restored instance can run a new backup of its own"
+272
View File
@@ -0,0 +1,272 @@
#!/bin/bash
# Getting the backup key onto a new server, with and without the recovery kit, and what the
# administrator is told in each case. Every scenario starts on a fresh Nextcloud (SQLite) with a
# copy of the origin's location; nothing is restored (full-restore-roundtrip.sh does that).
# Usage: recovery-scenarios.sh [nextcloud-image-tag] e.g. recovery-scenarios.sh 35-apache
#
# kit kit + passphrase: backup:key:import, then backup:target:add asks nothing
# kit-wrong kit with a wrong passphrase: refused, no key left behind
# no-kit passphrase only: target:add asks for it (or --passphrase-file), a wrong one
# changes nothing; backups wait for a new kit
# no-kit-old as no-kit, on a location backed up only before 1.1.0 (no settings file):
# the deletion delay is the default, and the admin is told so
# no-config-no-kit the location's config is gone and there is no kit: told that only the kit helps
# no-config-kit config gone, kit there: --repair-config writes it again, the backups open
# other-kit the kit of another server: refused, told to use the old server's kit
# empty nothing at the location: told to check the details, not to make a new key
# unused-key-kit a new key was made by mistake: key:import refuses and names
# --replace-unused-key; with it the kit's key takes its place
# unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase
# of the old server; a wrong one keeps the unused key
# used-key a key that protected a location is never replaced, also after the location was removed
set -euo pipefail
IMAGE="nextcloud:${1:-35-apache}"
APP=$(cd "$(dirname "$0")/../.." && pwd)
SHARED=$(mktemp -d)
chmod 777 "$SHARED"
PASS='recovery scenarios passphrase 2026'
fail() { echo "FAIL: $*"; exit 1; }
ok() { echo "ok $*"; }
cleanup() {
docker rm -fv ngb-rs-origin ngb-rs-other ngb-rs-new >/dev/null 2>&1 || true
docker run --rm -v "$SHARED:/target" alpine sh -c 'rm -rf /target/*' >/dev/null 2>&1 || true
rmdir "$SHARED" 2>/dev/null || true
return 0
}
trap cleanup EXIT
run_on() { docker exec -u www-data "$1" php occ "${@:2}"; }
# Output of a command that must fail; the scenario fails when it succeeds.
must_fail() { # container what args...
local out
if out=$(run_on "$1" "${@:3}" 2>&1); then printf '%s\n' "$out"; fail "$2: the command succeeded"; fi
printf '%s\n' "$out"
}
expect() { # output text what
printf '%s\n' "$1" | grep -qF -- "$2" || { printf '%s\n' "$1"; fail "$3: the output does not say \"$2\""; }
}
not_expect() {
printf '%s\n' "$1" | grep -qF -- "$2" && { printf '%s\n' "$1"; fail "$3: the output says \"$2\""; }
return 0
}
start() { # name
docker rm -fv "$1" >/dev/null 2>&1 || true
docker run -d --name "$1" -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
for i in $(seq 1 90); do run_on "$1" status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
docker exec "$1" chown www-data:www-data /var/www/html/custom_apps
run_on "$1" app:enable ng_backup >/dev/null
docker exec "$1" sh -c "printf '%s\n' '$PASS' > /tmp/pass; printf 'not the passphrase at all\n' > /tmp/wrongpass; chmod 644 /tmp/pass /tmp/wrongpass"
}
key_of() { run_on "$1" backup:status 2>&1 | sed -n 's/^Key: \([0-9a-f-]*\),.*/\1/p'; }
no_key() { # container what
local s
s=$(run_on "$1" backup:status 2>&1 || true)
expect "$s" "Key: not created" "$2: a key was left behind"
[ -z "$(run_on "$1" backup:target:list 2>&1 | grep offsite || true)" ] || fail "$2: a location was left behind"
}
location() { echo "local -a null::null -o datadir=/mnt/shared/$1 --path=repo"; }
# A fresh server with its own copy of the origin's location
scenario() { # name
docker exec ngb-rs-origin sh -c "rm -rf /mnt/shared/$1 && mkdir -p /mnt/shared/$1 && cp -a /mnt/shared/origin/repo /mnt/shared/$1/ && chown -R www-data /mnt/shared/$1"
start ngb-rs-new
docker exec -i ngb-rs-new sh -c 'cat > /tmp/kit.json && chmod 644 /tmp/kit.json' < "$SHARED/kit.json"
}
# The origin: a key with a deletion delay of its own, a kit, one backup
start ngb-rs-origin
run_on ngb-rs-origin backup:key:init --passphrase-file=/tmp/pass --label=Safe --delete-delay-days=21 >/dev/null || fail "key init"
run_on ngb-rs-origin backup:key:kit --output=/tmp/kit.json >/dev/null
CODE=$(run_on ngb-rs-origin backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
run_on ngb-rs-origin backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
docker exec ngb-rs-origin sh -c 'mkdir -p /mnt/shared/origin && chown www-data /mnt/shared/origin'
# shellcheck disable=SC2046
run_on ngb-rs-origin backup:target:add offsite $(location origin) >/dev/null || fail "target add on the origin"
run_on ngb-rs-origin backup:run offsite >/dev/null || fail "backup on the origin"
SNAP=$(run_on ngb-rs-origin backup:list offsite | grep -o '[0-9a-f]\{32\}' | head -1)
FP=$(key_of ngb-rs-origin)
docker exec ngb-rs-origin cat /tmp/kit.json > "$SHARED/kit.json"
docker exec ngb-rs-origin test -f /mnt/shared/origin/repo/settings || fail "the origin's location has no settings file"
ok "origin: key $FP, kit, snapshot $SNAP, settings on the location"
# kit: the passphrase once, for the kit; target:add asks nothing more
scenario kit
OUT=$(run_on ngb-rs-new backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass 2>&1) || { printf '%s\n' "$OUT"; fail "kit: key import"; }
expect "$OUT" "fingerprint $FP" "kit"
expect "$OUT" "21 days (from the kit)" "kit"
expect "$OUT" "backup:target:add" "kit: the next step"
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location kit) --no-interaction 2>&1) || { printf '%s\n' "$OUT"; fail "kit: target add"; }
not_expect "$OUT" "Passphrase" "kit: target:add asked for a passphrase"
expect "$OUT" "found and adopted" "kit"
run_on ngb-rs-new backup:list offsite | grep -q "$SNAP" || fail "kit: the snapshot is not listed"
run_on ngb-rs-new backup:run offsite >/dev/null || fail "kit: a backup after the kit import"
ok "kit: imported with the passphrase, location added without asking again, backups run"
# kit-wrong
scenario kit-wrong
OUT=$(must_fail ngb-rs-new "kit-wrong" backup:key:import /tmp/kit.json --passphrase-file=/tmp/wrongpass)
expect "$OUT" "Wrong passphrase" "kit-wrong"
no_key ngb-rs-new "kit-wrong"
ok "kit-wrong: refused, nothing left behind"
# no-kit
scenario no-kit
docker exec ngb-rs-new rm /tmp/kit.json
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "no-kit without a passphrase" backup:target:add offsite $(location no-kit) --no-interaction)
expect "$OUT" "this location holds backups" "no-kit: explains where the key comes from"
expect "$OUT" "--passphrase-file" "no-kit: says how to give the passphrase"
no_key ngb-rs-new "no-kit without a passphrase"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "no-kit wrong passphrase" backup:target:add offsite $(location no-kit) --passphrase-file=/tmp/wrongpass)
expect "$OUT" "Wrong passphrase" "no-kit wrong passphrase"
expect "$OUT" "Nothing was changed" "no-kit wrong passphrase"
no_key ngb-rs-new "no-kit wrong passphrase"
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location no-kit) --passphrase-file=/tmp/pass 2>&1) || { printf '%s\n' "$OUT"; fail "no-kit: target add"; }
expect "$OUT" "fingerprint $FP" "no-kit"
expect "$OUT" "21 days (from the location)" "no-kit"
expect "$OUT" "download a new recovery kit" "no-kit: asks for a new kit"
run_on ngb-rs-new backup:list offsite | grep -q "$SNAP" || fail "no-kit: the snapshot is not listed"
OUT=$(must_fail ngb-rs-new "no-kit backup before a new kit" backup:run offsite)
expect "$OUT" "Confirm the recovery kit" "no-kit: a backup before a new kit"
CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "no-kit: confirm the new kit"
run_on ngb-rs-new backup:run offsite >/dev/null || fail "no-kit: a backup after the new kit"
ok "no-kit: passphrase asked, wrong one changes nothing, key and delay from the location, backups wait for a new kit"
# no-kit-old: a location from before 1.1.0 has no settings file
scenario no-kit-old
docker exec ngb-rs-new rm /tmp/kit.json /mnt/shared/no-kit-old/repo/settings
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location no-kit-old) --passphrase-file=/tmp/pass 2>&1) || { printf '%s\n' "$OUT"; fail "no-kit-old: target add"; }
expect "$OUT" "fingerprint $FP" "no-kit-old"
expect "$OUT" "7 days (the default" "no-kit-old: the default delay and why"
docker exec ngb-rs-new test -f /mnt/shared/no-kit-old/repo/settings || fail "no-kit-old: the settings were not written when the location was added"
ok "no-kit-old: key from the location, default delay with the reason, settings written now"
# no-config-no-kit
scenario no-config-no-kit
docker exec ngb-rs-new rm /tmp/kit.json /mnt/shared/no-config-no-kit/repo/config
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "no-config-no-kit" backup:target:add offsite $(location no-config-no-kit) --passphrase-file=/tmp/pass)
expect "$OUT" "its config file is gone" "no-config-no-kit"
expect "$OUT" "only the recovery kit has the key" "no-config-no-kit"
expect "$OUT" "--repair-config" "no-config-no-kit"
no_key ngb-rs-new "no-config-no-kit"
docker exec ngb-rs-new test ! -f /mnt/shared/no-config-no-kit/repo/config || fail "no-config-no-kit: a config was written"
ok "no-config-no-kit: told the kit is needed, nothing written"
# no-config-kit
scenario no-config-kit
docker exec ngb-rs-new rm /mnt/shared/no-config-kit/repo/config
run_on ngb-rs-new backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass >/dev/null || fail "no-config-kit: key import"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "no-config-kit without --repair-config" backup:target:add offsite $(location no-config-kit))
expect "$OUT" "--repair-config" "no-config-kit: points at --repair-config"
docker exec ngb-rs-new test ! -f /mnt/shared/no-config-kit/repo/config || fail "no-config-kit: a config was written without --repair-config"
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location no-config-kit) --repair-config 2>&1) || { printf '%s\n' "$OUT"; fail "no-config-kit: --repair-config"; }
expect "$OUT" "written again" "no-config-kit"
expect "$OUT" "found and adopted" "no-config-kit"
run_on ngb-rs-new backup:list offsite | grep -q "$SNAP" || fail "no-config-kit: the snapshot is not listed after the repair"
run_on ngb-rs-new backup:browse offsite "$SNAP" >/dev/null 2>&1 || fail "no-config-kit: the snapshot cannot be read after the repair"
ok "no-config-kit: refused without --repair-config, config written again with it, the backups open"
# other-kit: the kit of another server
start ngb-rs-other
run_on ngb-rs-other backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "other: key init"
run_on ngb-rs-other backup:key:kit --output=/tmp/other.json >/dev/null
docker exec ngb-rs-other cat /tmp/other.json > "$SHARED/other.json"
docker rm -fv ngb-rs-other >/dev/null
scenario other-kit
docker exec ngb-rs-new rm /tmp/kit.json
docker exec -i ngb-rs-new sh -c 'cat > /tmp/other.json && chmod 644 /tmp/other.json' < "$SHARED/other.json"
run_on ngb-rs-new backup:key:import /tmp/other.json --passphrase-file=/tmp/pass >/dev/null || fail "other-kit: key import"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "other-kit" backup:target:add offsite $(location other-kit))
expect "$OUT" "different key" "other-kit"
expect "$OUT" "must be the old server's" "other-kit: points at the right kit"
[ -z "$(run_on ngb-rs-new backup:target:list 2>&1 | grep offsite || true)" ] || fail "other-kit: a location was left behind"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "other-kit repair" backup:target:add offsite $(location other-kit) --repair-config)
expect "$OUT" "leave out --repair-config" "other-kit: a config that is there is not repaired"
ok "other-kit: refused, told to use the old server's kit, the config untouched"
# empty
scenario empty
docker exec ngb-rs-new sh -c 'mkdir -p /mnt/shared/nothing && chown www-data /mnt/shared/nothing'
OUT=$(must_fail ngb-rs-new "empty" backup:target:add offsite local -a null::null -o datadir=/mnt/shared/nothing --path=repo --passphrase-file=/tmp/pass)
expect "$OUT" "no backups at this location" "empty"
expect "$OUT" "Check the connection details and --path" "empty"
no_key ngb-rs-new "empty"
ok "empty: told to check the details, no key made"
# unused-key-kit: a key made by mistake, then the kit
scenario unused-key-kit
run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "unused-key-kit: key init"
MISTAKE=$(key_of ngb-rs-new)
OUT=$(must_fail ngb-rs-new "unused-key-kit without the option" backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass)
expect "$OUT" "never used" "unused-key-kit"
expect "$OUT" "--replace-unused-key" "unused-key-kit: names the option"
[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-kit: the key changed without the option"
OUT=$(must_fail ngb-rs-new "unused-key-kit wrong passphrase" backup:key:import /tmp/kit.json --passphrase-file=/tmp/wrongpass --replace-unused-key)
expect "$OUT" "Wrong passphrase" "unused-key-kit wrong passphrase"
[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-kit: a wrong passphrase changed the key"
OUT=$(run_on ngb-rs-new backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-kit: replace"; }
expect "$OUT" "The unused key was replaced" "unused-key-kit"
expect "$OUT" "fingerprint $FP" "unused-key-kit"
expect "$OUT" "21 days (from the kit)" "unused-key-kit"
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location unused-key-kit) --no-interaction 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-kit: target add"; }
expect "$OUT" "found and adopted" "unused-key-kit"
run_on ngb-rs-new backup:run offsite >/dev/null || fail "unused-key-kit: a backup after the replacement"
ok "unused-key-kit: refused without the option, a wrong passphrase changes nothing, replaced with it, backups run"
# unused-key-no-kit: a key made by mistake, no kit
scenario unused-key-no-kit
docker exec ngb-rs-new rm /tmp/kit.json
run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "unused-key-no-kit: key init"
MISTAKE=$(key_of ngb-rs-new)
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "unused-key-no-kit without the option" backup:target:add offsite $(location unused-key-no-kit) --no-interaction)
expect "$OUT" "different key" "unused-key-no-kit"
expect "$OUT" "never used" "unused-key-no-kit: says the key can make way"
expect "$OUT" "--replace-unused-key" "unused-key-no-kit: names the option"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "unused-key-no-kit wrong passphrase" backup:target:add offsite $(location unused-key-no-kit) --replace-unused-key --passphrase-file=/tmp/wrongpass)
expect "$OUT" "Nothing was changed" "unused-key-no-kit wrong passphrase"
[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-no-kit: a wrong passphrase changed the key"
[ -z "$(run_on ngb-rs-new backup:target:list 2>&1 | grep offsite || true)" ] || fail "unused-key-no-kit: a location was left behind"
# shellcheck disable=SC2046
OUT=$(run_on ngb-rs-new backup:target:add offsite $(location unused-key-no-kit) --replace-unused-key --passphrase-file=/tmp/pass 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-no-kit: replace"; }
expect "$OUT" "The unused key was replaced" "unused-key-no-kit"
expect "$OUT" "fingerprint $FP" "unused-key-no-kit"
expect "$OUT" "download a new recovery kit" "unused-key-no-kit: asks for a new kit"
run_on ngb-rs-new backup:list offsite | grep -q "$SNAP" || fail "unused-key-no-kit: the snapshot is not listed"
OUT=$(must_fail ngb-rs-new "unused-key-no-kit backup before a new kit" backup:run offsite)
expect "$OUT" "Confirm the recovery kit" "unused-key-no-kit: the old key's kit confirmation does not count"
ok "unused-key-no-kit: hint on the refusal, a wrong passphrase changes nothing, replaced with a passphrase, a new kit is needed"
# used-key: a key that protected a location stays, also after that location is removed
scenario used-key
docker exec ngb-rs-new sh -c 'mkdir -p /mnt/shared/own && chown www-data /mnt/shared/own'
run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "used-key: key init"
OWN=$(key_of ngb-rs-new)
run_on ngb-rs-new backup:target:add own local -a null::null -o datadir=/mnt/shared/own --path=repo >/dev/null || fail "used-key: own location"
run_on ngb-rs-new backup:target:remove own >/dev/null || fail "used-key: remove the own location"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "used-key target:add" backup:target:add offsite $(location used-key) --replace-unused-key --passphrase-file=/tmp/pass)
expect "$OUT" "never replaced" "used-key target:add"
# shellcheck disable=SC2046
OUT=$(must_fail ngb-rs-new "used-key target:add without the option" backup:target:add offsite $(location used-key) --no-interaction)
not_expect "$OUT" "--replace-unused-key" "used-key: offers a replacement for a used key"
OUT=$(must_fail ngb-rs-new "used-key key:import" backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key)
expect "$OUT" "never replaced" "used-key key:import"
[ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed"
ok "used-key: never replaced, by kit or passphrase, also after its location was removed"
echo "RECOVERY SCENARIOS PASS"
+27
View File
@@ -136,6 +136,33 @@ done
[ "$RUNNING" = 0 ] || fail "the queued backups did not finish" [ "$RUNNING" = 0 ] || fail "the queued backups did not finish"
occ backup:list ci3 | grep " turn " >/dev/null || fail "the queued backup made no snapshot" occ backup:list ci3 | grep " turn " >/dev/null || fail "the queued backup made no snapshot"
ok "locations in turn: the second backup queued, and done after the first" ok "locations in turn: the second backup queued, and done after the first"
# Heavy background jobs of other apps wait during a backup, and only then. Ticks keep this one moving.
HEAVY='OCA\Recognize\BackgroundJobs\NgbTestJob'
reserved() { $X php -r 'require "lib/base.php"; $q = \OC::$server->get(\OCP\IDBConnection::class)->getQueryBuilder(); echo (int)$q->select("reserved_at")->from("jobs")->where($q->expr()->eq("class", $q->createNamedParameter($argv[1])))->executeQuery()->fetchOne();' "$HEAVY"; }
tick_until_done() {
for i in $(seq 1 60); do
api -X POST "$API/tick" >/dev/null || fail "tick"
RUNNING=$(api "$API/status" | php -r 'echo count(array_filter(json_decode(stream_get_contents(STDIN), true)["runs"], fn ($r) => $r["status"] === "running"));')
[ "$RUNNING" != 0 ] || return 0
done
fail "the backup did not finish"
}
$X php -r 'require "lib/base.php"; \OC::$server->get(\OCP\BackgroundJob\IJobList::class)->add($argv[1]);' "$HEAVY" || fail "add a job"
[ "$(reserved)" = 0 ] || fail "a new job is reserved"
L3=$(echo "$IDS" | awk '{print $2}')
api -X POST -d "{\"targetId\": $L3, \"label\": \"heavy\"}" "$API/backups" >/dev/null || fail "start a backup"
tick_until_done
[ "$(reserved)" != 0 ] || fail "the job was not paused during the backup"
api -X POST "$API/tick" >/dev/null
[ "$(reserved)" = 0 ] || fail "the job stays paused after the backup"
occ backup:pause-jobs off >/dev/null || fail "turn off"
sleep 8 # APCu app config cache of the web server
api -X POST -d "{\"targetId\": $L3, \"label\": \"heavy\"}" "$API/backups" >/dev/null || fail "start a backup"
tick_until_done
[ "$(reserved)" = 0 ] || fail "the job was paused with the setting off"
occ backup:pause-jobs on >/dev/null
$X php -r 'require "lib/base.php"; \OC::$server->get(\OCP\BackgroundJob\IJobList::class)->remove($argv[1]);' "$HEAVY"
ok "heavy background jobs: paused during a backup, back after it, left alone with the setting off"
occ background:cron >/dev/null occ background:cron >/dev/null
$X php -f /var/www/html/cron.php >/dev/null 2>&1 || true $X php -f /var/www/html/cron.php >/dev/null 2>&1 || true
occ backup:status | grep "system cron" >/dev/null && fail "backup:status still warns after cron ran" occ backup:status | grep "system cron" >/dev/null && fail "backup:status still warns after cron ran"
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# The update from a released version to this working tree, on a server that was backing up.
# Usage: upgrade-from-release.sh <release package .tar.gz> [nextcloud-image-tag] [sqlite|pgsql] [new-version]
# e.g. upgrade-from-release.sh ~/dev-local/release/ng_backup-1.0.3/ng_backup-1.0.3.tar.gz 34-apache pgsql 1.1.0
#
# The old version gets a key, a confirmed kit, a location with a daily time and one backup. Then the
# app files are replaced by this working tree (version raised to new-version when given) and
# occ upgrade runs. After it: the location keeps its time as its own schedule, the old restore point
# is listed, verifies and restores a file byte for byte, a new backup runs, the location gets its
# settings file, and the key counts as used.
set -euo pipefail
PKG=${1:?release package}
IMAGE="nextcloud:${2:-34-apache}"
DB=${3:-sqlite}
NEWVER=${4:-}
APP=$(cd "$(dirname "$0")/../.." && pwd)
N=ngb-up; NET=ngb-up-net
SHARED=$(mktemp -d)
NEWAPP=$(mktemp -d)
chmod 777 "$SHARED"
PASS='upgrade test passphrase 2026'
fail() { echo "FAIL: $*"; exit 1; }
ok() { echo "ok $*"; }
cleanup() {
docker rm -fv $N $N-db >/dev/null 2>&1 || true
docker network rm $NET >/dev/null 2>&1 || true
docker run --rm -v "$SHARED:/target" alpine sh -c 'rm -rf /target/*' >/dev/null 2>&1 || true
rm -rf "$NEWAPP"
rmdir "$SHARED" 2>/dev/null || true
return 0
}
trap cleanup EXIT
occ() { docker exec -u www-data $N php occ "$@"; }
docker network create $NET >/dev/null
env=(-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin-upgrade-pw)
case $DB in
sqlite) env+=(-e SQLITE_DATABASE=nextcloud) ;;
pgsql) docker run -d --name $N-db --network $NET -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nc -e POSTGRES_PASSWORD=nc postgres:16-alpine >/dev/null
env+=(-e POSTGRES_HOST=$N-db -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nc -e POSTGRES_PASSWORD=nc)
sleep 8 ;;
*) fail "unknown database $DB" ;;
esac
docker run -d --name $N --network $NET "${env[@]}" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
for i in $(seq 1 90); do occ status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
docker exec $N chown www-data:www-data /var/www/html/custom_apps
docker exec -i -u www-data $N tar xz -C /var/www/html/custom_apps < "$PKG"
OLDVER=$(docker exec $N sed -n 's#.*<version>\(.*\)</version>.*#\1#p' /var/www/html/custom_apps/ng_backup/appinfo/info.xml)
occ app:enable ng_backup >/dev/null || fail "enable $OLDVER"
ok "NG Backup $OLDVER on $(occ -V) / $DB"
# The old version at work: key, kit, a location with a daily time, a user file, one backup
docker exec $N sh -c "printf '%s\n' '$PASS' > /tmp/pass; chmod 644 /tmp/pass; mkdir -p /mnt/shared/loc; chown www-data /mnt/shared/loc"
occ backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "key init"
occ backup:key:kit --output=/tmp/kit.json >/dev/null
CODE=$(occ backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
occ config:app:set ng_backup schedule_time --value=03:15 >/dev/null
occ backup:target:add offsite local -a null::null -o datadir=/mnt/shared/loc --path=repo >/dev/null || fail "target add"
docker exec -u www-data $N sh -c 'mkdir -p data/admin/files/Docs && head -c 3000000 /dev/urandom > data/admin/files/Docs/before.bin'
occ files:scan admin >/dev/null
SUM=$(docker exec $N sha256sum /var/www/html/data/admin/files/Docs/before.bin | cut -d' ' -f1)
occ backup:run offsite >/dev/null || fail "backup with $OLDVER"
OLDSNAP=$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | head -1)
[ -n "$OLDSNAP" ] || fail "no restore point with $OLDVER"
ok "$OLDVER: key, kit, location at 03:15, restore point $OLDSNAP"
# The update: this working tree's runtime files in place of the old ones
mkdir -p "$NEWAPP/ng_backup/js"
cp -r "$APP/appinfo" "$APP/lib" "$APP/templates" "$APP/img" "$APP/css" "$NEWAPP/ng_backup/"
cp "$APP"/js/*.mjs "$NEWAPP/ng_backup/js/"
[ -n "$NEWVER" ] && sed -i "s#<version>.*</version>#<version>$NEWVER</version>#" "$NEWAPP/ng_backup/appinfo/info.xml"
docker exec -u www-data $N rm -rf /var/www/html/custom_apps/ng_backup
tar c -C "$NEWAPP" ng_backup | docker exec -i -u www-data $N tar x -C /var/www/html/custom_apps
OUT=$(occ upgrade 2>&1) || { printf '%s\n' "$OUT"; fail "occ upgrade"; }
VER=$(occ app:list --output=json | php -r '$a = json_decode(stream_get_contents(STDIN), true); echo $a["enabled"]["ng_backup"] ?? "";')
[ -z "$NEWVER" ] || [ "$VER" = "$NEWVER" ] || fail "after the upgrade the app has version '$VER'"
ok "occ upgrade to $VER"
LIST=$(occ backup:target:list)
printf '%s\n' "$LIST" | grep offsite | grep -q "03:15" || { printf '%s\n' "$LIST"; fail "the location did not keep 03:15 as its schedule"; }
occ backup:list offsite | grep -q "$OLDSNAP" || fail "the old restore point is not listed"
occ backup:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify"
occ backup:status >/dev/null || fail "backup:status"
docker exec $N sh -c 'rm -rf /tmp/rst && mkdir /tmp/rst && chown www-data /tmp/rst'
occ backup:restore offsite "$OLDSNAP" data/admin/files/Docs/before.bin --to-directory=/tmp/rst >/dev/null || fail "restore from the old restore point"
GOT=$(docker exec $N sh -c 'find /tmp/rst -name before.bin -exec sha256sum {} \;' | cut -d' ' -f1)
[ "$GOT" = "$SUM" ] || fail "the restored file differs"
ok "after the upgrade: schedule kept, old restore point listed, verified, a file restored byte for byte"
docker exec -u www-data $N sh -c 'head -c 2000000 /dev/urandom > data/admin/files/Docs/after.bin'
occ files:scan admin >/dev/null
occ backup:run offsite >/dev/null || fail "backup after the upgrade"
[ "$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | sort -u | wc -l)" -ge 2 ] || fail "no new restore point after the upgrade"
docker exec $N test -f /mnt/shared/loc/repo/settings || fail "the location got no settings file"
OUT=$(occ backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) && fail "a used key from before the upgrade was offered for replacement"
printf '%s\n' "$OUT" | grep -q "never replaced" || { printf '%s\n' "$OUT"; fail "the refusal does not say the key is never replaced"; }
ok "a new backup runs, the location got its settings, the key counts as used"
echo "UPGRADE $OLDVER -> ${VER:-working tree} PASS"