Compare commits

..

39 Commits

Author SHA1 Message Date
Dillard Blom 511c0fc285 Merge pull request #12: Release 1.0.0-rc.1 (app version 0.9.7)
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 / 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
Release 1.0.0-rc.1 (app version 0.9.7)
2026-10-17 15:04:09 +02:00
dillard 76e8bb0245 Release 1.0.0-rc.1 (app version 0.9.7) 2026-10-17 13:07:41 +02:00
dillard f32568f83c Screenshots of the current layout 2026-10-16 00:57:17 +02:00
dillard ff8619ae6b PLAN: plan towards 1.0.0 and 1.1.0 2026-10-16 00:28:24 +02:00
dillard 629b8dd315 Passphrases of at least 16 characters; explain that the location holds the key too 2026-10-16 00:07:39 +02:00
dillard c7cbc0b058 README: primary object storage and External storage mounts are not backed up 2026-10-15 22:47:45 +02:00
dillard 942e8ec003 Web interface: dates and times in the user's Nextcloud locale 2026-10-15 21:43:09 +02:00
dillard 144097d159 Run the backup job at every cron run; skip deflate on compressed data 2026-10-15 20:59:04 +02:00
dillard 5b7dd08e71 Ask for feedback on the admin page, with an experience report form 2026-10-15 10:32:21 +02:00
dillard 30f93fbb8a README: a backup survives a restart of the containers 2026-10-15 10:20:21 +02:00
dillard ca003c88ba Fast S3 verify and snapshot commit; shorter runs list; durations with days
On S3, index files and the tree segments read back when a snapshot is
written are fetched 16 at a time through the storage's S3 client
(IBackend::getRanges(), getMany()). Blobs that follow each other in a pack
are read as one range on every location. The snapshot is written in a step
of its own and the run shows "commit" meanwhile. On a 1 TB production
repository the check went from about 4 hours to 3.5 minutes.

backup:status and the web interface list the runs of the last 7 days,
running ones and at least the newest 5; --all / "Show older runs" list
all. Durations of a day or more show the days.
2026-10-14 17:12:41 +02:00
Dillard Blom 9a3bb99d12 Merge pull request #11: Release 0.9.6-beta.7
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 / 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
Release 0.9.6-beta.7
2026-10-14 13:20:56 +02:00
dillard c38cc47534 Release 0.9.6-beta.7 2026-10-14 13:14:46 +02:00
dillard dfd3f38d8c Backups to SMB no longer crawl; add occ backup:cancel
Each 30-second step opened the location again and read its list of
index files. Over SMB that cost a request per file, so on a large
repository opening took longer than the step: each step stored one
4 MiB pack and the list grew further. A first backup of nc01 to a
Storage Box over SMB managed 12 GiB in 35 hours.

occ backup:run and system cron now keep the repository open for the
whole run (reopened when another process took a step or the location
was edited), opening no longer counts towards the step's time, and
SMB folders are listed in one request.

occ backup:cancel stops a running backup: no new step starts and the
step in progress finishes first. A run of an earlier version, which
does not hold back its next step, is marked cancelled after two
minutes unless it is recording its snapshot.
2026-10-14 12:15:27 +02:00
dillard 92e693cfe6 Runs list: show the total size of each backup next to what it uploaded 2026-10-13 11:02:18 +02:00
dillard bec070b1b5 Full-restore test: show the output and the log when restore:full fails 2026-10-12 17:15:25 +02:00
Dillard Blom 89a126999f Merge pull request #10: Release 0.9.5-beta.6
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 / 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
SFTP: open the channel first, replace dropped connections; run takeover; index cache in one transaction; faster checks with progress
2026-10-12 15:05:59 +02:00
dillard 1b986c9d31 Test: no random_bytes(0) in the batched SFTP read test 2026-10-12 14:59:31 +02:00
dillard 025bbfbb62 Verify: list the pack folders only with more packs than folders
With few packs a request per pack is fewer requests than 256 listings. On
FTP every listing takes a data connection; the FTP server of the integration
tests has ten passive ports, and 256 listings in a row slowed it down until a
read failed ("Not found: snapshots/...").
2026-10-12 14:50:56 +02:00
dillard 8ed7455619 Release 0.9.5-beta.6 2026-10-12 14:27:39 +02:00
dillard 30a250bc11 Stale-backup notification: "No successful backup to … yet" instead of "since never" 2026-10-12 11:32:42 +02:00
dillard 9c06ab2841 SFTP: replace a dropped connection; write the index cache in one transaction
A Hetzner Storage Box drops an SFTP connection that carried a lot of data
and then stayed quiet for a few minutes; files_external keeps using the dead
client, on which every file looks missing. StorageBackend now checks the
connection after a minute without requests, and replaces a connection that
is dead after a failed request (once per request; put, move and delete are
not repeated). SftpReadStream closes a connection it left in an unknown state.

The verify cache refresh took more than 6 minutes on PostgreSQL for 27,547
packs, one autocommitted insert per pack: putMany()/replaceAll() write in one
transaction. After a verify the cache holds the index files as they are, so a
blob in two packs stays cached for both.
2026-10-12 01:58:54 +02:00
dillard d659322e60 Merge branch 'fix/sftp-fresh-connection' into polish 2026-10-11 17:16:33 +02:00
dillard 7726c179f0 SftpReadStream: say why opening failed on the connection 2026-10-11 15:58:20 +02:00
dillard df732f185a Index: refresh the cache after a verify in one pass over the blobs, not one per pack 2026-10-11 14:31:57 +02:00
dillard b3c8068f8d Batched SFTP reads: no more batches on a connection whose server did not confirm a close 2026-10-11 14:05:29 +02:00
dillard 0a9bd963fb Verify and batched SFTP reads: address review notes 2026-10-11 14:00:48 +02:00
dillard 379b81db02 Verify: index files several at a time over SFTP, pack folders listed, progress stored and shown 2026-10-11 13:39:38 +02:00
dillard 828f3f142a Merge branch 'fix/sftp-fresh-connection' into polish 2026-10-11 13:26:47 +02:00
dillard c146d700e8 Merge branch 'main' into polish 2026-10-11 13:25:06 +02:00
dillard 8e273ac259 Backup step: reporting on a committed snapshot cannot fail the run; check the snapshot row after a unique violation 2026-10-11 12:30:05 +02:00
dillard 6385925845 Backup step: write the run only if no other process wrote it since it was loaded 2026-10-11 12:24:00 +02:00
dillard 894258f836 SFTP: open the channel before the first fast read; backup step: survive a takeover by another process 2026-10-11 11:45:27 +02:00
dillard 65f14b4380 Merge branch 'main' into polish
# Conflicts:
#	CHANGELOG.md
2026-10-08 21:19:34 +02:00
dillard a01aa16ccd Backups: show the progress of the files phase
A run only showed "running (files)", which says nothing during a backup
of a terabyte. The files phase now records an expected size on its first
step: the previous snapshot's size, or for a first backup the users'
files, versions and trash from Nextcloud's file cache (approximate, shown
with a ~). The web interface and occ backup:status show the share of it
done so far, including a large file that is still being read; it stays
below 100% until the run finishes.
2026-10-08 13:48:12 +02:00
dillard cc20b9174f info.xml: author e-mail address 2026-10-08 13:23:21 +02:00
dillard 239aa1b923 StorageBackend: drop a redundant cast 2026-10-08 13:03:57 +02:00
dillard 9775cdc475 Locations: edit connection settings without losing the history
A changed password or host name could only be handled by adding a new
location, which starts an empty repository and, with a retention lock at
the provider, keeps the old one billed. Edit now changes a location in
place (web interface and occ backup:target:edit). The settings are only
saved when they lead to the same repository; a failed connection and a
missing repository give separate errors, and the folder is not created
while checking. Stored passwords are never sent to the browser: an empty
password field keeps the stored value.
2026-10-08 12:56:15 +02:00
dillard a8da8cb54a info.xml: website points to the GitHub repository
The app store shows <website> as the app's homepage; pointing it at the
store page itself made the link go nowhere.
2026-10-08 02:23:59 +02:00
57 changed files with 2832 additions and 218 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Experience report
description: Tell us how NG Backup works for you, also when everything went fine.
title: "Experience: "
body:
- type: markdown
attributes:
value: |
Thanks for trying NG Backup! Reports of what works are just as useful as bug reports:
they tell us which setups are tested. Please leave out host names, user names and keys.
- type: dropdown
id: location
attributes:
label: Backup location
multiple: true
options:
- S3 / S3-compatible
- SFTP
- WebDAV
- SMB/CIFS
- FTP
- Swift
- Local path (disk, NFS mount, ...)
validations:
required: true
- type: input
id: provider
attributes:
label: Storage provider (optional)
placeholder: e.g. Hetzner Storage Box, OVH, Backblaze B2, own NAS
- type: input
id: setup
attributes:
label: Nextcloud version and setup
placeholder: e.g. 32.0.1, AIO / Docker / bare metal, PostgreSQL / MariaDB
- type: input
id: size
attributes:
label: Rough size of the backup
placeholder: e.g. 50 GB, 200,000 files
- type: checkboxes
id: tried
attributes:
label: What did you try?
options:
- label: Full backup
- label: Incremental backup
- label: Verify
- label: Restore of files or folders
- label: Full restore on another server
- type: textarea
id: notes
attributes:
label: How did it go?
description: What worked, what was unclear, slow or broken.
validations:
required: true
+137
View File
@@ -7,6 +7,140 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
beta/daily update channel, which would hide the app from most admins. The stability stage is
instead carried by the app description, the git tag, the release, and this file.
## [Unreleased]
## [1.0.0-rc.1] - 2026-10-17
Release candidate for 1.0.0; the app version is 0.9.7. Only bug fixes until 1.0.0. Tested on a
production instance (about 1 TB, 150,000 files): full and incremental backups and a verify on
SFTP and SMB (Hetzner Storage Box) and S3 (OVH), a deep verify on SFTP, and partial restores from
SMB and S3 checked against checksums.
### Added
- The admin page asks for feedback, with a link to a short "Experience report" form on GitHub:
which location, setup and size you used, and how it went.
### Changed
- New passphrases must be at least 16 characters (was 12). The location stores the key
encrypted with each passphrase, so the passphrase is what really protects the backups.
Existing passphrases keep working. The README and the how-to now say so, and why the
recovery kit must still be kept off the server.
- Dates and times in the web interface follow the locale in the user's Nextcloud settings instead
of the browser's language, so a 24-hour locale shows 16:00 rather than 4:00 PM.
- The list of backup runs (web interface and `occ backup:status`) shows the runs of the last 7
days, those still running, and at least the 5 newest; a failed run stays visible for that week.
`occ backup:status --all` and "Show older runs" in the web interface list all of them.
- `occ backup:status` and `occ backup:verify` show durations of a day or more with the days
("1d 12:10:13" instead of "12:10:13").
### Fixed
- With system cron, a running backup moved forward only every other cron run: Nextcloud
skipped the background job when cron came a few seconds before its 5-minute interval was
over, so a backup was idle 6 of every 10 minutes. The job now runs at every cron run.
- Photos, video and other compressed data are no longer deflated only to throw the result away:
a quick test on three small slices of each block skips it. Compressing was the slowest part of
a backup (about 24 MiB/s).
- Checking a backup on an S3 location (`occ backup:verify`) took hours on a large repository:
the index files were downloaded one at a time, at about two a second. They are now downloaded
16 at a time: on a production instance (27,585 index files, 1 TB) the check went from about
4 hours to 3.5 minutes.
- Writing the snapshot at the end of a long backup to S3 took a quarter of an hour without any
progress shown: it read back the file list of every step one at a time. These reads are now
done 16 at a time on S3 (96 reads: 82 seconds before, 2 now), blobs that follow each other in
a pack are read at once on every location, the snapshot is written in a step of its own, and
the run shows "commit" while it does so.
## [0.9.6-beta.7] - 2026-10-14
Bugfix release for SMB locations, with `occ backup:cancel` to stop a backup that does not get
anywhere. Tested on a production instance: a first full backup of 151,021 files (807 GiB) to OVH
S3 completed, and a backup to a Hetzner Storage Box over SMB that had slowed to 12 GiB in 35
hours ran at about 5.6 MiB/s after the update, while the S3 backup was still reading.
### Added
- The list of backup runs (web interface and `occ backup:status`) shows the total size of each
backup next to what it uploaded: an incremental backup of 1 TB that uploads 800 MB now shows
both. `occ backup:status` shows sizes in GiB where that reads better than MiB.
- `occ backup:cancel <run>` stops a running backup; `occ backup:status` shows the run numbers.
No new step of the run starts, the step in progress finishes (usually within a minute), then
the process working on the run (`occ backup:run` or cron) stops. What it already stored is
removed by the next cleanup. A backup started by an earlier version is stopped as well: after
two minutes it is marked cancelled while it works, and its process stops at the end of the
step.
### Fixed
- Backups to SMB locations, and to other slow locations with many packs, slowed to a crawl: each
30-second step opened the location again, and reading the list of index files took longer
than the step itself. Each step then stored a single 4 MiB pack, which made the list longer
still. A first full backup of 0.8 TB to a Hetzner Storage Box over SMB managed 12 GiB in 35
hours. `occ backup:run` and system cron now open the location once per run, opening no longer
counts towards the step's time, and SMB folders are listed in one request instead of one per
file. A backup to an SMB location that is not getting anywhere keeps running in its old
process after the update: update first, then stop it with `occ backup:cancel <run>` and start
it again. The new backup reuses the packs the old one stored.
## [0.9.5-beta.6] - 2026-10-12
Bugfix release for SFTP locations and large repositories. Tested on a production instance with a
1 TB repository on a Hetzner Storage Box over SFTP: a check in 5.5 minutes, a deep check of all
316,529 blobs (815 GiB) in 11 hours, and an incremental backup in under 8 minutes.
### Added
- Progress of a running backup in the web interface and `occ backup:status`, e.g.
"running (files) 68%". The total is the size of the previous snapshot; for a first backup it
is estimated from Nextcloud's file cache (users' files, versions and trash) and shown as
"~68%".
- Edit a backup location (web interface and `occ backup:target:edit`): change the password, host,
folder, connection type or name without losing its history. Before this the only option was a
new location, which starts an empty repository and, with a retention lock at the provider,
keeps paying for the old one. The new settings are only saved when they lead to the same
repository; stored passwords are never sent to the browser, and an empty password field keeps
the stored one. Administrators are notified of the change.
- `occ backup:verify` reports how far it is every 30 seconds, and `occ backup:status` shows the
progress of a running check and how the last check of each location ended. On a large
repository a check takes hours, a deep one days.
### Changed
- Checking and opening a large repository over SFTP is much faster: the index files (one per
pack, 27,547 for 1 TB) are downloaded 32 at a time instead of one after the other, 114 instead
of 6 files per second to a Hetzner Storage Box (about 4 minutes instead of 77 for 1 TB). A check
of a repository with more than 256 packs also lists the pack folders (256 requests) instead of
asking for every pack.
- App store listing: the author's e-mail address is shown.
- App store listing: the homepage link points to the GitHub repository instead of back to the
app store page itself.
- The notification for a location without a successful backup read "No successful backup to …
since never" when there had never been one; it now reads "No successful backup to … yet".
### Fixed
- 0.9.4 could not open a repository at the root of an SFTP location (empty path): its faster
SFTP reads sent their first request before phpseclib had opened the SFTP channel, which only
happens on the first SFTP call after login. Every backup then failed with "No repository at
this location". The channel is now opened first.
- A check (`occ backup:verify`) did not renew its hold on the location while it downloaded the
index; on a large repository that takes longer than the hold lasts, so a weekly cleanup could
have started during the check.
- A long first backup could end as failed although its snapshot was complete: when its final
step took longer than the step's lease (150 s with `occ backup:run`), the background job took
the run over, finished it a second time and failed on the snapshot it had already recorded,
overwriting the result. The lease now lasts at least 15 minutes, a step writes the run only if
no other process wrote it since the step loaded it (in one database statement), and recording
a snapshot that is already recorded is no longer an error.
- `occ backup:verify` on a large repository failed with "SFTP connection failed while opening
snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that
carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went
quiet after downloading the index: it refreshed the local index cache, first in 29 minutes (a
pass over all blobs for every pack: 27,547 packs, 400,000 blobs for 1 TB), then still in more
than 6 minutes (a database transaction per pack on PostgreSQL). Nextcloud's SFTP storage keeps
using a dropped connection, on which every file looks missing. Now:
- an SFTP connection that was not used for a minute is checked first, and one that turns out
to be dead after a failed request is replaced, once per request; uploads, moves and deletes
are only checked first, never repeated;
- the index cache is written in one transaction (27,547 packs in 11 seconds on a test server,
against about a minute row by row);
- after a check the cache holds the index files as they are: before, a blob that was in two
packs was only cached for one of them.
## [0.9.4-beta.5] - 2026-10-09
Bugfix release: faster restores.
@@ -154,6 +288,9 @@ and not implemented yet.
- See the "Storage backend experiences" section of the README for provider-specific quirks found
so far (e.g. Hetzner Storage Box sub-accounts).
[1.0.0-rc.1]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.1
[0.9.6-beta.7]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.6-beta.7
[0.9.5-beta.6]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.5-beta.6
[0.9.4-beta.5]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.4-beta.5
[0.9.3-beta.4]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.3-beta.4
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
+23
View File
@@ -377,6 +377,29 @@ in README "Known limitations":
11. `backup:restore:full` in onderhoudsmodus en hervatbaar maken; nu: opnieuw draaien met `--force`.
12. Config/rename op FTP/SMB-servers die geen rename over een bestaand bestand toestaan.
**Plan richting 1.0.0 en 1.1.0 (besluit 2026-10-15/16):**
- **1.0.0-rc.1:** wat nu op `polish` staat (snelle S3-verify en snapshot-commit, kortere runlijst,
duur met dagen, job bij elke cron-run, deflate-test, feedbackformulier, datums in de locale van
de gebruiker, wachtwoordzin min. 16 tekens). Geen nieuwe functies meer.
- **Vóór 1.0.0 (verplicht):** volledige restore met kit op een tweede server (PVE02), alleen lezend
of op een kopie van de locatie; plus een user-restore. Zonder geslaagde test geen 1.0.0.
- **1.1.0:**
1. `backup:key:import` zonder kit: de sleutel-slots van de locatie (`config`) lezen, met de kit
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
DR-test op PVE02 herhalen zonder kit.
2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten):
Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch.
Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal
~6 uur achter elkaar, zichtbaar in UI en log. Op nc01 met de hand getest tijdens SMB-run 10.
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,
33% op de SMB-upload, ~7% CPU. Vergt extra PHP-processen; botst mogelijk met uitgangspunt 1.
- **Later (geen versie):** optie om de sleutel niet op de locatie op te slaan (kit is dan de enige
kopie; uitdrukkelijke waarschuwing). Primaire object storage en External-storage-koppelingen
meenemen via de opslaglaag (§3.4; nu niet ondersteund, zie README "Known limitations").
- **Open:** of de bevestigingscode van de kit dubbelop is (nog niet over besloten).
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
terugzetten vanuit de Files-app).
+27 -7
View File
@@ -7,8 +7,8 @@ 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
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
**Status: beta (0.9.4).** Tested thoroughly, usable alongside your current backup, but not yet a
drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
**Status: release candidate for 1.0.0 (1.0.0-rc.1, app version 0.9.7).** Tested thoroughly, usable
alongside your current backup, but not yet a drop-in replacement for it. 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.
License: AGPL-3.0-or-later.
@@ -23,6 +23,10 @@ License: AGPL-3.0-or-later.
leaves the server, and streamed straight to the location: no local copy, no double disk space.
Unchanged files are not read again; only new data is uploaded.
- The database is dumped through Nextcloud's own connection, inside one consistent transaction.
- A backup runs in short steps from Nextcloud's background jobs, and every step saves where it
got to. A restart of the server or the containers does not lose the run: it continues at the
next background job run. Tested on a production Nextcloud AIO instance: a 1 TB full backup over
SMB carried on through the nightly AIO update, which restarts every container.
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
removed files go to the trash.
@@ -47,9 +51,19 @@ All backups of an installation are encrypted with one master key.
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
and restores work without anyone typing a passphrase. Any administrator can restore from the
web interface. Consequence: whoever fully controls the server can read the backups; whoever
only gets hold of the backup location cannot.
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
only gets hold of the backup location cannot, as long as the passphrases are strong (see below).
- The **location holds the key too**, by design (like restic and Borg in repokey mode): its
`config` file contains the master key, encrypted separately with each passphrase (Argon2id),
the same as in the recovery kit. Without a passphrase it is of no use, but whoever can read the
location can try passphrases offline. **Your passphrase is therefore the actual key**: it must
be at least 16 characters; five or more random words are a good choice.
- 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
`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**
and **one passphrase**.
- 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.
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
lock the company out. A suggested setup:
@@ -168,6 +182,11 @@ Contributions welcome via PR — add what you ran into with your own provider.
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
External storage app are not in the data directory and are **not included**, without a warning.
Back them up separately. On the roadmap: reading files through Nextcloud's storage layer, which
covers both, with external mounts optional per mount (`PLAN.md` section 3.4).
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
snapshot in the web interface and per-user export manifests are held in memory. Expect high
memory use there; reports from such instances are very welcome.
@@ -193,8 +212,9 @@ On the backlog; none of these block the beta or 1.0.0, but know them before you
|---|---|
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
| `backup:target:backends/add/list/remove/append-only` | backup locations |
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
| `backup:run`, `backup:status`, `backup:list` | back up, status (runs of the last 7 days; `--all` for all), restore points |
| `backup:cancel <run>` | stop a running backup |
| `backup:browse`, `backup:restore` | browse and restore |
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
+6 -4
View File
@@ -4,20 +4,20 @@
<id>ng_backup</id>
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
<description><![CDATA[Release candidate for 1.0.0: scheduled, encrypted, incremental backups of the database, config and data
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
single users (via user_migration) and of the whole server onto a fresh installation. Tested
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
See the README for known limitations and the recovery kit before relying on it.]]></description>
<version>0.9.4</version>
<version>0.9.7</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
<namespace>NgBackup</namespace>
<documentation>
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
</documentation>
<category>tools</category>
<website>https://apps.nextcloud.com/apps/ng_backup</website>
<website>https://github.com/dillardblom/ng_backup</website>
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/1-backups.png</screenshot>
@@ -37,9 +37,11 @@ See the README for known limitations and the recovery kit before relying on it.]
<command>OCA\NgBackup\Command\KeyConfirm</command>
<command>OCA\NgBackup\Command\TargetBackends</command>
<command>OCA\NgBackup\Command\TargetAdd</command>
<command>OCA\NgBackup\Command\TargetEdit</command>
<command>OCA\NgBackup\Command\TargetList</command>
<command>OCA\NgBackup\Command\TargetRemove</command>
<command>OCA\NgBackup\Command\RunBackup</command>
<command>OCA\NgBackup\Command\CancelRun</command>
<command>OCA\NgBackup\Command\Status</command>
<command>OCA\NgBackup\Command\ListSnapshots</command>
<command>OCA\NgBackup\Command\Browse</command>
+2
View File
@@ -18,6 +18,8 @@ return [
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
['name' => 'AdminApi#targetSettings', 'url' => '/api/targets/{id}/settings', 'verb' => 'GET'],
['name' => 'AdminApi#updateTarget', 'url' => '/api/targets/{id}', 'verb' => 'PUT'],
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
+21 -2
View File
@@ -12,13 +12,16 @@ occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I confirm"
```
- The passphrase protects the backup. Keep it somewhere other than this server.
- The passphrase protects the backup: the location stores the key encrypted with it, so whoever
can read the location can try to guess it. Use at least 16 characters (five or more random
words work well) and keep it somewhere other than this server.
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
who kept an old kit and that passphrase (see "Known limitations" in the README).
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
user writes it there; then move it off the server as root, next to the passphrase, and delete the
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
copy in `/var/tmp`. The location holds the same encrypted key, but keep the kit anyway: if
someone damages or deletes the key on the location, the kit is the only way back in.
## 2. Add a backup location
@@ -50,6 +53,22 @@ occ backup:target:list
occ backup:status
```
A backup that hangs or takes far too long can be stopped. `backup:status` shows the run number;
the command waits until the step in progress is finished, then the process working on the run
stops, and the next cleanup removes what it stored:
```
occ backup:cancel 7
```
Changed password, new host name, or moved the repository? Edit the location instead of adding a
new one, so its history stays (in the web interface: **Edit** next to the location). The new
settings must lead to the same repository; options you do not pass keep their value:
```
occ backup:target:edit nas -o password=<new pw>
```
## 3. Run the first backup
```
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Backend;
/** IBackend::getMany() and getRanges() for a backend where reading one after the other costs nothing extra. */
trait GetsOneByOne {
/**
* @param list<string> $paths
* @return \Generator<string, string>
*/
public function getMany(array $paths): \Generator {
foreach ($paths as $path) {
$fh = $this->get($path);
try {
$data = stream_get_contents($fh);
} finally {
fclose($fh);
}
if ($data === false) {
throw new BackendException('Read failed for ' . $path);
}
yield $path => $data;
}
}
/**
* @param list<array{0:string, 1:int, 2:int}> $ranges
* @return \Generator<int, string>
*/
public function getRanges(array $ranges): \Generator {
foreach ($ranges as $i => [$path, $offset, $length]) {
yield $i => $this->getRange($path, $offset, $length);
}
}
}
+21
View File
@@ -22,9 +22,30 @@ interface IBackend {
/** @return resource readable stream of the whole object */
public function get(string $path);
/**
* Many small objects whole, in the order given (the repository's index files). A backend
* where every request costs a round trip can fetch them several at a time.
*
* @param list<string> $paths
* @return \Generator<string, string> contents by path
* @throws BackendException for an object that cannot be read
*/
public function getMany(array $paths): \Generator;
/** @return string bytes [$offset, $offset + $length) of the object (used to fetch single blobs from a pack) */
public function getRange(string $path, int $offset, int $length): string;
/**
* Many byte ranges, in the order given (blobs spread over many packs, such as the tree
* segments of a backup). A backend where every request costs a round trip can fetch them
* several at a time.
*
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
* @return \Generator<int, string> contents by position in $ranges
* @throws BackendException for a range that cannot be read
*/
public function getRanges(array $ranges): \Generator;
public function exists(string $path): bool;
/**
+2
View File
@@ -8,6 +8,8 @@ declare(strict_types=1);
namespace OCA\NgBackup\Backend;
final class LocalBackend implements IBackend {
use GetsOneByOne;
public function __construct(
private string $root,
) {
+235 -19
View File
@@ -28,6 +28,10 @@ final class SftpReadStream {
private const PROTOCOL = 'ngbsftpread';
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
public const DEPTH = 8;
/** Files read at once by readMany(): each holds a handle on the server while its batch runs. */
public const BATCH = 32;
/** Larger files are left to the usual read by readMany(), which holds every file in memory. */
private const MANY_MAX_BYTES = 16 * 1048576;
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
private const CHUNK = 261120;
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
@@ -40,6 +44,7 @@ final class SftpReadStream {
private const FXP_HANDLE = 102;
private const FXP_DATA = 103;
private const FXF_READ = 1;
private const FX_OK = 0;
private const FX_EOF = 1;
/**
@@ -50,11 +55,20 @@ final class SftpReadStream {
*/
private static ?\WeakMap $chunks = null;
private static int $requestId = 0x4e470000;
/**
* Connections whose server did not confirm closing a batch's files: readMany() is not used on
* them again, so handles cannot pile up.
*
* @var \WeakMap<object, true>|null
*/
private static ?\WeakMap $noBatches = null;
/** Streams in a row that failed; at MAX_FAILURES only the storage's own stream is used. */
private static int $failures = 0;
public const MAX_FAILURES = 3;
/** Set by stream_open() when the connection failed rather than the file. */
private static bool $openBroke = false;
/** Why the last stream_open() failed on the connection. */
private static string $openCause = '';
/** @var resource|null set by PHP for stream wrappers */
public $context;
@@ -101,14 +115,8 @@ final class SftpReadStream {
return null;
}
try {
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
return null;
}
$sftp = $storage->getConnection();
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
// methods of these names neither public nor at all.
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|| !is_callable([$sftp, '_realpath']) || !array_key_exists('packet_type', get_object_vars($sftp))) {
$sftp = self::connection($storage);
if ($sftp === null) {
return null;
}
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
@@ -129,14 +137,191 @@ final class SftpReadStream {
return null;
}
if ($fh === false && self::openBroke()) {
throw new BackendException('SFTP connection failed while opening ' . $path);
throw new BackendException('SFTP connection failed while opening ' . $path . ': ' . self::$openCause);
}
return $fh;
}
/**
* The storage's phpseclib connection, with its SFTP channel open, when it offers what these
* streams need; null otherwise.
*/
private static function connection(object $storage): ?object {
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
return null;
}
$sftp = $storage->getConnection();
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
// methods of these names neither public nor at all.
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
return null;
}
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
if ($sftp->pwd() === false) {
return null;
}
return $sftp;
}
/**
* Read many small files whole, BATCH at a time: the OPEN, READ and CLOSE requests of a batch
* all go out before their answers are read, so a batch costs a few round trips instead of
* four per file. Meant for files of up to a few hundred KiB (the repository's index files):
* every file is held in memory.
*
* @param list<string> $paths below the storage's root
* @return array<string, string|null>|null contents by path; null for a file this could not
* read (missing, or a server error), so the caller
* reads it the usual way; null for all when the
* connection does not offer what this needs
* @throws BackendException when the connection failed while requests were out: the storage's
* own stream must not be tried on it
*/
public static function readMany(object $storage, array $paths): ?array {
if (self::$failures >= self::MAX_FAILURES) {
return null;
}
try {
$sftp = self::connection($storage);
$root = $sftp === null ? '' : rtrim((string)$storage->getRoot(), '/');
} catch (\Throwable) {
self::$failures++;
return null;
}
if ($sftp === null || self::$noBatches?->offsetExists($sftp) === true) {
return null;
}
$result = array_fill_keys($paths, null);
foreach (array_chunk($paths, self::BATCH) as $batch) {
try {
[$read, $closed] = self::readBatch($sftp, $root, $batch);
} catch (\Throwable $e) {
self::$failures++;
// Answers to requests already sent may still arrive, and files_external would take
// them for its own: end the connection rather than leave it out of step.
self::disconnect($sftp);
throw $e instanceof BackendException ? $e : new BackendException('SFTP connection failed while reading ' . count($batch) . ' files: ' . $e->getMessage(), 0, $e);
}
foreach ($read as $path => $contents) {
$result[$path] = $contents;
}
if (!$closed) {
// The server did not confirm closing these files: no more batches on this
// connection, so handles cannot pile up. The rest is read the usual way.
self::noMoreBatches($sftp);
break;
}
}
self::$failures = 0;
return $result;
}
private static function noMoreBatches(object $sftp): void {
self::$noBatches ??= new \WeakMap();
self::$noBatches[$sftp] = true;
}
private static function disconnect(object $sftp): void {
try {
if (is_callable([$sftp, 'disconnect'])) {
$sftp->disconnect();
}
} catch (\Throwable) {
}
}
/** Requests of one kind for a whole batch, then their answers in order. @return list<array{int, string}> */
private static function exchange(object $sftp, int $type, array $packets): array {
$ids = [];
foreach ($packets as $packet) {
$id = self::nextId();
if (!$sftp->_send_sftp_packet($type, $packet, $id)) {
throw new BackendException('SFTP connection failed while sending a request');
}
$ids[] = $id;
}
$answers = [];
foreach ($ids as $id) {
$response = $sftp->_get_sftp_packet($id);
if (!is_string($response)) {
throw new BackendException('SFTP connection failed while waiting for an answer');
}
$answers[] = [$sftp->packet_type, $response];
}
return $answers;
}
/**
* @param list<string> $batch
* @return array{0: array<string, string>, 1: bool} the files read whole, and whether the
* server confirmed closing every handle
*/
private static function readBatch(object $sftp, string $root, array $batch): array {
$opens = [];
$openPaths = [];
foreach ($batch as $path) {
$remote = $sftp->_realpath($root . '/' . ltrim($path, '/'));
if (is_string($remote)) {
$opens[] = pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0);
$openPaths[] = $path;
}
// a path that cannot be resolved is left to the caller's usual read
}
$handles = []; // path => handle
foreach (self::exchange($sftp, self::FXP_OPEN, $opens) as $i => [$type, $response]) {
if ($type === self::FXP_HANDLE) {
$handles[$openPaths[$i]] = substr($response, 4);
}
// any other answer (missing, no permission): the caller reads it the usual way
}
$data = array_fill_keys(array_keys($handles), '');
$reading = $handles;
while ($reading !== []) {
$length = self::chunk($sftp);
$reads = [];
foreach ($reading as $path => $handle) {
$offset = strlen($data[$path]);
$reads[] = pack('Na*N3', strlen($handle), $handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
}
$paths = array_keys($reading);
foreach (self::exchange($sftp, self::FXP_READ, $reads) as $i => [$type, $response]) {
$path = $paths[$i];
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
if ($type === self::FXP_DATA && is_array($declared) && $declared[1] > 0 && $declared[1] === strlen($response) - 4
&& $declared[1] <= $length && strlen($data[$path]) + $declared[1] <= self::MANY_MAX_BYTES) {
$data[$path] .= substr($response, 4); // more may follow: read on from there
continue;
}
unset($reading[$path]);
$status = $type === self::FXP_STATUS && is_array($declared) ? $declared[1] : null;
if ($status !== self::FX_EOF) {
// a server error, a malformed answer or a file too large for this: the caller
// reads it the usual way
unset($data[$path]);
}
}
}
$closes = [];
foreach ($handles as $handle) {
$closes[] = pack('Na*', strlen($handle), $handle);
}
$closed = true;
foreach (self::exchange($sftp, self::FXP_CLOSE, $closes) as [$type, $response]) {
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
if (!is_array($status) || $status[1] !== self::FX_OK) {
$closed = false;
}
}
return [$data, $closed];
}
/** Test hook: forget the learned READ sizes and earlier failures. */
public static function reset(): void {
self::$chunks = null;
self::$noBatches = null;
self::$failures = 0;
}
@@ -156,14 +341,14 @@ final class SftpReadStream {
}
$id = self::nextId();
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) {
return $this->connectionFailed();
return $this->connectionFailed('the request could not be sent');
}
$response = $this->sftp->_get_sftp_packet($id);
} catch (\Throwable) {
return $this->connectionFailed();
} catch (\Throwable $e) {
return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
}
if (!is_string($response)) {
return $this->connectionFailed();
return $this->connectionFailed('no answer');
}
if ($this->sftp->packet_type !== self::FXP_HANDLE) {
return false; // an answer, but no handle: the file cannot be opened
@@ -173,12 +358,39 @@ final class SftpReadStream {
}
/** Not a missing file but a connection that does not behave as expected. */
private function connectionFailed(): bool {
private function connectionFailed(string $cause): bool {
self::$failures++;
self::$openBroke = true;
self::$openCause = $cause . self::connectionState($this->sftp);
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
return false;
}
/** What phpseclib knows about the connection, for an error message. */
private static function connectionState(object $sftp): string {
$state = [];
try {
if (is_callable([$sftp, 'isConnected'])) {
$state[] = 'connected: ' . ($sftp->isConnected() ? 'yes' : 'no');
}
if (is_callable([$sftp, 'getLastError'])) {
$last = (string)$sftp->getLastError();
if ($last !== '') {
$state[] = 'ssh: ' . $last;
}
}
if (is_callable([$sftp, 'getLastSFTPError'])) {
$last = (string)$sftp->getLastSFTPError();
if ($last !== '') {
$state[] = 'sftp: ' . $last;
}
}
} catch (\Throwable) {
}
return $state === [] ? '' : ' (' . implode('; ', $state) . ')';
}
/** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
private static function openBroke(): bool {
return self::$openBroke;
@@ -304,6 +516,9 @@ final class SftpReadStream {
} catch (\Throwable) {
$this->broken = true;
}
if ($this->broken) {
self::disconnect($this->sftp);
}
}
$this->handle = '';
}
@@ -385,19 +600,20 @@ final class SftpReadStream {
private function drain(): void {
$inFlight = $this->inFlight;
$this->inFlight = [];
if ($this->broken) {
return;
}
try {
foreach ($inFlight as [$id]) {
foreach ($this->broken ? [] : $inFlight as [$id]) {
if (!is_string($this->sftp->_get_sftp_packet($id))) {
$this->broken = true;
return;
break;
}
}
} catch (\Throwable) {
$this->broken = true;
}
if ($this->broken) {
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
self::disconnect($this->sftp);
}
}
private static function chunk(object $sftp): int {
+316 -5
View File
@@ -19,16 +19,36 @@ final class StorageBackend implements IBackend {
private bool $atomicPut;
/** SFTP: read through SftpReadStream, which keeps several requests in flight. */
private bool $sftpReads;
/** S3: many small reads go straight to the storage's S3 client, several at a time. */
private bool $s3Reads;
/** S3 requests in flight at once for getMany() and getRanges(). */
public const S3_CONCURRENCY = 16;
/** Most ranges, and bytes, getRanges() holds in memory at once. */
private const RANGES_LOT = 64;
private const RANGES_LOT_BYTES = 64 * 1048576;
/** Seconds without a request after which an SFTP connection is checked before it is used. */
public const IDLE_CHECK = 60;
private float $lastUsed;
/** Times the storage was built anew after its connection died (for tests and diagnostics). */
public int $reconnects = 0;
/**
* @param (\Closure():IStorage)|null $reconnect builds the same storage anew, with a new
* connection
*/
public function __construct(
private IStorage $storage,
private string $base = '',
bool $createBase = true,
private ?\Closure $reconnect = null,
) {
$this->lastUsed = microtime(true);
$this->base = trim($base, '/');
self::validate($this->base, true);
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
if ($this->base !== '') {
$this->s3Reads = $storage instanceof \OCA\Files_External\Lib\Storage\AmazonS3;
if ($createBase && $this->base !== '') {
$this->mkdirs($this->base);
}
}
@@ -39,6 +59,8 @@ final class StorageBackend implements IBackend {
}
public function put(string $path, $stream): void {
// not repeated on a new connection: the stream is (partly) read
$this->idleCheck();
$expected = self::remaining($stream);
$target = $this->abs($path);
$this->mkdirs(dirname($target));
@@ -82,6 +104,11 @@ final class StorageBackend implements IBackend {
}
public function get(string $path) {
return $this->call(fn () => $this->open($path));
}
/** @return resource */
private function open(string $path) {
if ($this->sftpReads) {
$fh = SftpReadStream::open($this->storage, $this->abs($path));
if (is_resource($fh)) {
@@ -97,7 +124,130 @@ final class StorageBackend implements IBackend {
return $fh;
}
/**
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
* after the other.
*/
public function getMany(array $paths): \Generator {
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
foreach (array_chunk($paths, $size) as $chunk) {
$fast = null;
if ($this->s3Reads) {
$this->idleCheck();
$fast = $this->s3Fetch(array_map(fn (string $p): array => [$p, null], $chunk));
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (int $i): ?string => $fast[$i] ?? null, array_keys($chunk)));
} elseif ($this->sftpReads) {
$abs = array_map(fn (string $p): string => $this->abs($p), $chunk);
$fast = $this->call(fn () => SftpReadStream::readMany($this->storage, $abs));
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
}
foreach ($chunk as $path) {
$data = $fast[$path] ?? null;
if ($data === null) {
// not read in the batch (or no batches here): the usual way, which also
// reports a missing file as such
$fh = $this->get($path);
try {
$data = stream_get_contents($fh);
} finally {
fclose($fh);
}
if ($data === false) {
throw new BackendException('Read failed for ' . $path);
}
}
yield $path => $data;
}
}
}
/** Over S3 several at a time (s3Fetch()); elsewhere one after the other. */
public function getRanges(array $ranges): \Generator {
$lot = [];
$bytes = 0;
foreach ($ranges as $i => $range) {
if ($lot !== [] && (count($lot) >= self::RANGES_LOT || $bytes + $range[2] > self::RANGES_LOT_BYTES)) {
yield from $this->rangesLot($lot);
$lot = [];
$bytes = 0;
}
$lot[$i] = $range;
$bytes += $range[2];
}
if ($lot !== []) {
yield from $this->rangesLot($lot);
}
}
/**
* @param array<int, array{0:string, 1:int, 2:int}> $lot
* @return \Generator<int, string>
*/
private function rangesLot(array $lot): \Generator {
$fast = null;
if ($this->s3Reads) {
$this->idleCheck();
$fast = $this->s3Fetch(array_map(fn (array $r): array => [$r[0], $r[1] . '-' . ($r[1] + $r[2] - 1)], $lot));
}
foreach ($lot as $i => [$path, $offset, $length]) {
$data = $fast[$i] ?? null;
// not read in the batch, or not all of it: the usual way, which also reports why
yield $i => ($data !== null && strlen($data) === $length) ? $data : $this->getRange($path, $offset, $length);
}
}
/**
* Read objects (or byte ranges of them, "first-last") straight through the S3 client of
* files_external's S3 storage, S3_CONCURRENCY at a time. Its own reads open a new HTTPS
* connection per object, after a HEAD request: one after the other that is about two reads a
* second, which made checking the index of a large repository take hours. What fails here
* is left out of the result, for the caller to read the usual way (which reports the error).
*
* @param array<int, array{0:string, 1:?string}> $requests [path, range or null] by key
* @return array<int, string>|null contents by key; null when the storage is not as expected
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
*/
private function s3Fetch(array $requests): ?array {
try {
/** @psalm-suppress UndefinedMethod, UndefinedThisPropertyFetch */
[$client, $bucket, $sse] = (function (): array {
$sse = method_exists($this, 'getServerSideEncryptionParameters') ? $this->getServerSideEncryptionParameters()
: (method_exists($this, 'getSSECParameters') ? $this->getSSECParameters() : []);
return [$this->getConnection(), $this->getBucket(), $sse];
})->call($this->storage);
if (!$client instanceof \Aws\S3\S3Client || !is_string($bucket)) {
return null;
}
$results = [];
$keys = array_keys($requests); // the pool numbers results 0, 1, ...: mapped back below
$commands = (function () use ($requests, $client, $bucket, $sse): \Generator {
foreach (array_values($requests) as $n => [$path, $range]) {
$args = ['Bucket' => $bucket, 'Key' => trim($this->abs($path), '/')] + $sse;
if ($range !== null) {
$args['Range'] = 'bytes=' . $range;
}
yield $n => $client->getCommand('GetObject', $args);
}
})();
(new \Aws\CommandPool($client, $commands, [
'concurrency' => self::S3_CONCURRENCY,
'fulfilled' => function (mixed $result, int $n) use (&$results, $keys): void {
$results[$keys[$n]] = (string)$result['Body'];
},
'rejected' => static function (): void {
},
]))->promise()->wait();
return $results;
} catch (\Throwable) {
return null;
}
}
public function getRange(string $path, int $offset, int $length): string {
return $this->call(fn () => $this->readRange($path, $offset, $length));
}
private function readRange(string $path, int $offset, int $length): string {
$fh = $this->get($path);
try {
if (fseek($fh, $offset) !== 0) {
@@ -120,23 +270,40 @@ final class StorageBackend implements IBackend {
}
}
/** Whether the storage accepts the connection settings (files_external's own check). */
public function canConnect(): bool {
try {
return !method_exists($this->storage, 'test') || $this->storage->test();
} catch (\Throwable) {
return false;
}
}
public function exists(string $path): bool {
return $this->storage->file_exists($this->abs($path));
$abs = $this->abs($path);
return $this->call(fn () => $this->storage->file_exists($abs), fn (bool $found) => !$found);
}
public function list(string $prefix): array {
$result = [];
$this->listInto($this->abs($prefix), $result);
$abs = $this->abs($prefix);
$result = $this->call(function () use ($abs): array {
$result = [];
$this->listInto($abs, $result);
return $result;
}, fn (array $found) => $found === []);
sort($result);
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
return array_map(fn ($p) => substr($p, $cut), $result);
}
public function delete(string $path): void {
$this->idleCheck();
$this->storage->unlink($this->abs($path));
}
public function move(string $from, string $to): void {
// not repeated on a new connection: the server may have done it before the connection died
$this->idleCheck();
$target = $this->abs($to);
$this->mkdirs(dirname($target));
if (!$this->storage->rename($this->abs($from), $target)) {
@@ -146,15 +313,31 @@ final class StorageBackend implements IBackend {
public function listFiles(string $dir): array {
$abs = $this->abs($dir);
return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []);
}
/** @return list<string> */
private function filesIn(string $dir, string $abs): array {
if (!$this->storage->is_dir($abs)) {
return []; // not created yet
}
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
$names = $this->smbNames($abs);
if ($names !== null) {
$result = [];
foreach ($names as $name) {
if (!self::isTemporary($name)) {
$result[] = substr($abs . '/' . $name, $cut);
}
}
sort($result);
return $result;
}
$dh = $this->storage->opendir($abs);
if ($dh === false) {
// An existing folder read as empty would look like a new repository.
throw new BackendException('Cannot list ' . $dir);
}
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
$result = [];
while (($name = readdir($dh)) !== false) {
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
@@ -166,6 +349,134 @@ final class StorageBackend implements IBackend {
return $result;
}
/**
* The names in an SMB folder, read in one listing; null for other storages, with access
* control lists on, or when the storage's internals are not as expected. The SMB storage's
* opendir() asks the server for the attributes of each entry to leave out hidden ones: with
* thousands of index files that took minutes. Hidden entries are still left out: names
* starting with a dot, and, after asking, any name a repository does not use (letters,
* digits, - and _), such as desktop.ini or Thumbs.db. The repository's own files have such
* names: they are not checked one by one, which was the cost.
*
* @return list<string>|null
*/
private function smbNames(string $abs): ?array {
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
return null;
}
try {
/** @var list<string>|null */
return (function (string $path): ?array {
/** @psalm-suppress UndefinedThisPropertyFetch */
if ($this->checkAcl) {
return null;
}
$names = [];
/** @psalm-suppress UndefinedThisPropertyFetch, UndefinedMethod */
foreach ($this->share->dir(ltrim($this->buildPath($path), '/')) as $info) {
$name = (string)$info->getName();
/** @psalm-suppress UndefinedThisPropertyFetch */
if (!$this->showHidden && (str_starts_with($name, '.')
|| (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1 && $info->isHidden()))) {
continue;
}
$names[] = $name;
}
return $names;
})->call($this->storage, $abs);
} catch (\Throwable) {
return null; // another SMB storage version, or an error: the usual way reports it
}
}
/**
* Run $op; when it throws, or $failed says its result may come from a dead connection, and
* the SFTP connection turns out to be dead, run it once more on a new connection.
*
* @template T
* @param \Closure():T $op
* @param (\Closure(T):bool)|null $failed
* @return T
*/
private function call(\Closure $op, ?\Closure $failed = null): mixed {
$this->idleCheck();
try {
$result = $op();
} catch (\Throwable $e) {
if (!$this->reopenIfDead()) {
throw $e;
}
return $op();
}
if ($failed !== null && $failed($result) && $this->reopenIfDead()) {
return $op();
}
return $result;
}
/**
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
* quiet for a few minutes, while phpseclib still takes it for open; files_external never
* builds a new one, and every request on it fails as if the file were missing. So after
* such a pause the connection is checked (one round trip) before it is used again.
*/
private function idleCheck(): void {
$now = microtime(true);
if ($this->reconnect !== null && $this->sftpReads && $now - $this->lastUsed >= self::IDLE_CHECK
&& $this->alive(true) === false) {
$this->reopen();
}
$this->lastUsed = microtime(true);
}
private function reopenIfDead(): bool {
if ($this->reconnect === null || !$this->sftpReads || $this->alive(false) !== false) {
return false;
}
$this->reopen();
return true;
}
/**
* Whether the storage's SFTP connection is alive; null when it cannot tell.
* $ask: send a request instead of trusting what phpseclib saw last.
*/
private function alive(bool $ask): ?bool {
try {
if (!is_callable([$this->storage, 'getConnection'])) {
return null;
}
$sftp = $this->storage->getConnection();
if (!is_object($sftp) || !is_callable([$sftp, 'isConnected'])) {
return null;
}
if (!$sftp->isConnected()) {
return false;
}
if (!$ask || !is_callable([$sftp, 'stat'])) {
return true;
}
if (is_callable([$sftp, 'clearStatCache'])) {
$sftp->clearStatCache(); // or stat() answers from memory
}
$sftp->stat('.');
return (bool)$sftp->isConnected();
} catch (\Throwable) {
return false;
}
}
private function reopen(): void {
/** @var \Closure():IStorage $reconnect */
$reconnect = $this->reconnect;
$this->storage = $reconnect();
$this->reconnects++;
$this->lastUsed = microtime(true);
if (function_exists('OCP\Log\logger')) {
\OCP\Log\logger('ng_backup')->info('The SFTP connection to the backup location was lost; reconnected');
}
}
private function listInto(string $dir, array &$result): void {
if (!$this->storage->is_dir($dir)) {
return;
+5 -1
View File
@@ -34,7 +34,11 @@ final class BackupJob extends TimedJob {
private LoggerInterface $logger,
) {
parent::__construct($time);
$this->setInterval(300);
// Well below the usual 5-minute cron interval: the last run is stamped a moment after cron
// starts, so with 300 the next cron run came a few seconds too early and the job ran only
// every other time, idle for 6 of every 10 minutes during a backup. When nothing runs, the
// job only does a few cheap database checks.
$this->setInterval(60);
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
}
+74
View File
@@ -0,0 +1,74 @@
<?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\Db\Run;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\BackupService;
use OCP\AppFramework\Db\DoesNotExistException;
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 CancelRun extends Command {
public function __construct(
private RunMapper $runs,
private BackupService $backups,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:cancel')
->setDescription('Stop a running backup')
->addArgument('run', InputArgument::REQUIRED, 'Run number, as shown by backup:status')
->setHelp("No new step of the run starts; the step in progress finishes first (usually within a
"
. "minute), so a backup that is just being completed is not cut off. The process working on the
"
. "run (occ backup:run, or cron) then stops. A backup started by an earlier version is marked
"
. "cancelled after two minutes and stops at the end of its step. What it already stored is
"
. "removed by the next cleanup.");
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$id = (string)$input->getArgument('run');
if (!ctype_digit($id)) {
$output->writeln('<error>Give the run number, as shown by backup:status.</error>');
return 1;
}
try {
$run = $this->runs->find((int)$id);
} catch (DoesNotExistException) {
$output->writeln("<error>There is no run $id.</error>");
return 1;
}
if ($run->getKind() !== BackupService::KIND_FULL) {
$output->writeln("<error>Run $id is not a backup; only backups can be cancelled.</error>");
return 1;
}
if ($run->getStatus() === Run::RUNNING) {
$output->writeln("Waiting for the current step of run $id to finish...");
}
try {
$run = $this->backups->cancel($run);
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
if ($run->getStatus() !== Run::CANCELLED) {
$output->writeln("Run $id is not running (status: {$run->getStatus()}); nothing to cancel.");
return 0;
}
$output->writeln("Run $id is cancelled.");
return 0;
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ HELP);
$pass = rtrim((string)@file_get_contents($file), "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
$q = (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
if ($pass !== $again) {
+1 -1
View File
@@ -88,7 +88,7 @@ HELP;
return rtrim((string)@file_get_contents($file), "\r\n");
}
$h = $this->getHelper('question');
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false));
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false));
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
throw new \InvalidArgumentException('The passphrases do not match');
}
+41 -6
View File
@@ -10,22 +10,29 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Db\RunMapper;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\VerifyService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Status extends Command {
/** Runs of the last days that are listed by default (failed ones stay visible that long). */
public const RECENT_DAYS = 7;
public function __construct(
private KeyService $keys,
private TargetService $targets,
private RunMapper $runs,
private VerifyService $verify,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs')
->addOption('all', null, InputOption::VALUE_NONE, 'List all runs, not only those of the last ' . self::RECENT_DAYS . ' days');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
@@ -42,15 +49,43 @@ final class Status extends Command {
}
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
$rows = [];
foreach ($this->runs->findRecent(10) as $r) {
$all = (bool)$input->getOption('all');
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
foreach ($shown as $r) {
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''),
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? \OCA\NgBackup\Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
}
if ($rows !== []) {
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'size', 'uploaded', 'error'])->setRows($rows)->render();
}
$older = $all ? 0 : $this->runs->countAll() - count($shown);
if ($older > 0) {
$output->writeln(sprintf('%d older run%s not shown (--all)', $older, $older === 1 ? '' : 's'));
}
foreach ($this->verify->progress() as $targetId => $state) {
$output->writeln(sprintf('Verify of %s on %s: %s', mb_strimwidth((string)($state['what'] ?? ''), 0, 40, '…'),
$names[$targetId] ?? '#' . $targetId, VerifyService::describe($state)));
}
return 0;
}
/** " 68%", or " ~68%" when estimated from the file cache; empty without an estimate */
private static function size(int $bytes): string {
$b = (float)$bytes;
foreach (['B', 'KiB', 'MiB', 'GiB'] as $u) {
if ($b < 1024) {
return $u === 'B' ? "$bytes B" : sprintf('%.1f %s', $b, $u);
}
$b /= 1024.0;
}
return sprintf('%.1f TiB', $b);
}
private static function percent(\OCA\NgBackup\Db\Run $r): string {
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
return $p === null ? '' : sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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\AlertService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class TargetEdit extends Command {
public function __construct(
private TargetService $targets,
private AlertService $alerts,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:edit')
->setDescription('Change the connection settings or the name of a backup location, keeping its backups')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addOption('name', null, InputOption::VALUE_REQUIRED, 'New name')
->addOption('backend', 'b', InputOption::VALUE_REQUIRED, 'New files_external backend id (see backup:target:backends)')
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'New auth mechanism id')
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Option key=value to change (repeatable); other options keep their value')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'New folder on the location')
->setHelp(<<<'HELP'
The new settings must lead to the same repository as before, so the history of the location is
kept. Use it after a password change, a new host name, or after moving the repository.
Example:
occ backup:target:edit nas -o password=new-secret
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$t = $this->targets->get($input->getArgument('target'));
$options = $this->targets->options($t);
if ($input->getOption('backend') !== null || $input->getOption('auth') !== null) {
$options = []; // a different connection type has its own options
}
foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) {
$output->writeln("<error>Option '$pair' must be key=value</error>");
return 1;
}
$k = (string)strstr($pair, '=', true);
$v = substr($pair, strlen($k) + 1);
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
}
$before = $t->getName();
$t = $this->targets->update($t, $input->getOption('name') ?? $t->getName(), $input->getOption('backend') ?? $t->getBackend(),
$input->getOption('auth') ?? $t->getAuth(), $options, $input->getOption('path') ?? $t->getBasePath());
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$this->alerts->securityEvent('target_changed', ['target' => $before]);
$output->writeln(sprintf('Location <info>%s</info> updated; repository %s found with the new settings.', $t->getName(), (string)$t->getRepositoryId()));
return 0;
}
}
+8 -2
View File
@@ -37,6 +37,9 @@ blob is also downloaded and decrypted, which fails loudly on exactly that even i
lists it -- the same verification an actual restore would do, run here deliberately instead of
waiting to find out during one.
While it runs it prints how far it is every 30 seconds; occ backup:status shows the same, and
how the last check of each location ended.
Run this periodically (cron, picking a sample of recent snapshots) rather than only right before
you'd need to rely on a restore.
@@ -51,9 +54,12 @@ HELP);
$target = $this->targets->get($input->getArgument('target'));
$ref = (string)$input->getArgument('snapshot');
$deep = (bool)$input->getOption('deep');
$onProgress = static function (array $state) use ($output): void {
$output->writeln(date('H:i:s') . ' ' . VerifyService::describe($state));
};
$result = str_starts_with($ref, 'users/')
? $this->verify->verifyUserExport($target, $ref, $deep)
: $this->verify->verify($target, $ref, $deep);
? $this->verify->verifyUserExport($target, $ref, $deep, $onProgress)
: $this->verify->verify($target, $ref, $deep, $onProgress);
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
+29 -3
View File
@@ -51,14 +51,16 @@ final class AdminApiController extends Controller {
parent::__construct(Application::APP_ID, $request);
}
public function status(): JSONResponse {
/** @param bool $allRuns all runs instead of those of the last days (Status::RECENT_DAYS) */
public function status(bool $allRuns = false): JSONResponse {
$targets = [];
foreach ($this->targets->list() as $t) {
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
}
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
$shown = $this->runs->findShown($allRuns ? null : time() - \OCA\NgBackup\Command\Status::RECENT_DAYS * 86400);
$runs = array_map(fn (Run $r) => $this->runJson($r), $shown);
return new JSONResponse([
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
@@ -68,6 +70,8 @@ final class AdminApiController extends Controller {
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
'targets' => $targets,
'runs' => $runs,
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
]);
}
@@ -181,6 +185,28 @@ final class AdminApiController extends Controller {
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
}
/** The stored settings for the edit form; password values are left out (null). */
#[PasswordConfirmationRequired]
public function targetSettings(int $id): JSONResponse {
$t = $this->targets->get((string)$id);
return new JSONResponse(['name' => $t->getName(), 'backend' => $t->getBackend(), 'auth' => $t->getAuth(),
'options' => $this->targets->editableOptions($t), 'path' => $t->getBasePath()]);
}
/** Options with a null value keep their stored value. */
#[PasswordConfirmationRequired]
public function updateTarget(int $id, string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
try {
$t = $this->targets->get((string)$id);
$before = $t->getName();
$t = $this->targets->update($t, $name, $backend, $auth, $options, $path);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$this->alerts->securityEvent('target_changed', ['target' => $before]);
return new JSONResponse(['id' => $t->getId()]);
}
/** Size limit in GiB; null or 0 = unlimited. */
#[PasswordConfirmationRequired]
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
@@ -283,6 +309,6 @@ final class AdminApiController extends Controller {
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
'stats' => json_decode($r->getStats() ?? 'null', true)];
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
}
}
+72 -7
View File
@@ -30,19 +30,84 @@ final class DbIndexCache implements IIndexCache {
}
public function put(string $repositoryId, string $packId, array $entries): void {
try {
$this->insert($repositoryId, $packId, $entries);
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
/**
* In one transaction: on PostgreSQL with its default settings every statement of its own
* waits for a disk sync, which made 27,547 rows take more than 6 minutes. Rows already there
* are replaced. Should the transaction fail (another process writing the same rows), the
* rows are written one by one, keeping rows that are already there.
*/
public function putMany(string $repositoryId, array $packs): void {
if ($packs === []) {
return;
}
try {
$this->transaction(function () use ($repositoryId, $packs): void {
$this->remove($repositoryId, array_keys($packs));
$this->insertAll($repositoryId, $packs);
});
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
throw $e; // a transaction of the caller's, which may be spoilt now
}
foreach ($packs as $packId => $entries) {
$this->put($repositoryId, $packId, $entries);
}
}
}
/** All or nothing: when it fails, the cache stays as it was. */
public function replaceAll(string $repositoryId, array $packs): void {
$this->transaction(function () use ($repositoryId, $packs): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
$qb->executeStatement();
$this->insertAll($repositoryId, $packs);
});
}
/** Run $write in a transaction of its own, or as part of the caller's when there is one. */
private function transaction(\Closure $write): void {
if ($this->db->inTransaction()) {
$write();
return;
}
$this->db->beginTransaction();
try {
$write();
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
/** @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs */
private function insertAll(string $repositoryId, array $packs): void {
foreach ($packs as $packId => $entries) {
$this->insert($repositoryId, $packId, $entries);
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function insert(string $repositoryId, string $packId, array $entries): void {
$qb = $this->db->getQueryBuilder();
$qb->insert('ngb_index')->values([
'repository_id' => $qb->createNamedParameter($repositoryId),
'pack_id' => $qb->createNamedParameter($packId),
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
]);
try {
$qb->executeStatement();
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
$qb->executeStatement();
}
public function remove(string $repositoryId, array $packIds): void {
+64
View File
@@ -17,6 +17,36 @@ final class RunMapper extends QBMapper {
parent::__construct($db, 'ngb_runs', Run::class);
}
/**
* Write $run only if it is still running and unchanged since it was loaded (updated_at
* $loadedAt), in one statement: two processes that loaded the same run cannot both write it.
* The new updated_at is always later than $loadedAt, so every write is seen as a change.
*
* @return Run|null the written run, or null when another process wrote it first
*/
public function updateIfUnchanged(Run $run, int $loadedAt): ?Run {
$run->setUpdatedAt(max(time(), $loadedAt + 1));
$types = $run->getFieldTypes();
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName());
foreach (array_keys($run->getUpdatedFields()) as $field) {
if ($field === 'id') {
continue;
}
$value = $run->{'get' . ucfirst($field)}();
$type = ($types[$field] ?? 'string') === 'integer' ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR;
$qb->set($run->propertyToColumn($field), $qb->createNamedParameter($value, $value === null ? IQueryBuilder::PARAM_NULL : $type));
}
$qb->where($qb->expr()->eq('id', $qb->createNamedParameter($run->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)))
->andWhere($qb->expr()->eq('updated_at', $qb->createNamedParameter($loadedAt, IQueryBuilder::PARAM_INT)));
if ($qb->executeStatement() !== 1) {
return null;
}
$run->resetUpdatedFields();
return $run;
}
public function find(int $id): Run {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
@@ -43,6 +73,40 @@ final class RunMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* The runs to list, newest first: those started at or after $since, those still running, and
* at least the newest $atLeast, so the list is never empty after a quiet week. All runs when
* $since is null.
*
* @return list<Run>
*/
public function findShown(?int $since, int $atLeast = 5): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC');
if ($since === null) {
return $this->findEntities($qb);
}
$qb->where($qb->expr()->orX(
$qb->expr()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)),
));
$runs = [];
foreach ([...$this->findEntities($qb), ...$this->findRecent($atLeast)] as $r) {
$runs[$r->getId()] = $r;
}
krsort($runs);
return array_values($runs);
}
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'n'))->from($this->getTableName());
$result = $qb->executeQuery();
$n = (int)$result->fetchOne();
$result->closeCursor();
return $n;
}
/** @return list<Run> runs of $kind started at or after $since, newest first */
public function startedSince(int $targetId, string $kind, int $since): array {
$qb = $this->db->getQueryBuilder();
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup;
final class Format {
/** "12:10:13", or "1d 12:10:13" from a day on (a long first backup takes days). */
public static function duration(int $seconds): string {
$seconds = max(0, $seconds);
$days = intdiv($seconds, 86400);
$time = gmdate('H:i:s', $seconds % 86400);
return $days > 0 ? $days . 'd ' . $time : $time;
}
}
+38 -6
View File
@@ -140,26 +140,58 @@ final class BackupRun {
$state['parentPos'] = $parent->position();
$state['stats'] = $stats;
if ($finishedWalk) {
// The snapshot is written in a step of its own, with its own time: after a long
// run it reads back thousands of segments, and the run reports that it got here.
$state['phase'] = 'finish';
}
return $state;
}
if ($state['phase'] === 'finish') {
$segments = $state['segments'];
// Every segment ends with a newline, so they read as one stream: fetched together,
// several at a time where the location allows it.
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
'warnings' => self::warnings($state)] + $state['meta'],
(function () use ($repo, $segments) {
foreach ($segments as $blobs) {
yield from $repo->readLines($blobs);
}
})());
$repo->readLines(array_values(array_merge(...$state['segments']))));
$state['phase'] = 'commit';
}
return $state;
}
/**
* Progress of the files phase as a fraction (at most 0.99 until it is done), or null without an
* estimate. Bytes of a large file that is still being read count too.
*
* @return array{fraction: float, approximate: bool}|null
*/
public static function progress(array $state): ?array {
$estimate = $state['estimate'] ?? null;
if (($state['phase'] ?? null) !== 'files' || !is_array($estimate) || ($estimate['bytes'] ?? 0) <= 0) {
return null;
}
$done = (int)($state['stats']['bytes'] ?? 0) + (int)($state['cur']['offset'] ?? 0);
return ['fraction' => min(0.99, $done / (int)$estimate['bytes']), 'approximate' => (bool)($estimate['approximate'] ?? true)];
}
/**
* progress() of a run, while it is in its files phase.
*
* @return array{fraction: float, approximate: bool}|null
*/
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') {
return null;
}
try {
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
}
/**
* A file or directory that could not be read: it is missing from this snapshot. Remembered (first
* 1000, deduplicated across steps) so the run can report it instead of silently succeeding.
+5 -1
View File
@@ -51,7 +51,10 @@ final class Notifier implements INotifier {
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
break;
case 'backup_stale':
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
// 'never' was stored by 0.9.4 and earlier
$notification->setParsedSubject(in_array($p['since'] ?? '', ['', 'never'], true)
? $l->t('No successful backup to %s yet', [$p['target']])
: $l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
break;
case 'security_event':
@@ -59,6 +62,7 @@ final class Notifier implements INotifier {
$notification->setParsedSubject(match ($p['event'] ?? '') {
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
'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'] ?? '']),
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
+61 -22
View File
@@ -28,6 +28,8 @@ final class BlobIndex {
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
private ?array $heldCacheWrites = null;
private string $repositoryId = '';
/** Index files written to the cache per database transaction while loading. */
private const CACHE_LOT = 256;
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
public int $downloaded = 0;
@@ -87,47 +89,75 @@ final class BlobIndex {
* and only the index files that are not cached yet are downloaded. Cached packs that no
* longer exist on the backend are dropped from the cache.
*/
public function load(bool $verify = false): void {
public function load(bool $verify = false, ?callable $progress = null): void {
$this->blobs = [];
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
$present = [];
$this->downloaded = 0;
$order = []; // pack ids in listing order: where a blob is in two packs, the last listed wins
$fetch = [];
foreach ($this->backend->listFiles('index') as $path) {
$packId = basename($path);
$present[$packId] = true;
if (isset($cached[$packId])) {
$entries = $cached[$packId];
} else {
$fh = $this->backend->get($path);
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
fclose($fh);
$this->downloaded++;
$this->cache?->put($this->repositoryId, $packId, $entries);
}
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
$order[] = $packId;
if (!isset($cached[$packId])) {
$fetch[] = $path;
}
}
if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified.
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
foreach ($this->packs() as $packId => $_) {
$entries = [];
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
if ($pack === $packId) {
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
}
// The rest is downloaded several at a time where the location allows it: on a large
// repository these are tens of thousands of small files. getMany() keeps their order, so
// the cached packs listed before each one are added first.
$next = 0;
$total = count($fetch);
$toCache = []; // written to the cache a few hundred at a time, each lot in one transaction
foreach ($this->backend->getMany($fetch) as $path => $data) {
$packId = basename($path);
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
$this->downloaded++;
if ($this->cache !== null) {
$toCache[$packId] = $entries;
// a check writes the whole cache afresh at the end
if (!$verify && count($toCache) >= self::CACHE_LOT) {
$this->cache->putMany($this->repositoryId, $toCache);
$toCache = [];
}
$this->cache->put($this->repositoryId, $packId, $entries);
}
for (; $next < count($order) && $order[$next] !== $packId; $next++) {
$this->add($order[$next], $cached[$order[$next]] ?? []);
}
$this->add($packId, $entries);
$next++;
if ($progress !== null) {
$progress($this->downloaded, $total);
}
}
for (; $next < count($order); $next++) {
$this->add($order[$next], $cached[$order[$next]] ?? []);
}
if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified, as the index files list it: a blob in
// two packs is in both rows, so that removing one of the packs leaves the other right.
// In one transaction: one row at a time took more than 6 minutes on a 1 TB repository
// (27,547 packs), long enough for the location to drop the connection.
$this->cache->replaceAll($this->repositoryId, $toCache);
return;
}
if ($toCache !== []) {
$this->cache?->putMany($this->repositoryId, $toCache);
}
$stale = array_keys(array_diff_key($cached, $present));
if ($stale !== []) {
$this->cache?->remove($this->repositoryId, $stale);
}
}
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
private function add(string $packId, array $entries): void {
foreach ($entries as $e) {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
}
}
public function has(string $id): bool {
return isset($this->blobs[$id]) || isset($this->pending[$id]);
}
@@ -181,6 +211,15 @@ final class BlobIndex {
}
}
/** Number of packs that hold blobs of the index. */
public function packCount(): int {
$packs = [];
foreach ($this->blobs as [$pack]) {
$packs[$pack] = true;
}
return count($packs);
}
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
public function storedBytes(): int {
$total = 0;
+14
View File
@@ -15,6 +15,20 @@ interface IIndexCache {
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
public function put(string $repositoryId, string $packId, array $entries): void;
/**
* put() for many packs at once (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function putMany(string $repositoryId, array $packs): void;
/**
* Replace everything cached for the repository with $packs (one database transaction).
*
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
*/
public function replaceAll(string $repositoryId, array $packs): void;
/** @param list<string> $packIds */
public function remove(string $repositoryId, array $packIds): void;
}
+10
View File
@@ -28,11 +28,21 @@ final class PackReaderGuard implements IBackend {
return $this->inner->get($path);
}
public function getMany(array $paths): \Generator {
$this->reader->close();
return $this->inner->getMany($paths);
}
public function getRange(string $path, int $offset, int $length): string {
$this->reader->close();
return $this->inner->getRange($path, $offset, $length);
}
public function getRanges(array $ranges): \Generator {
$this->reader->close();
return $this->inner->getRanges($ranges);
}
public function exists(string $path): bool {
$this->reader->close();
return $this->inner->exists($path);
+138 -19
View File
@@ -28,8 +28,14 @@ final class Repository {
public const FORMAT = 1;
public const BLOB_SIZE = 4 * 1048576;
private const FLAG_DEFLATE = 1;
/** Blobs from this size on get a quick compression test before the full compression. */
private const DEFLATE_PROBE_FROM = 256 * 1024;
/** Bytes of each of the three slices (start, middle, end) in that test. */
private const DEFLATE_PROBE_SLICE = 32 * 1024;
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
private const VERIFY_READ_ATTEMPTS = 3;
/** Largest read that loadBlobs() makes of blobs that follow each other in a pack. */
private const MERGED_READ_MAX = 16 * 1048576;
private StreamCipher $cipher;
private BlobIndex $index;
@@ -37,6 +43,8 @@ final class Repository {
private ?int $maxBytes = null;
private Catalog $catalog;
private PackReader $packReader;
/** verify() lists the pack folders from this many packs on (see presentPacks()); tests lower it. */
public int $listPacksAbove = 256;
private function __construct(
private IBackend $backend,
@@ -286,8 +294,8 @@ final class Repository {
*/
public function readLines(array $blobIds): \Generator {
$carry = '';
foreach ($blobIds as $id) {
$data = $carry . $this->loadBlob($id);
foreach ($this->loadBlobs($blobIds) as $blob) {
$data = $carry . $blob;
$lines = explode("\n", $data);
$carry = array_pop($lines);
yield from $lines;
@@ -699,7 +707,7 @@ final class Repository {
}
$flags = 0;
$payload = $data;
$deflated = gzdeflate($data, 6);
$deflated = self::worthDeflating($data) ? gzdeflate($data, 6) : false;
if ($deflated !== false && strlen($deflated) < strlen($data) * 0.95) {
$payload = $deflated;
$flags |= self::FLAG_DEFLATE;
@@ -709,6 +717,67 @@ final class Repository {
return $id;
}
/**
* Photos, video and archives are compressed already: deflating them takes most of a backup's
* CPU time (about 24 MiB/s) only to be thrown away, as it saves less than the 5% that keeps
* it. A fast deflate of three small slices tells first; below 10% savings there, the full
* deflate is skipped.
*/
private static function worthDeflating(string $data): bool {
$n = strlen($data);
if ($n < self::DEFLATE_PROBE_FROM) {
return true;
}
$k = self::DEFLATE_PROBE_SLICE;
$probe = substr($data, 0, $k) . substr($data, intdiv($n - $k, 2), $k) . substr($data, $n - $k);
$deflated = gzdeflate($probe, 1);
return $deflated !== false && strlen($deflated) * 10 < strlen($probe) * 9;
}
/**
* Blobs spread over many packs (the tree segments of a run, one per step), through
* IBackend::getRanges(): on a remote location one at a time is a round trip each, which for
* a long run made writing its snapshot take a quarter of an hour. Blobs that follow each
* other in a pack are read as one range.
*
* @param list<string> $ids
* @return \Generator<int, string> verified blob contents, in order
*/
private function loadBlobs(array $ids): \Generator {
/** @var list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] of each read */
$ranges = [];
/** @var list<list<array{0:string, 1:int, 2:int}>> $parts per read: [blob id, offset in the read, length] */
$parts = [];
// the read being extended
$curPath = null;
$curOffset = 0;
$curLength = 0;
$curParts = [];
foreach ($ids as $id) {
[$pack, $offset, $length] = $this->index->get($id);
$path = 'packs/' . substr($pack, 0, 2) . '/' . $pack;
if ($curPath === $path && $curOffset + $curLength === $offset && $curLength + $length <= self::MERGED_READ_MAX) {
$curParts[] = [$id, $curLength, $length];
$curLength += $length;
continue;
}
if ($curPath !== null) {
$ranges[] = [$curPath, $curOffset, $curLength];
$parts[] = $curParts;
}
[$curPath, $curOffset, $curLength, $curParts] = [$path, $offset, $length, [[$id, 0, $length]]];
}
if ($curPath !== null) {
$ranges[] = [$curPath, $curOffset, $curLength];
$parts[] = $curParts;
}
foreach ($this->backend->getRanges($ranges) as $i => $data) {
foreach ($parts[$i] as [$id, $at, $length]) {
yield $this->decodeBlob($id, substr($data, $at, $length));
}
}
}
private function loadBlob(string $id): string {
[$pack, $offset, $length] = $this->index->get($id);
return $this->decodeBlob($id, $this->packReader->read($pack, $offset, $length));
@@ -750,17 +819,23 @@ final class Repository {
* referenced by more than one file is only checked once; a missing database manifest is a
* failure, not something to silently skip over.
*
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
* callers use it to keep a time-boxed lease alive during a large audit
* @param ?callable(array{phase:string, done:int, total:?int, blobs?:int, bytes?:int}):void $heartbeat
* called often during the check, with how far it is: phase "index" (index files
* downloaded), "packs" (pack folders listed), "files" and "database" (files of the
* snapshot checked); callers use it to keep a time-boxed lease alive and to report
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
*/
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
$heartbeat ??= static function (): void {
$heartbeat ??= static function (array $progress): void {
};
$this->index->load(true);
$meta = $this->snapshotMeta($snapshotId);
$filesTotal = isset($meta['stats']['files']) ? (int)$meta['stats']['files'] : null;
$this->index->load(true, static function (int $done, int $total) use ($heartbeat): void {
$heartbeat(['phase' => 'index', 'done' => $done, 'total' => $total]);
});
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
$seen = [];
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
[$packSeen, $listedDirs] = $this->presentPacks($heartbeat);
foreach ($this->streamEntries($snapshotId) as $entry) {
$stats['filesChecked']++;
foreach ($entry['b'] as $id) {
@@ -768,11 +843,11 @@ final class Repository {
continue;
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
$heartbeat(['phase' => 'files', 'done' => $stats['filesChecked'], 'total' => $filesTotal,
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
}
}
$meta = $this->snapshotMeta($snapshotId);
if (isset($meta['db']) && is_string($meta['db'])) {
if (!$this->backend->exists($meta['db'])) {
$stats['missing'][] = $meta['db'];
@@ -784,8 +859,9 @@ final class Repository {
continue;
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
$heartbeat(['phase' => 'database', 'done' => $stats['filesChecked'], 'total' => $filesTotal,
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
}
}
}
@@ -793,6 +869,40 @@ final class Repository {
return $stats;
}
/**
* Which packs are on the location, from one listing per pack folder instead of a request per
* pack. A folder that cannot be listed, or comes back empty (which a storage also reports when
* it could not look), is left out: its packs are then checked one by one. With no more packs
* than folders nothing is listed: a request per pack is fewer requests then, and on FTP every
* request takes a data connection, of which a server may have only a few.
*
* @return array{0: array<string, bool>, 1: array<string, true>} pack id => present, and the
* folders that were listed
*/
private function presentPacks(callable $heartbeat): array {
$present = [];
$listed = [];
if ($this->index->packCount() <= $this->listPacksAbove) {
return [$present, $listed];
}
for ($i = 0; $i < 256; $i++) {
$dir = sprintf('%02x', $i);
try {
$files = $this->backend->listFiles('packs/' . $dir);
} catch (BackendException) {
$files = [];
}
foreach ($files as $path) {
$present[basename($path)] = true;
}
if ($files !== []) {
$listed[$dir] = true;
}
$heartbeat(['phase' => 'packs', 'done' => $i + 1, 'total' => 256]);
}
return [$present, $listed];
}
/**
* Same checks as verify(), for a user export: every blob its manifest references must be in
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
@@ -803,9 +913,11 @@ final class Repository {
if (!$this->isRecordedUserExport($manifestPath)) {
throw new RepositoryException("$manifestPath is not a recorded user export for this location");
}
$heartbeat ??= static function (): void {
$heartbeat ??= static function (array $progress): void {
};
$this->index->load(true);
$this->index->load(true, static function (int $done, int $total) use ($heartbeat): void {
$heartbeat(['phase' => 'index', 'done' => $done, 'total' => $total]);
});
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
$seen = [];
$packSeen = [];
@@ -821,20 +933,27 @@ final class Repository {
}
$seen[$id] = true;
$this->verifyBlob($id, $deep, $stats, $packSeen);
$heartbeat();
$heartbeat(['phase' => 'files', 'done' => $stats['filesChecked'], 'total' => null,
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
}
}
return $stats;
}
/** @param array<string, bool> $packSeen */
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen): void {
/**
* @param array<string, bool> $packSeen pack id => present; filled in as packs are checked
* @param array<string, true> $listedDirs pack folders whose listing is in $packSeen: a pack
* not in it is not there
*/
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen, array $listedDirs = []): void {
if (!$this->index->has($id)) {
$stats['missing'][] = $id;
return;
}
[$pack] = $this->index->get($id);
$packExists = $packSeen[$pack] ??= $this->backend->exists('packs/' . substr($pack, 0, 2) . '/' . $pack);
$packExists = $packSeen[$pack] ??= isset($listedDirs[substr($pack, 0, 2)])
? false
: $this->backend->exists('packs/' . substr($pack, 0, 2) . '/' . $pack);
if (!$packExists) {
$stats['missing'][] = $id;
return;
+1 -1
View File
@@ -104,7 +104,7 @@ final class AlertService {
}
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : '']);
}
private function notifyAdmins(string $subject, string $objectId, array $params): void {
+205 -23
View File
@@ -14,9 +14,12 @@ use OCA\NgBackup\Db\Snapshot;
use OCA\NgBackup\Db\SnapshotMapper;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Job\BackupRun;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Repository\RepositoryException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
@@ -32,6 +35,9 @@ use Psr\Log\LoggerInterface;
final class BackupService {
public const KIND_FULL = 'full';
/** @var \WeakMap<Repository, string> the location settings each kept repository was opened with */
private \WeakMap $openedWith;
public function __construct(
private TargetService $targets,
private KeyService $keys,
@@ -45,6 +51,9 @@ final class BackupService {
private AlertService $alerts,
private LeaseService $leases,
) {
/** @var \WeakMap<Repository, string> $opened */
$opened = new \WeakMap();
$this->openedWith = $opened;
}
public function start(Target $target, string $label = ''): Run {
@@ -68,6 +77,46 @@ final class BackupService {
}
}
/**
* The expected size of the files phase, for the progress shown while it runs: the size of the
* previous snapshot, or for a first backup the users' files, versions and trash as Nextcloud's
* file cache knows them (approximate: other data in the data directory is not counted).
*
* @return array{bytes: int, approximate: bool}
*/
private function estimateBytes(Run $run, array $files): array {
if (($files['parent'] ?? null) !== null) {
try {
return ['bytes' => $this->snapshots->findOne($run->getTargetId(), (string)$files['parent'])->getBytes() ?? 0, 'approximate' => false];
} catch (\Throwable) {
}
}
try {
$qb = $this->db->getQueryBuilder();
$qb->select('numeric_id')->from('storages')
->where($qb->expr()->like('id', $qb->createNamedParameter('home::%')))
->orWhere($qb->expr()->like('id', $qb->createNamedParameter('object::user:%')));
$result = $qb->executeQuery();
$storages = [];
while (($id = $result->fetchOne()) !== false) {
$storages[] = (int)$id;
}
$result->closeCursor();
$bytes = 0;
foreach (array_chunk($storages, 500) as $chunk) {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->sum('size'))->from('filecache')
->where($qb->expr()->in('storage', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->in('path_hash', $qb->createNamedParameter(array_map('md5', ['files', 'files_versions', 'files_trashbin']), IQueryBuilder::PARAM_STR_ARRAY)))
->andWhere($qb->expr()->gt('size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
$bytes += (int)$qb->executeQuery()->fetchOne();
}
return ['bytes' => $bytes, 'approximate' => true];
} catch (\Throwable) {
return ['bytes' => 0, 'approximate' => true];
}
}
private function createRun(Target $target, string $label): Run {
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
if ($running !== []) {
@@ -102,10 +151,22 @@ final class BackupService {
/**
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
* run locked and returns immediately.
*
* @param Repository|null $repo the location's repository, opened by an earlier step of this run
* in this process (see runToCompletion()); set when this step opens it
*/
public function step(Run $run, float $deadline): Run {
public function step(Run $run, float $deadline, ?Repository &$repo = null): Run {
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
// A step can run well past its deadline (the commit of a large snapshot writes its tree and
// index), so the lease holds at least 15 minutes; should it still expire and another process
// take the run over, the checks below keep the two from overwriting each other.
$ttl = (int)min(3600, max(900, $deadline - microtime(true) + 600));
// No new step once a cancel has begun: it waits for the step in progress, if any.
try {
$this->leases->release($this->leases->acquire(self::cancelKey($run->getId()), LeaseService::SHARED, 60));
} catch (LockedException) {
return $run;
}
try {
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
} catch (LockedException) {
@@ -118,13 +179,28 @@ final class BackupService {
$this->leases->release($runLease);
return $run;
}
$loadedAt = null;
$savedBefore = $run->getUpdatedAt();
try {
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
return $run;
}
$loadedAt = $run->getUpdatedAt();
$target = $this->targets->get((string)$run->getTargetId());
$repo = $this->targets->repository($target);
// Reopen when another process took a step since ours (its packs are not in our copy of
// the index) or the location was edited (password, folder, size limit).
$settings = json_encode([$target->getBackend(), $target->getAuth(), $target->getOptions(),
$target->getBasePath(), $target->getRepositoryId(), $target->getMaxBytes()], JSON_THROW_ON_ERROR);
if ($repo === null || $loadedAt !== $savedBefore || ($this->openedWith[$repo] ?? null) !== $settings) {
// Opening reads the index listing, which takes minutes on a slow location with many
// packs: that time does not count, or each step would store one blob and stop.
$opening = microtime(true);
$repo = null;
$repo = $this->targets->repository($target);
$this->openedWith[$repo] = $settings;
$deadline += microtime(true) - $opening;
}
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
if ($run->getPhase() === 'db') {
@@ -147,7 +223,11 @@ final class BackupService {
$state['dump'] = $dump;
}
} else {
$state['files']['estimate'] ??= $this->estimateBytes($run, $state['files']);
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
if (in_array($state['files']['phase'], ['finish', 'commit'], true)) {
$run->setPhase('commit'); // all files read: writing and recording the snapshot
}
if ($state['files']['phase'] === 'commit') {
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
// export or restore may have written the catalog since this step opened $repo.
@@ -162,14 +242,20 @@ final class BackupService {
}
if ($state['files']['phase'] === 'done') {
$this->recordSnapshot($run, $state['files']);
$this->alerts->storedBytes($target, $repo->storedBytes());
$stats = $state['files']['stats'];
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
$warnings = BackupRun::warnings($state['files']);
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
// The snapshot is committed: what follows only reports on it and must not turn
// the run into a failure.
try {
$this->alerts->storedBytes($target, $repo->storedBytes());
$stats = $state['files']['stats'];
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
$warnings = BackupRun::warnings($state['files']);
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
}
} catch (\Throwable $e) {
$this->logger->warning('NG Backup run ' . $run->getId() . ': reporting on the finished backup failed: ' . $e->getMessage(), ['exception' => $e]);
}
$run->setStatus(Run::DONE);
$run->setPhase('done');
@@ -178,15 +264,33 @@ final class BackupService {
}
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
$run->setUpdatedAt(time());
return $this->runs->update($run);
// Another process may have taken the run over after this step's lease expired: then its
// writes stand and this step's are dropped.
$saved = $this->runs->updateIfUnchanged($run, $loadedAt);
if ($saved === null) {
$repo = null; // the run is another process's now: its packs are not in our index
return $this->runs->find($run->getId());
}
return $saved;
} catch (\Throwable $e) {
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$repo = null; // may hold blobs of a half-written pack: the next step opens it afresh
if ($loadedAt === null) {
// The run could not even be loaded: there is nothing fresh to mark as failed, and
// the caller's copy may be stale.
$this->logger->error('NG Backup run ' . $run->getId() . ': step could not load the run: ' . $e->getMessage(), ['exception' => $e]);
return $run;
}
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
$failed = $this->runs->updateIfUnchanged($run, $loadedAt);
if ($failed === null) {
// Another process that took the run over may have finished it: its result stands.
$this->logger->warning('NG Backup run ' . $run->getId() . ': a step that another process had taken over ended with: ' . $e->getMessage());
return $this->runs->find($run->getId());
}
$run = $failed;
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
try {
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
} catch (\Throwable) {
@@ -200,14 +304,16 @@ final class BackupService {
/** Run to completion in this process (occ, system cron), reporting progress. */
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
$repo = null; // opened once, by the first step, and kept for the next ones
while ($run->getStatus() === Run::RUNNING) {
$before = $run->getUpdatedAt();
$run = $this->step($run, microtime(true) + $stepSeconds);
$run = $this->step($run, microtime(true) + $stepSeconds, $repo);
if ($run->getStatus() === Run::RUNNING && $run->getUpdatedAt() === $before) {
// Step was not possible (another process holds the run, or a crashed step's lease has
// not expired yet): wait instead of spinning.
sleep(5);
$run = $this->runs->find($run->getId());
$repo = null; // another process may have stored packs in the meantime
}
if ($progress !== null) {
$progress($run);
@@ -216,14 +322,78 @@ final class BackupService {
return $run;
}
public function cancel(Run $run): Run {
$run->setStatus(Run::CANCELLED);
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
return $this->runs->update($run);
/**
* Stop a running backup. First no new step may start, then this waits (up to $wait seconds)
* for the step in progress, so a step that is recording the snapshot finishes first. A process
* running the backup then finds it cancelled and stops. The packs it stored stay until the
* next cleanup.
*
* A process of an earlier version does not hold back its next step and may never leave a gap:
* after $wait the run is marked cancelled while it works, unless it is recording the snapshot.
* Its step then fails to save and the process stops.
*/
public function cancel(Run $run, int $wait = 120): Run {
$id = $run->getId();
$until = time() + $wait;
$hold = null;
for ($try = 0; $hold === null; $try++) {
try {
$hold = $this->leases->acquire(self::cancelKey($id), LeaseService::EXCLUSIVE, $wait + 300);
} catch (LockedException) {
if ($try >= 20) {
throw new \RuntimeException("Run $id is being cancelled already");
}
usleep(100000); // a step checking whether it may start
}
}
try {
$lease = null;
while ($lease === null && time() < $until) {
try {
$lease = $this->leases->acquire('run/' . $id, LeaseService::EXCLUSIVE, 60);
} catch (LockedException) {
sleep(2);
}
}
try {
for ($try = 0; $try < 10; $try++) {
$run = $this->runs->find($id);
if ($run->getStatus() !== Run::RUNNING) {
return $run;
}
$state = json_decode($run->getState(), true) ?: [];
if ($lease === null && ($state['files']['phase'] ?? '') === 'commit') {
throw new \RuntimeException("Run $id is recording its snapshot; try again in a minute");
}
$loadedAt = $run->getUpdatedAt();
$run->setStatus(Run::CANCELLED);
$run->setFinishedAt(time());
if (($cancelled = $this->runs->updateIfUnchanged($run, $loadedAt)) !== null) {
return $cancelled;
}
usleep(200000); // a step of another process saved just now
}
throw new \RuntimeException("Run $id keeps changing; try again");
} finally {
if ($lease !== null) {
$this->leases->release($lease);
}
}
} finally {
$this->leases->release($hold);
}
}
private static function cancelKey(int $runId): string {
return 'run/' . $runId . '/cancel';
}
private function recordSnapshot(Run $run, array $files): void {
try {
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
return; // already recorded, by an earlier attempt at this step
} catch (DoesNotExistException) {
}
$s = new Snapshot();
$s->setTargetId($run->getTargetId());
$s->setSnapshotId($files['snapshot']);
@@ -232,7 +402,19 @@ final class BackupService {
$s->setCreatedAt(time());
$s->setFiles($files['stats']['files']);
$s->setBytes($files['stats']['bytes']);
$this->snapshots->insert($s);
try {
$this->snapshots->insert($s);
} catch (\OCP\DB\Exception $e) {
// Recorded by another process between the check above and this insert?
if ($e->getReason() !== \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
try {
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
} catch (DoesNotExistException) {
throw $e;
}
}
}
/** @return list<string> */
+8 -5
View File
@@ -31,6 +31,11 @@ final class KeyService {
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
public const DEFAULT_DELETE_DELAY = 7;
/**
* The location holds the passphrase-wrapped key too, so whoever can read it can try
* passphrases offline: the passphrase is what really protects the backups.
*/
public const MIN_PASSPHRASE = 16;
private ?KeyRing $cached = null;
@@ -56,9 +61,7 @@ final class KeyService {
if ($this->isInitialized()) {
throw new \RuntimeException('A backup key already exists');
}
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
}
self::checkPassphrase($passphrase);
$keys = KeyRing::generate();
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
@@ -234,8 +237,8 @@ final class KeyService {
}
private static function checkPassphrase(string $passphrase): void {
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
if (strlen($passphrase) < self::MIN_PASSPHRASE) {
throw new \InvalidArgumentException('The passphrase must be at least ' . self::MIN_PASSPHRASE . ' characters');
}
}
+104 -17
View File
@@ -17,6 +17,7 @@ use OCA\NgBackup\Db\TargetMapper;
use OCA\NgBackup\Repository\Repository;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Files\Storage\IStorage;
use OCP\Security\ICrypto;
use OCP\Server;
@@ -37,7 +38,7 @@ final class TargetService {
) {
}
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
/** @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array}> */
public function availableBackends(): array {
return $this->factory()->describeBackends();
}
@@ -50,20 +51,8 @@ final class TargetService {
* @return array{target: Target, created: bool}
*/
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
// Without the native extension files_external falls back to the smbclient binary, which
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
// up front instead of letting the admin find out from a failed run.
if ($backend === 'smb' && !extension_loaded('smbclient')) {
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
}
try {
$this->mapper->findByName($name);
throw new \InvalidArgumentException("A target named '$name' already exists");
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
}
$this->validateName($name);
$this->requireUsableBackend($backend);
$keys = $this->keys->keyRing();
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
if ($storageBackend->exists('config')) {
@@ -97,6 +86,103 @@ final class TargetService {
return ['target' => $target, 'created' => $created];
}
/**
* 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
* and a typo cannot silently start a new, empty repository. A null option value keeps the
* stored one, so the web interface never has to send stored passwords back.
*
* @param array<string, mixed> $options
*/
public function update(Target $target, string $name, string $backend, string $auth, array $options, string $basePath): Target {
$this->validateName($name, $target->getId());
$this->requireUsableBackend($backend);
$stored = $this->options($target);
foreach ($options as $key => $value) {
if ($value === null) {
if (array_key_exists($key, $stored)) {
$options[$key] = $stored[$key];
} else {
unset($options[$key]);
}
}
}
// Do not create the folder: a typo in the path must not leave an empty folder behind.
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
if (!$storageBackend->exists('config')) {
if (!$storageBackend->canConnect()) {
throw new \InvalidArgumentException('Cannot connect with these settings. Check the host, user name and password');
}
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'. To start an empty repository, add a new location instead");
}
$repo = Repository::openWithKey($storageBackend, $this->keys->keyRing(), $this->indexCache, $this->anchor);
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
throw new \InvalidArgumentException('These settings lead to a different repository than the one of this location');
}
$target->setName($name);
$target->setBackend($backend);
$target->setAuth($auth);
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
$target->setBasePath(trim($basePath, '/'));
return $this->mapper->update($target);
}
/**
* The stored options for an edit form: password-type values are replaced by null, which
* update() reads as "keep the stored value".
*
* @return array<string, mixed>
*/
public function editableOptions(Target $target): array {
$secret = [];
foreach ($this->availableBackends() as $b) {
if ($b['id'] !== $target->getBackend()) {
continue;
}
$params = $b['parameters'];
foreach ($b['auth'] as $a) {
if ($a['id'] === $target->getAuth()) {
$params += $a['parameters'];
}
}
foreach ($params as $key => $p) {
if (($p['type'] ?? 0) === 2) {
$secret[$key] = true;
}
}
}
$options = $this->options($target);
foreach ($options as $key => $value) {
if (isset($secret[$key])) {
$options[$key] = null;
}
}
return $options;
}
private function validateName(string $name, ?int $ownId = null): void {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
try {
$other = $this->mapper->findByName($name);
} catch (DoesNotExistException) {
return;
}
if ($other->getId() !== $ownId) {
throw new \InvalidArgumentException("A target named '$name' already exists");
}
}
private function requireUsableBackend(string $backend): void {
// Without the native extension files_external falls back to the smbclient binary, which
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
// up front instead of letting the admin find out from a failed run.
if ($backend === 'smb' && !extension_loaded('smbclient')) {
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
}
}
/**
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
@@ -193,8 +279,9 @@ final class TargetService {
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
}
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
$create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
return new StorageBackend($create(), $basePath, $createBase, $create);
}
private ?ExternalStorageFactory $factory = null;
+123 -13
View File
@@ -7,42 +7,152 @@ declare(strict_types=1);
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Db\Target;
use OCP\IAppConfig;
use OCP\Lock\LockedException;
/** Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). */
/**
* Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). On a large
* repository a check takes hours (a deep one days), so how far it is gets stored every
* PROGRESS_SECONDS, together with how it ended: `occ backup:status` shows it.
*/
final class VerifyService {
public const PROGRESS_SECONDS = 30;
private const KEY_PREFIX = 'verify_progress_';
public function __construct(
private TargetService $targets,
private LeaseService $leases,
private IAppConfig $appConfig,
) {
}
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
public function verify(Target $target, string $snapshotId, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
/**
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
*/
public function verify(Target $target, string $snapshotId, bool $deep = false, ?callable $onProgress = null): array {
return $this->run($target, $snapshotId, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
}
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
/**
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
*/
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false, ?callable $onProgress = null): array {
return $this->run($target, $manifestPath, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
}
private function run(Target $target, bool $deep, callable $fn): array {
/** One line on a check's progress or outcome, for occ (see progress()). */
public static function describe(array $s): string {
$size = static fn (int $b): string => $b >= 1073741824 ? sprintf('%.1f GiB', (float)$b / 1073741824.0) : sprintf('%.1f MiB', (float)$b / 1048576.0);
$count = static fn (int $done, mixed $total): string => number_format($done)
. (is_int($total) && $total > 0 ? '/' . number_format($total) . sprintf(' (%d%%)', intdiv(100 * $done, $total)) : '');
$deep = ($s['deep'] ?? false) === true;
$kind = $deep ? 'deep check' : 'check';
if (($s['status'] ?? '') === 'running') {
$phase = match ($s['phase'] ?? '') {
'index' => 'index files ' . $count((int)$s['done'], $s['total'] ?? null),
'packs' => 'pack folders ' . $count((int)$s['done'], $s['total'] ?? null),
'files', 'database' => 'files ' . $count((int)$s['done'], $s['total'] ?? null) . ', ' . number_format((int)($s['blobs'] ?? 0)) . ' blobs'
. ($deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
default => 'starting',
};
$age = time() - (int)($s['updated'] ?? 0);
return sprintf('%s running since %s: %s; updated %s ago%s', $kind, date('Y-m-d H:i', (int)$s['started']), $phase,
$age < 120 ? $age . ' s' : intdiv($age, 60) . ' min',
$age > 600 ? ' (no progress for a while: the process may have stopped)' : '');
}
$took = \OCA\NgBackup\Format::duration((int)($s['finished'] ?? 0) - (int)($s['started'] ?? 0));
return match ($s['status'] ?? '') {
'ok' => sprintf('%s OK on %s (took %s): %s files, %s blobs%s', $kind, date('Y-m-d H:i', (int)$s['finished']), $took,
number_format((int)($s['files'] ?? 0)), number_format((int)($s['blobs'] ?? 0)), $deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
'problems' => sprintf('%s found PROBLEMS on %s: %d missing, %d failed, %d unreadable blob(s); run occ backup:verify again for the details',
$kind, date('Y-m-d H:i', (int)$s['finished']), (int)($s['missing'] ?? 0), (int)($s['failedBlobs'] ?? 0), (int)($s['unreadable'] ?? 0)),
default => sprintf('%s FAILED on %s: %s', $kind, date('Y-m-d H:i', (int)($s['finished'] ?? 0)), (string)($s['error'] ?? 'unknown error')),
};
}
/**
* The stored state of the last check per location: running (with how far it is) or how it
* ended.
*
* @return array<int, array<string, mixed>> by location id
*/
public function progress(): array {
$result = [];
foreach ($this->appConfig->getKeys(Application::APP_ID) as $key) {
if (!str_starts_with($key, self::KEY_PREFIX)) {
continue;
}
$state = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '', lazy: true), true);
if (is_array($state)) {
$result[(int)substr($key, strlen(self::KEY_PREFIX))] = $state;
}
}
ksort($result);
return $result;
}
private function run(Target $target, string $what, bool $deep, ?callable $onProgress, callable $fn): array {
$ttl = $deep ? 600 : 300;
$state = ['what' => $what, 'deep' => $deep, 'status' => 'running', 'started' => time(), 'updated' => time(),
'pid' => getmypid(), 'phase' => 'starting', 'done' => 0, 'total' => null, 'blobs' => 0, 'bytes' => 0];
try {
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $deep, $ttl, $fn): array {
$last = time();
$heartbeat = function () use ($refresh, $ttl, &$last): void {
if (time() - $last >= intdiv($ttl, 2)) {
$this->store($target, $state);
} catch (\Throwable) {
// progress reports are a convenience; the check itself goes ahead
}
try {
$result = $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $ttl, $fn, $onProgress, &$state): array {
$lastRefresh = time();
$lastStore = time();
$heartbeat = function (?array $progress = null) use ($refresh, $ttl, $target, $onProgress, &$lastRefresh, &$lastStore, &$state): void {
$now = time();
if ($now - $lastRefresh >= intdiv($ttl, 2)) {
$refresh();
$last = time();
$lastRefresh = $now;
}
if ($progress !== null && $now - $lastStore >= self::PROGRESS_SECONDS) {
$state = array_merge($state, $progress, ['updated' => $now]);
try {
$this->store($target, $state);
} catch (\Throwable) {
// a progress report that cannot be stored must not stop the check
}
$lastStore = $now;
if ($onProgress !== null) {
$onProgress($state);
}
}
};
return $fn($this->targets->repository($target), $heartbeat);
});
} catch (LockedException) {
$this->finish($target, $state, 'failed', ['error' => 'A cleanup of the location was in progress']);
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
} catch (\Throwable $e) {
$this->finish($target, $state, 'failed', ['error' => $e->getMessage()]);
throw $e;
}
$problems = count($result['missing']) + count($result['failed']) + count($result['unreadable']);
$this->finish($target, $state, $problems === 0 ? 'ok' : 'problems', [
'phase' => 'done', 'files' => $result['filesChecked'], 'blobs' => $result['blobsChecked'], 'bytes' => $result['bytesChecked'],
'missing' => count($result['missing']), 'failedBlobs' => count($result['failed']), 'unreadable' => count($result['unreadable']),
]);
return $result;
}
private function finish(Target $target, array $state, string $status, array $extra): void {
try {
$this->store($target, array_merge($state, $extra, ['status' => $status, 'updated' => time(), 'finished' => time()]));
} catch (\Throwable) {
// the check's own result counts; its stored summary is a convenience
}
}
private function store(Target $target, array $state): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_PREFIX . $target->getId(), json_encode($state, JSON_THROW_ON_ERROR), lazy: true);
}
}
+1 -12
View File
@@ -218,16 +218,11 @@
</PropertyNotSetInConstructor>
</file>
<file src="lib/Job/BackupRun.php">
<ArgumentTypeCoercion>
<code><![CDATA[$blobs]]></code>
</ArgumentTypeCoercion>
<MissingParamType>
<code><![CDATA[$fh]]></code>
</MissingParamType>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$mtime]]></code>
<code><![CDATA[$state['phase']]]></code>
<code><![CDATA[$state['stats']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="lib/Job/ParentCursor.php">
@@ -395,7 +390,7 @@
</file>
<file src="lib/Service/BackupService.php">
<InvalidOperand>
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
<code><![CDATA[$deadline - microtime(true) + 600]]></code>
</InvalidOperand>
<UndefinedClass>
<code><![CDATA[\OC]]></code>
@@ -474,12 +469,6 @@
</RedundantCastGivenDocblockType>
</file>
<file src="lib/Service/TargetService.php">
<InvalidReturnStatement>
<code><![CDATA[$this->factory()->describeBackends()]]></code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[list<array{id:string, name:string, auth:list<string>, parameters:array}>]]></code>
</InvalidReturnType>
<UndefinedDocblockClass>
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
</UndefinedDocblockClass>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 227 KiB

+23 -2
View File
@@ -6,6 +6,10 @@
<div class="ng-backup">
<NcSettingsSection :name="t('ng_backup', 'NG Backup')"
:description="t('ng_backup', 'Encrypted, incremental backups of this Nextcloud (database, configuration and data). Backup locations are never mounted and are not visible in Files.')">
<p class="ngb-feedback">
{{ t('ng_backup', 'NG Backup is in beta. We would like to hear how it works for you, also when everything went fine.') }}
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'Share your experience ↗') }}</a>
</p>
<NcLoadingIcon v-if="!status" :size="32" />
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
</NcSettingsSection>
@@ -22,7 +26,7 @@
</template>
<template v-else-if="status.key.everConfirmed">
<template v-if="status.targets.length">
<Backups :status="status" @changed="refresh" />
<Backups :status="status" :all-runs="allRuns" @changed="refresh" @show-all-runs="showAllRuns" />
<RestoreBrowser :status="status" @changed="refresh" />
</template>
<NcNoteCard v-else type="info">{{ t('ng_backup', 'Add a backup location under "Settings" first.') }}</NcNoteCard>
@@ -45,15 +49,24 @@ import KeySetup from './components/KeySetup.vue'
import Locations from './components/Locations.vue'
import RestoreBrowser from './components/RestoreBrowser.vue'
// Opens the experience report form in .github/ISSUE_TEMPLATE
const feedbackUrl = 'https://github.com/dillardblom/ng_backup/issues/new?template=experience.yml'
const status = ref(null)
const allRuns = ref(false) // runs older than the last days too
const error = ref('')
const tab = ref('settings')
let tabInitialized = false
let timer = null
function showAllRuns(all) {
allRuns.value = all
refresh()
}
async function refresh() {
try {
status.value = await api.status()
status.value = await api.status(allRuns.value)
error.value = ''
// Default to the daily-use tab once the key is set up; stay on Settings otherwise.
// Only decided on first load, so switching tabs ourselves isn't undone by polling.
@@ -79,6 +92,14 @@ onBeforeUnmount(() => clearTimeout(timer))
.ng-backup :deep(.settings-section) {
max-width: 900px;
}
.ngb-feedback {
color: var(--color-text-maxcontrast);
margin-bottom: 12px;
}
.ngb-feedback a {
color: var(--color-primary-element);
text-decoration: underline;
}
.ngb-tabs {
display: flex;
gap: 8px;
+3 -1
View File
@@ -16,7 +16,7 @@ async function call(method, path, data) {
}
export default {
status: () => call('get', '/status'),
status: (allRuns = false) => call('get', '/status', allRuns ? { allRuns: true } : undefined),
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
@@ -27,6 +27,8 @@ export default {
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
backends: () => call('get', '/backends'),
addTarget: (target) => call('post', '/targets', target),
targetSettings: (id) => call('get', `/targets/${id}/settings`),
updateTarget: (id, target) => call('put', `/targets/${id}`, target),
removeTarget: (id) => call('delete', `/targets/${id}`),
testTarget: (id) => call('post', `/targets/${id}/test`),
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
+23 -9
View File
@@ -22,8 +22,8 @@
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
<ul>
<li v-for="item in trash[target.id]" :key="item.id">
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
</li>
</ul>
@@ -40,39 +40,51 @@
<th>{{ t('ng_backup', 'Restore point') }}</th>
<th>{{ t('ng_backup', 'Status') }}</th>
<th>{{ t('ng_backup', 'Files') }}</th>
<th :title="t('ng_backup', 'Total size of the files in this backup, unchanged files included')">{{ t('ng_backup', 'Size') }}</th>
<th>{{ t('ng_backup', 'Uploaded') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
<td>{{ formatDateTime(run.startedAt) }}</td>
<td>{{ targetName(run.targetId) }}</td>
<td>{{ run.kind }}</td>
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + percent(run) + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="allRuns || status.olderRuns" class="ngb-muted">
<template v-if="allRuns">
{{ t('ng_backup', 'All runs.') }}
<NcButton variant="tertiary" @click="emit('show-all-runs', false)">{{ n('ng_backup', 'Only the last day', 'Only the last %n days', status.recentDays) }}</NcButton>
</template>
<template v-else>
{{ n('ng_backup', 'Runs of the last day.', 'Runs of the last %n days.', status.recentDays) }}
<NcButton variant="tertiary" @click="emit('show-all-runs', true)">{{ n('ng_backup', 'Show %n older run', 'Show %n older runs', status.olderRuns) }}</NcButton>
</template>
</p>
</NcSettingsSection>
</template>
<script setup>
import { ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
import { formatSize } from '../format.js'
import { formatDateTime, formatDate, formatSize } from '../format.js'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { reactive } from 'vue'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'show-all-runs'])
const schedule = ref(props.status.schedule)
const error = ref('')
watch(() => props.status.schedule, (s) => { schedule.value = s })
@@ -101,6 +113,8 @@ async function untrash(target, item) {
}
}
// " 68%", or " ~68%" when the total is estimated from Nextcloud's file cache (first backup)
const percent = (run) => run.progress ? ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%' : ''
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
+7 -6
View File
@@ -8,13 +8,13 @@
<!-- 1. Create the key -->
<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)')" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
<NcTextField v-model="delay" type="number" min="1" max="365"
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 16 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
{{ t('ng_backup', 'Create backup key') }}
</NcButton>
</form>
@@ -35,7 +35,7 @@
<tr v-for="slot in status.key.slots" :key="slot.slot">
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
<td><strong>{{ slot.label }}</strong></td>
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
<td class="ngb-muted">{{ slot.created ? formatDate(slot.created) : '' }}</td>
<td class="ngb-actions">
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
@@ -46,10 +46,10 @@
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 16 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>
@@ -69,7 +69,7 @@
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
</div>
<p v-else>
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: formatDateTime(status.key.confirmation.time) }) }}
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
</p>
@@ -90,6 +90,7 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
import { formatDateTime, formatDate } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
+71 -18
View File
@@ -14,12 +14,16 @@
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
</span>
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
<NcButton variant="tertiary" @click="startEdit(target)">{{ t('ng_backup', 'Edit') }}</NcButton>
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
</li>
</ul>
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
<form v-else class="ngb-form" @submit.prevent="add">
<NcButton v-if="!adding" @click="startAdd()">{{ t('ng_backup', 'Add location') }}</NcButton>
<form v-else class="ngb-form" @submit.prevent="save">
<NcNoteCard v-if="editing" type="info">
{{ t('ng_backup', 'Editing "{name}". The new settings must lead to the same repository, so its backups and history are kept. Leave a password field empty to keep the stored password.', { name: editing.name }) }}
</NcNoteCard>
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
@@ -32,8 +36,8 @@
<NcTextField v-model="form.maxGb" type="number" min="0" :label="t('ng_backup', 'Maximum size in GiB')"
:helper-text="form.maxGb ? t('ng_backup', 'Backups stop at this size; administrators are warned at 80%.') : t('ng_backup', 'Empty = unlimited: everything stored is billed by your provider, and nothing stops a backup that keeps growing.')" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ editing ? t('ng_backup', 'Connect and save') : t('ng_backup', 'Connect and add') }}</NcButton>
<NcButton @click="adding = false; editing = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
@@ -41,7 +45,7 @@
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
@@ -58,6 +62,7 @@ defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const adding = ref(false)
const editing = ref(null) // the target being edited, or null while adding
const busy = ref(false)
const error = ref('')
const backends = ref([])
@@ -83,35 +88,83 @@ const toFields = (params) => Object.entries(params || {})
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
let prefilling = false
watch(backend, (b) => {
if (prefilling) {
return
}
auth.value = b?.auth?.[0] ?? null
form.options = {}
})
async function startAdd() {
adding.value = true
error.value = ''
async function loadBackends() {
if (!backends.value.length) {
try {
backends.value = await api.backends()
} catch (e) {
error.value = e.message
}
backends.value = await api.backends()
}
}
async function add() {
busy.value = true
async function startAdd() {
error.value = ''
editing.value = null
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
backend.value = null
adding.value = true
try {
await loadBackends()
} catch (e) {
error.value = e.message
}
}
async function startEdit(target) {
error.value = ''
try {
await confirmPassword()
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
await loadBackends()
const settings = await api.targetSettings(target.id)
prefilling = true
backend.value = backends.value.find((b) => b.id === settings.backend) ?? null
auth.value = backend.value?.auth.find((a) => a.id === settings.auth) ?? null
Object.assign(form, { name: settings.name, path: settings.path, options: Object.fromEntries(Object.entries(settings.options).map(([k, v]) => [k, v ?? ''])),
maxGb: target.maxBytes ? String(+(target.maxBytes / 1073741824).toFixed(2)) : '' })
await nextTick()
prefilling = false
editing.value = target
adding.value = true
} catch (e) {
prefilling = false
error.value = e?.message || ''
}
}
async function save() {
busy.value = true
error.value = ''
const maxGb = form.maxGb === '' ? null : Number(form.maxGb)
try {
await confirmPassword()
if (editing.value) {
// An empty password field sends null: the server keeps the stored password.
const options = { ...form.options }
for (const field of fields.value) {
if (field.type === 2 && !options[field.key]) {
options[field.key] = null
}
}
await api.updateTarget(editing.value.id, { name: form.name, backend: backend.value.id, auth: auth.value.id, options, path: form.path })
if (maxGb !== (editing.value.maxBytes ? Number((editing.value.maxBytes / 1073741824).toFixed(2)) : null)) {
await api.setTargetLimit(editing.value.id, maxGb)
}
delete tests[editing.value.id]
} else {
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path, maxGb })
}
adding.value = false
editing.value = null
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
emit('changed')
} catch (e) {
error.value = e.message
error.value = e?.message || ''
} finally {
busy.value = false
}
+2 -2
View File
@@ -52,7 +52,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import api from '../api.js'
import { formatSize } from '../format.js'
import { formatDateTime, formatSize } from '../format.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
@@ -74,7 +74,7 @@ const modes = [
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
]
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
const snapshotLabel = (s) => formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '')
const join = (name) => path.value + '/' + name
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
const crumbs = computed(() => {
+13
View File
@@ -1,5 +1,18 @@
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
import { getCanonicalLocale } from '@nextcloud/l10n'
// The locale of the user's Nextcloud settings (Personal info > Locale), not the browser's:
// a browser set to English would otherwise show 4:00 PM to someone with a 24-hour locale.
const locale = getCanonicalLocale()
// A number is a unix timestamp in seconds, a string an ISO 8601 date (snapshot times).
const toDate = (value) => typeof value === 'number' ? new Date(value * 1000) : new Date(value)
export const formatDateTime = (value) => toDate(value).toLocaleString(locale)
export const formatDate = (value) => toDate(value).toLocaleDateString(locale)
export function formatSize(bytes) {
if (bytes === null || bytes === undefined) {
return ''
+117 -2
View File
@@ -231,6 +231,93 @@ class SftpReadStreamTest extends TestCase {
$this->assertNull(SftpReadStream::open($storage, 'f'));
}
public function testReadManyReadsSmallFilesInBatches(): void {
$files = [];
for ($i = 0; $i < 70; $i++) {
// one empty file on purpose (random_bytes(0) throws)
$files["/home/index/$i"] = $i === 0 ? '' : random_bytes(random_int(1, 5000));
}
$files['/home/index/big'] = random_bytes(600000); // more than one READ
$sftp = new FakeSftpConnection($files);
$paths = array_map(fn ($p) => substr($p, 6), array_keys($files));
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), $paths);
$this->assertSame(array_combine($paths, array_values($files)), $got);
$this->assertSame(SftpReadStream::BATCH, $sftp->maxInFlight, 'a batch goes out at once');
$this->assertSame(0, $sftp->pending());
$this->assertSame([], $sftp->openHandles);
}
public function testReadManyLeavesAMissingOrFailingFileToTheCaller(): void {
$sftp = new FakeSftpConnection(['/home/a' => 'aaa', '/home/b' => random_bytes(400000), '/home/c' => 'ccc'], failAt: 300000);
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'missing', 'b', 'c']);
$this->assertSame(['a' => 'aaa', 'missing' => null, 'b' => null, 'c' => 'ccc'], $got);
$this->assertSame(0, $sftp->pending());
$this->assertSame([], $sftp->openHandles);
}
public function testReadManyAcrossACappedServer(): void {
$data = random_bytes(300000);
$sftp = new FakeSftpConnection(['/home/a' => $data, '/home/b' => 'b'], cap: 100000);
$this->assertSame(['a' => $data, 'b' => 'b'], SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']));
}
public function testReadManyEndsTheConnectionOnAConnectionError(): void {
// Answers to requests already out may still come: files_external must not get them.
$sftp = new FakeSftpConnection(['/home/a' => random_bytes(10), '/home/b' => 'b'], loseAt: 0);
try {
SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']);
$this->fail('expected a BackendException');
} catch (BackendException) {
}
$this->assertTrue($sftp->disconnected);
}
public function testReadManyStopsBatchingWhenCloseIsNotConfirmed(): void {
$files = [];
for ($i = 0; $i < 40; $i++) {
$files["/home/$i"] = "file $i";
}
$sftp = new FakeSftpConnection($files, closeStatus: 4);
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), array_map('strval', range(0, 39)));
$this->assertSame('file 0', $got['0']);
$this->assertSame('file 31', $got['31']);
$this->assertNull($got['32'], 'no second batch after unconfirmed closes');
$this->assertCount(SftpReadStream::BATCH, array_filter($got, fn ($v) => $v !== null));
// Nor on a later call over the same connection.
$this->assertNull(SftpReadStream::readMany(new FakeSftpStorage($sftp), ['0']));
}
public function testReadManyLeavesAMalformedAnswerToTheCaller(): void {
$sftp = new FakeSftpConnection(['/home/a' => 'aaaa', '/home/b' => 'bbbb'], malformed: '/home/a');
$this->assertSame(['a' => null, 'b' => 'bbbb'], SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']));
$this->assertSame([], $sftp->openHandles);
}
public function testReadManyOnAnUnsupportedConnectionIsNull(): void {
$this->assertNull(SftpReadStream::readMany(new \stdClass(), ['a']));
$this->assertNull(SftpReadStream::readMany(new FakeSftpStorage(new FakeSftpConnection(['/home/a' => 'a'], channelFails: true)), ['a']));
}
public function testAFreshConnectionHasItsSftpChannelOpenedFirst(): void {
// phpseclib 2 logs in at connect time but opens the SFTP channel on the first SFTP call.
$sftp = new FakeSftpConnection(['/home/config' => 'repository config']);
$this->assertFalse($sftp->initialised);
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'config');
$this->assertIsResource($fh);
$this->assertSame('repository config', stream_get_contents($fh));
fclose($fh);
}
public function testAChannelThatCannotBeOpenedLeavesItToTheStoragesOwnStream(): void {
$sftp = new FakeSftpConnection(['/home/config' => 'x'], channelFails: true);
$this->assertNull(SftpReadStream::open(new FakeSftpStorage($sftp), 'config'));
$this->assertSame(0, $sftp->sent, 'nothing may be sent on a connection without an SFTP channel');
}
public function testAConnectionWithoutTheNeededMethodsIsNotUsed(): void {
$storage = new class {
public function getRoot(): string {
@@ -379,6 +466,10 @@ final class FakeSftpConnection {
public array $reads = [];
/** @var array<string, string> */
public array $openHandles = [];
/** Whether the SFTP channel is open: phpseclib 2 opens it on the first SFTP call. */
public bool $initialised = false;
/** Packets sent */
public int $sent = 0;
/** @var array<int, array{int, string}> answers by request id, in the order the server sent them */
private array $answers = [];
private int $handles = 0;
@@ -393,9 +484,28 @@ final class FakeSftpConnection {
/** READ at or past this offset finds the connection gone (phpseclib returns false); -1: OPEN */
private ?int $loseAt = null,
private bool $closeFails = false,
private bool $channelFails = false,
/** status code the server answers CLOSE with */
private int $closeStatus = 0,
/** a file whose DATA answers declare a wrong length */
private ?string $malformed = null,
) {
}
public bool $disconnected = false;
public function disconnect(): void {
$this->disconnected = true;
}
public function pwd(): string|false {
if ($this->channelFails) {
return false;
}
$this->initialised = true;
return '/home';
}
public function file(string $path): ?string {
return $this->files[$path] ?? null;
}
@@ -409,6 +519,10 @@ final class FakeSftpConnection {
}
public function _send_sftp_packet(int $type, string $data, int $id = 1): bool {
$this->sent++;
if (!$this->initialised) {
return false; // like phpseclib 2: no SFTP channel yet
}
switch ($type) {
case 3: // OPEN
if ($this->throwAt === -1) {
@@ -432,7 +546,7 @@ final class FakeSftpConnection {
}
$handle = substr($data, 4, unpack('N', $data)[1]);
unset($this->openHandles[$handle]);
$this->answers[$id] = [101, pack('N', 0)];
$this->answers[$id] = [101, pack('N', $this->closeStatus)];
break;
case 5: // READ
$len = unpack('N', $data)[1];
@@ -454,7 +568,8 @@ final class FakeSftpConnection {
$this->answers[$id] = [101, pack('N', 1)]; // FX_EOF
} else {
$chunk = substr($file, $offset, min($n, $this->cap));
$this->answers[$id] = [103, pack('Na*', strlen($chunk), $chunk)];
$declared = $this->openHandles[$handle] === $this->malformed ? strlen($chunk) + 7 : strlen($chunk);
$this->answers[$id] = [103, pack('Na*', $declared, $chunk)];
}
$this->maxInFlight = max($this->maxInFlight, count($this->answers));
break;
@@ -10,6 +10,8 @@ use OCA\NgBackup\Tests\Unit\TempDirTrait;
use OCP\Files\Storage\IStorage;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../../stubs/SmbStorage.php';
/** On a remote storage every is_dir() is a round trip: listing index/ must not cost one per pack. */
class StorageBackendListTest extends TestCase {
use TempDirTrait;
@@ -54,4 +56,58 @@ class StorageBackendListTest extends TestCase {
$this->expectException(BackendException::class);
(new StorageBackend($storage, 'repo'))->listFiles('catalog');
}
public function testSmbFoldersAreListedInOneRequest(): void {
$calls = new \ArrayObject();
$share = new class($calls) {
public function __construct(private \ArrayObject $calls) {
}
public function dir(string $path): array {
$this->calls[] = "dir $path";
return array_map(fn (string $n) => new class($n, $this->calls) {
public function __construct(private string $n, private \ArrayObject $calls) {
}
public function getName(): string {
return $this->n;
}
public function isHidden(): bool {
$this->calls[] = "attributes {$this->n}";
return $this->n === 'desktop.ini';
}
}, ['b1', 'a2', '.hidden', 'desktop.ini', 'c3.part-0123abcd']);
}
};
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
(fn () => $this->share = $share)->call($storage);
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->expects($this->never())->method('opendir');
$this->assertSame(['index/a2', 'index/b1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
$this->assertSame(['dir repo/index', 'attributes desktop.ini', 'attributes c3.part-0123abcd'], $calls->getArrayCopy(),
'only names a repository does not use cost a request');
}
public function testSmbWithAccessControlListsIsListedTheUsualWay(): void {
$dir = $this->tempDir();
touch("$dir/a1");
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
(fn () => $this->checkAcl = true)->call($storage);
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->expects($this->once())->method('opendir')->willReturnCallback(fn () => opendir($dir));
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
}
public function testSmbFallsBackToTheUsualListing(): void {
$dir = $this->tempDir();
touch("$dir/a1");
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock(); // no share
$storage->method('instanceOfStorage')->willReturn(false);
$storage->method('is_dir')->willReturn(true);
$storage->method('opendir')->willReturnCallback(fn () => opendir($dir));
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Backend;
use OCA\NgBackup\Backend\BackendException;
use OCA\NgBackup\Backend\StorageBackend;
use OCP\Files\Storage\IStorage;
use PHPUnit\Framework\TestCase;
/**
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
* quiet for a few minutes; files_external keeps using the dead client, on which every file
* looks missing. The backend builds the storage anew instead.
*/
class StorageBackendReconnectTest extends TestCase {
/** @var list<ReconnectFakeSftp> */
private array $connections = [];
/** New connections come up dead (the location is gone). */
private bool $deadOnArrival = false;
private function storage(): IStorage {
$sftp = new ReconnectFakeSftp();
$sftp->dropped = $this->deadOnArrival;
$this->connections[] = $sftp;
$storage = $this->createMock(SftpLikeStorage::class);
$storage->method('instanceOfStorage')->willReturnCallback(fn (string $class): bool => $class === \OCA\Files_External\Lib\Storage\SFTP::class);
$storage->method('getConnection')->willReturn($sftp);
$storage->method('is_dir')->willReturn(true);
$storage->method('file_exists')->willReturnCallback(fn (string $path): bool => $sftp->request() && $path === 'repo/config');
$storage->method('fopen')->willReturnCallback(function (string $path) use ($sftp) {
if (!$sftp->request() || $path !== 'repo/config') {
return false;
}
$fh = fopen('php://memory', 'w+b');
fwrite($fh, 'config');
rewind($fh);
return $fh;
});
return $storage;
}
private function backend(bool $reconnect = true): StorageBackend {
return new StorageBackend($this->storage(), 'repo', false, $reconnect ? fn (): IStorage => $this->storage() : null);
}
private static function idle(StorageBackend $backend): void {
(new \ReflectionProperty($backend, 'lastUsed'))->setValue($backend, microtime(true) - StorageBackend::IDLE_CHECK - 1);
}
public function testADeadConnectionIsReplacedAfterAPause(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true;
self::idle($backend);
$this->assertTrue($backend->exists('config'));
$this->assertSame(1, $backend->reconnects);
$this->assertSame(1, $this->connections[0]->requests, 'only the check went over the old connection');
}
public function testAPauseOnALiveConnectionCostsOneCheck(): void {
$backend = $this->backend();
self::idle($backend);
$this->assertTrue($backend->exists('config'));
$this->assertSame(0, $backend->reconnects);
$this->assertSame(2, $this->connections[0]->requests);
$this->assertTrue($backend->exists('config'));
$this->assertSame(3, $this->connections[0]->requests, 'no check without a pause');
}
public function testAFailureOnAConnectionThatJustDiedIsRepeated(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true; // without a pause: not checked first
$this->assertTrue($backend->exists('config'));
$fh = $backend->get('config');
$this->assertSame('config', stream_get_contents($fh));
$this->assertSame(1, $backend->reconnects);
}
public function testAMissingFileOnALiveConnectionIsJustMissing(): void {
$backend = $this->backend();
$this->assertFalse($backend->exists('nothing'));
try {
$backend->get('nothing');
$this->fail('missing');
} catch (BackendException $e) {
$this->assertStringContainsString('Not found', $e->getMessage());
}
$this->assertSame(0, $backend->reconnects);
$this->assertCount(1, $this->connections);
}
public function testANewConnectionThatFailsToo(): void {
$backend = $this->backend();
$this->connections[0]->dropped = true;
$backend->exists('config'); // reconnects
$this->connections[1]->dropped = true;
$this->deadOnArrival = true;
self::idle($backend);
try {
$backend->get('config');
$this->fail('no connection');
} catch (BackendException) {
}
$this->assertSame(3, $backend->reconnects, 'once after the pause, once after the failure, no more');
}
public function testWithoutAWayToReconnectNothingChanges(): void {
$backend = $this->backend(false);
$this->connections[0]->dropped = true;
self::idle($backend);
$this->assertFalse($backend->exists('config'));
$this->assertCount(1, $this->connections);
}
}
interface SftpLikeStorage extends IStorage {
public function getConnection(): object;
}
/** The public phpseclib calls the backend uses to tell a dead connection. */
final class ReconnectFakeSftp {
public bool $dropped = false;
private bool $connected = true;
public int $requests = 0;
public function isConnected(): bool {
return $this->connected;
}
public function clearStatCache(): void {
}
public function stat(string $path): array|false {
return $this->request() ? ['type' => 2] : false;
}
/** A request: false (and from then on disconnected) when the server has dropped the connection. */
public function request(): bool {
$this->requests++;
if ($this->dropped) {
$this->connected = false;
}
return $this->connected;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit;
use OCA\NgBackup\Format;
use PHPUnit\Framework\TestCase;
class FormatTest extends TestCase {
public function testDurationShowsDaysFromOneDayOn(): void {
$this->assertSame('00:00:00', Format::duration(-5));
$this->assertSame('12:10:13', Format::duration(12 * 3600 + 10 * 60 + 13));
$this->assertSame('1d 00:00:00', Format::duration(86400));
$this->assertSame('1d 12:10:13', Format::duration(86400 + 12 * 3600 + 10 * 60 + 13));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Job;
use OCA\NgBackup\Job\BackupRun;
use PHPUnit\Framework\TestCase;
class BackupProgressTest extends TestCase {
public function testCountsFinishedFilesAndTheFileBeingRead(): void {
$state = ['phase' => 'files', 'estimate' => ['bytes' => 1000, 'approximate' => false],
'stats' => ['bytes' => 500], 'cur' => ['offset' => 180]];
$this->assertSame(['fraction' => 0.68, 'approximate' => false], BackupRun::progress($state));
}
public function testStaysBelowHundredPercentWhenTheEstimateIsTooLow(): void {
$state = ['phase' => 'files', 'estimate' => ['bytes' => 1000, 'approximate' => true], 'stats' => ['bytes' => 5000], 'cur' => null];
$this->assertSame(['fraction' => 0.99, 'approximate' => true], BackupRun::progress($state));
}
public function testNoProgressWithoutEstimateOrOutsideTheFilesPhase(): void {
$this->assertNull(BackupRun::progress(['phase' => 'files', 'stats' => ['bytes' => 1]]));
$this->assertNull(BackupRun::progress(['phase' => 'files', 'estimate' => ['bytes' => 0, 'approximate' => true], 'stats' => ['bytes' => 1]]));
$this->assertNull(BackupRun::progress(['phase' => 'commit', 'estimate' => ['bytes' => 10, 'approximate' => false], 'stats' => ['bytes' => 1]]));
}
}
+106
View File
@@ -42,6 +42,61 @@ class BackupRunTest extends TestCase {
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testTheSnapshotIsWrittenInAStepOfItsOwnFromAllSegmentsAtOnce(): void {
$src = $this->tempDir();
for ($i = 0; $i < 60; $i++) {
file_put_contents("$src/f$i.txt", random_bytes(200000));
}
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
$keys = KeyRing::generate();
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
$state = BackupRun::start($src);
for ($i = 0; $i < 500 && $state['phase'] === 'files'; $i++) {
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
}
$this->assertSame('finish', $state['phase'], 'the step that read the last file does not write the snapshot');
$this->assertGreaterThan(2, count($state['segments']));
$backend->batches = [];
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
$this->assertSame('commit', $state['phase']);
// One batch; the blobs of a segment follow each other in its pack and are one read.
$this->assertSame([count($state['segments'])], $backend->batches);
Repository::openWithKey($backend, $keys)->recordSnapshot($state['snapshot']);
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testBlobsThatFollowEachOtherInAPackAreReadAsOneRange(): void {
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
$keys = KeyRing::generate();
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
$stats = [];
$segments = [];
$expected = [];
for ($s = 0; $s < 3; $s++) {
$writer = new \OCA\NgBackup\Repository\BlobWriter(fn (string $data): string => $repo->storeData($data, $stats), 1000);
$lines = [];
for ($i = 0; $i < 100; $i++) {
$lines[] = json_encode(['segment' => $s, 'line' => $i, 'pad' => bin2hex(random_bytes(20))]);
}
$writer->write(implode("\n", $lines) . "\n");
$segments[] = $writer->finish();
$repo->flushPacks(); // one pack per segment, as one per step
array_push($expected, ...$lines);
}
$this->assertGreaterThan(3, count(array_merge(...$segments)));
$backend->batches = [];
$read = iterator_to_array(Repository::openWithKey($backend, $keys)->readLines(array_merge(...$segments)), false);
$this->assertSame($expected, array_values(array_filter($read, fn (string $l): bool => $l !== '')));
$this->assertSame([3], $backend->batches, 'one read per segment, all in one batch');
}
public function testFileChangedBetweenStepsIsReadAgain(): void {
$src = $this->tempDir();
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
@@ -100,3 +155,54 @@ class BackupRunTest extends TestCase {
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
}
}
final class RangeCountingBackend implements \OCA\NgBackup\Backend\IBackend {
use \OCA\NgBackup\Backend\GetsOneByOne {
getRanges as private oneByOne;
}
/** @var list<int> number of ranges per getRanges() call */
public array $batches = [];
public function __construct(
private \OCA\NgBackup\Backend\IBackend $inner,
) {
}
public function getRanges(array $ranges): \Generator {
$this->batches[] = count($ranges);
return $this->oneByOne($ranges);
}
public function put(string $path, $stream): void {
$this->inner->put($path, $stream);
}
public function get(string $path) {
return $this->inner->get($path);
}
public function getRange(string $path, int $offset, int $length): string {
return $this->inner->getRange($path, $offset, $length);
}
public function exists(string $path): bool {
return $this->inner->exists($path);
}
public function move(string $from, string $to): void {
$this->inner->move($from, $to);
}
public function list(string $prefix): array {
return $this->inner->list($prefix);
}
public function listFiles(string $dir): array {
return $this->inner->listFiles($dir);
}
public function delete(string $path): void {
$this->inner->delete($path);
}
}
+38
View File
@@ -20,6 +20,8 @@ class IndexCacheTest extends TestCase {
public function all(string $r): array { return $this->data[$r] ?? []; }
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
};
$src = $this->tempDir();
for ($i = 0; $i < 12; $i++) {
@@ -65,6 +67,8 @@ class IndexCacheTest extends TestCase {
$this->data[$r][$p] = $e;
}
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
};
$backend = new LocalBackend($this->tempDir());
$keys = KeyRing::generate();
@@ -88,4 +92,38 @@ class IndexCacheTest extends TestCase {
$this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data)));
$this->assertSame(0, Repository::openWithKey($backend, $keys, $cache)->indexDownloads());
}
/** After a check the cache holds every index file as it is, also a blob that is in two packs. */
public function testACheckCachesTheIndexFilesAsTheyAre(): void {
$cache = new class implements IIndexCache {
public array $data = [];
public int $replaced = 0;
public function all(string $r): array { return $this->data[$r] ?? []; }
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
public function replaceAll(string $r, array $packs): void { $this->replaced++; $this->data[$r] = $packs; }
};
$backend = new LocalBackend($this->tempDir());
$cipher = new \OCA\NgBackup\Crypto\StreamCipher(KeyRing::generate());
$index = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
$index->setCache($cache, 'r');
$blob = static fn (string $id, int $offset): array => ['id' => $id, 'offset' => $offset, 'length' => 10, 'raw' => 8, 'flags' => 0];
$index->addPack('pack1', [$blob('a', 0), $blob('shared', 10)]);
$index->addPack('pack2', [$blob('shared', 0), $blob('b', 10)]);
$cache->data = ['r' => ['gone' => [$blob('x', 0)]]];
$index->load(true);
$this->assertSame(1, $cache->replaced);
$this->assertSame(['pack1', 'pack2'], array_keys($cache->data['r']));
$this->assertCount(2, $cache->data['r']['pack1'], 'the shared blob stays in the row of its first pack');
// the second pack goes: the shared blob is still found in the first
$index->removePack('pack2');
$again = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
$again->setCache($cache, 'r');
$again->load();
$this->assertSame(0, $again->downloaded);
$this->assertSame('pack1', $again->get('shared')[0]);
}
}
+2
View File
@@ -106,6 +106,8 @@ class PackReadTest extends TestCase {
}
final class PackOpenCountingBackend implements IBackend {
use \OCA\NgBackup\Backend\GetsOneByOne;
/** @var list<string> */
public array $gets = [];
/** @var list<bool> */
+14
View File
@@ -66,6 +66,20 @@ class RepositoryTest extends TestCase {
$this->assertSame(1, $r2['newBlobs']);
}
public function testCompressedDataSkipsDeflateAndOtherDataIsStillCompressed(): void {
$repo = Repository::init($this->backend, 'pw');
$text = str_repeat("a line of a log file, much like the one before\n", 30000); // ~1.4 MiB
$noise = random_bytes(1048576); // like a photo: deflate saves nothing
$stats = [];
$textId = $repo->storeData($text, $stats);
$textUploaded = $stats['uploaded'];
$noiseId = $repo->storeData($noise, $stats);
$repo->flushPacks();
$this->assertLessThan(strlen($text) / 10, $textUploaded);
$this->assertLessThan(strlen($noise) + 1024, $stats['uploaded'] - $textUploaded);
$this->assertSame($text . $noise, implode('', iterator_to_array(Repository::open($this->backend, 'pw')->readBlobs([$textId, $noiseId]), false)));
}
public function testPartialRestore(): void {
$repo = Repository::init($this->backend, 'pw');
$r = $repo->backupDirectory($this->src);
+162
View File
@@ -78,6 +78,107 @@ class VerifyTest extends TestCase {
$this->assertSame(['db/does-not-exist'], $result['missing']);
}
public function testVerifyReportsHowFarItIs(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$seen = [];
$open = $this->open();
$open->listPacksAbove = 0;
$open->verify($s['snapshot'], heartbeat: function (array $p) use (&$seen): void {
$seen[] = $p;
});
$phases = array_values(array_unique(array_column($seen, 'phase')));
$this->assertSame(['index', 'packs', 'files'], $phases);
$this->assertSame(['phase' => 'packs', 'done' => 256, 'total' => 256], $seen[array_key_last(array_filter($seen, fn ($p) => $p['phase'] === 'packs'))]);
$last = end($seen);
$this->assertSame(2, $last['done']);
$this->assertNull($last['total'], 'this snapshot has no file count in its metadata (a backup run records one)');
}
/** Lists the pack folders however few packs there are. */
private static function listing(Repository $repo): Repository {
$repo->listPacksAbove = 0;
return $repo;
}
public function testWithNoMorePacksThanFoldersEveryPackIsCheckedInstead(): void {
// fewer requests; on FTP each takes a data connection, of which a server may have few
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$counting = new ListingBackend($this->backend);
$result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']);
$this->assertSame([], $result['missing']);
$this->assertSame(0, $counting->packListings);
$this->assertGreaterThan(0, $counting->packExists);
}
public function testVerifyListsPackFoldersInsteadOfCheckingEveryPack(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$counting = new ListingBackend($this->backend);
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
$this->assertSame([], $result['missing']);
$this->assertSame(0, $counting->packExists);
}
public function testAPackFolderThatCannotBeListedIsCheckedPackByPack(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$counting = new ListingBackend($this->backend, unlistable: true);
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
$this->assertSame([], $result['missing']);
$this->assertGreaterThan(0, $counting->packExists);
}
public function testAnEmptyFolderListingIsNoProofThatItsPacksAreGone(): void {
// A storage also reports an empty folder when it could not look (is_dir() false).
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$counting = new ListingBackend($this->backend, emptyListings: true);
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
$this->assertSame([], $result['missing']);
$this->assertGreaterThan(0, $counting->packExists);
}
public function testAPackMissingFromAFolderThatIsNotEmptyIsReportedWithoutAskingForIt(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$packs = $this->backend->list('packs');
// Another file in the same folder keeps its listing non-empty.
$this->backend->put(dirname($packs[0]) . '/0000other', fopen('data://text/plain,x', 'r'));
foreach ($packs as $pack) {
$this->backend->delete($pack);
}
$counting = new ListingBackend($this->backend);
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
$this->assertNotSame([], $result['missing']);
$this->assertSame(0, $counting->packExists);
}
public function testAPackMissingFromAnEmptyFolderIsReportedToo(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
foreach ($this->backend->list('packs') as $pack) {
$this->backend->delete($pack);
}
$result = self::listing(Repository::openWithKey(new ListingBackend($this->backend), $this->keys, null, null))->verify($s['snapshot']);
$this->assertNotSame([], $result['missing']);
}
public function testDeepVerifyDetectsATamperedPack(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
@@ -131,6 +232,8 @@ class VerifyTest extends TestCase {
/** Delegates to a real backend, but reading a pack throws for the first $failures reads. */
final class FlakyRangeBackend implements IBackend {
use \OCA\NgBackup\Backend\GetsOneByOne;
public function __construct(
private IBackend $inner,
private int $failures,
@@ -181,3 +284,62 @@ final class FlakyRangeBackend implements IBackend {
$this->inner->delete($path);
}
}
/** Delegates to a real backend; counts exists() on packs, and can refuse to list pack folders. */
final class ListingBackend implements IBackend {
use \OCA\NgBackup\Backend\GetsOneByOne;
public int $packExists = 0;
public int $packListings = 0;
public function __construct(
private IBackend $inner,
private bool $unlistable = false,
private bool $emptyListings = false,
) {
}
public function put(string $path, $stream): void {
$this->inner->put($path, $stream);
}
public function get(string $path) {
return $this->inner->get($path);
}
public function getRange(string $path, int $offset, int $length): string {
return $this->inner->getRange($path, $offset, $length);
}
public function exists(string $path): bool {
if (str_starts_with($path, 'packs/')) {
$this->packExists++;
}
return $this->inner->exists($path);
}
public function move(string $from, string $to): void {
$this->inner->move($from, $to);
}
public function list(string $prefix): array {
return $this->inner->list($prefix);
}
public function listFiles(string $dir): array {
if (str_starts_with($dir, 'packs/')) {
$this->packListings++;
}
if ($this->unlistable && str_starts_with($dir, 'packs/')) {
throw new BackendException('Cannot list ' . $dir);
}
if ($this->emptyListings && str_starts_with($dir, 'packs/')) {
return [];
}
return $this->inner->listFiles($dir);
}
public function delete(string $path): void {
$this->inner->delete($path);
}
}
+5 -1
View File
@@ -68,7 +68,11 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
ok "recovery kit imported, existing repository adopted, snapshot visible"
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction >/dev/null || fail "restore:full"
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction 2>&1) || {
printf '%s\n' "$OUT" | tail -40
docker exec ngb-frt-restore sh -c 'tail -c 6000 /var/www/html/data/nextcloud.log' || true
fail "restore:full"
}
ok "backup:restore:full completed"
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
// Stand-in for the files_external SMB storage in unit tests: only what StorageBackend uses.
namespace OCA\Files_External\Lib\Storage;
use OCP\Files\Storage\IStorage;
if (!class_exists(SMB::class)) {
abstract class SMB implements IStorage {
/** @var object */
protected $share;
protected bool $showHidden = false;
protected bool $checkAcl = false;
protected function buildPath(string $path): string {
return '/' . trim($path, '/');
}
}
}