- tests/integration/roundtrip.sh: key, kit code and confirmation,
location, full and unchanged incremental backup, restore new-folder and
replace (files identical, extra file in the trash), database dump and
restore, retention, trash and restore from trash, no files_external
errors in the log
- tests/integration/matrix.sh: fresh Nextcloud per version and database
(SQLite, MariaDB, PostgreSQL), removes only its own containers and
their volumes; locally green on NC32 and NC35 with all three databases
- GitHub Actions: unit tests on PHP 8.2-8.4, round trip on NC32-35 x
SQLite/MariaDB/PostgreSQL
Fixes found by the matrix:
- files_external is no longer booted when it is disabled: only its
classes are loaded and a private BackendService gets its backends from
its Application class (booting registered its mount provider, which
failed on installations where its tables were never created)
- SQLite: the database file is excluded from the file backup (it is
covered by the logical dump and copying the live file is inconsistent)
- occ backup:key:confirm accepts --code and --confirm for scripts
- DbDumper::dumpStep(): tables are dumped until the step's deadline in one
REPEATABLE READ transaction and resumed at the last primary key in the
next step; the running table checksum is carried over. A dump that fits
in one step is fully consistent, otherwise the snapshot records
dbConsistent=false. BackupService uses it, so the database phase no
longer needs one long request (hosted Nextcloud, web cron)
- restore inserts 100 rows per statement and leaves tables whose checksum
already matches the dump untouched
- spike C: 30-step dump gives the same checksums as the one-shot dump;
restore rewrote only the 3 damaged tables of 147; 300k-row table
restored in 14.6 s instead of 46 s
Every change to the set of snapshots or trash entries writes an
encrypted catalog generation bound to the repository and its position,
with the hash of the previous generation. The server anchors the newest
generation it has seen in its app config; on open, the location must
show at least that generation and chain back to it, and every snapshot
listed must exist. A rolled-back or tampered location is refused (the
run fails and administrators are notified). Opening with the passphrase
on a new server accepts the newest valid chain.
occ backup:target:trust-current accepts a legitimately older state
(e.g. restored from the provider's snapshot) after typing the location
name; audited and announced to all administrators.
Tests: chain advances, rollback, hidden snapshot, swapped generation,
disaster recovery without anchor. Tested on NC34 by deleting the newest
catalog generation on the SFTP location.
- restore runs from the web are created under the location's exclusive
lock, like backup runs
- raw restore writes to an unpredictable temporary name opened with
exclusive create (never follows a planted symlink) and validates the
path prefix; the restore service rejects non-canonical paths instead
of trimming them (occ normalises user input before calling it)
- restore jobs report progress every minute, so a long restore is not
taken for a dead one
- README: Object Lock turns deletions into delete markers
- one locking protocol per location: backup steps and restores hold a
shared lock, prune and untrash an exclusive one and refuse while any
run (backup, restore, export) exists; starting a backup checks and
creates the run under the exclusive lock, closing the race with prune
- snapshot entries are validated when read (relative path without '.',
'..', NUL, backslash or control characters); raw restore also checks
that the resolved directory stays below the target; the restore API
and service accept canonical paths only
- recovery kit confirmation requires a recent password confirmation and
the confirmation code printed in the current kit file (new code per
kit version), proving the kit was actually downloaded
- prune decides on the authenticated index files on the location and
refreshes the local index cache from them
- restore job marks the run failed on any error; restore runs without
progress for 6 hours are failed so they cannot block cleanup forever
- tests for safe paths and a malicious snapshot path
- removed snapshots go to a trash on the location and their data is only
freed after the deletion delay, which is chosen when the key is created
(default 7 days, 1-365) and cannot be changed afterwards; any admin can
bring them back (occ backup:trash, backup:trash:restore, web UI)
- up to three passphrase slots open the same master key (like LUKS), so
one person leaving or dying does not lock the company out; slot changes
are written to every location, announced to all admins and require a
new recovery kit confirmation, while backups keep running
- occ backup:key:slots, backup:key:slot:add|replace|remove; key init takes
--label and --delete-delay-days
- web UI: deletion delay and first slot at key creation, slot management,
trash per location with restore; all behind password confirmation
- README: key, slots (safe / CTO / head of IT), and protection against
ransomware: object lock in compliance mode (e.g. 90 days), credentials
without delete rights, provider snapshots, off-site copy
- notifications to administrators for failed backup/restore runs and
locations without a successful backup for two days (once a day)
- setup check in the admin overview: error when the key/kit is not set
up, warning for missing, old or failed backups
- security events (recovery kit downloaded, location removed,
append-only turned off, retention changed, snapshots forgotten) go to
the audit log and are announced to all administrators, from the web
and from occ; the scheduled weekly cleanup is logged only
- fix: the recovery-kit download link failed the CSRF check (412); it
now requires a recent password confirmation instead, like creating the
key, removing a location and restoring
- RetentionPolicy (last/daily/weekly/monthly) and PruneService: forget
snapshots the policy drops, delete packs nothing uses and repack mostly
unused ones by copying encrypted blobs as-is; new packs are indexed
before old ones are removed
- a location lock keeps prune and backups apart; append-only locations
are never pruned (cleanup via the location's lifecycle rules)
- weekly prune from the background job; occ backup:prune [--dry-run],
backup:retention, backup:target:append-only
- tests: forget+prune frees space and the remaining snapshot restores
identically, repack of mixed packs, dry-run preview, retention buckets
- AdminApiController (admin only, CSRF-protected): status, create key,
download recovery kit, confirm (statement accepted + exact phrase),
backends with their usable auth mechanisms, add/test/remove locations,
start backups, schedule, snapshots, browse, queue restores
- RestoreJob runs web-initiated restores in the background; backup and
restore runs are kept apart in the run table
- admin section 'NG Backup' with a Vue 3 / @nextcloud/vue 9 page: key and
recovery-kit flow (download, tick statement, 'Yes, I confirm'), location
form built from files_external's parameter definitions, schedule, run
list with polling, snapshot browser and restore with the three modes
- auth mechanisms that depend on a user session or on files_external's
own stored credentials are not offered
API tested with curl on NC34 (status, backends, schedule validation,
confirmation refusal, browse, queued restore and backup completed by
cron); non-admins get 403, anonymous 401.
User files are restored through the Files API so the file cache,
versions and trash stay consistent: modes new-folder (default, into
'Restored <date>/<path>'), merge (overwritten files keep their old
content as a version) and replace (extra files go to the trash). Each
file is staged under a temporary name and moved into place only after
all its blobs were verified. Any snapshot path can also be restored raw
to a local directory (disaster recovery).
Tested on NC34: all three modes restore checksum-identical files, the
overwritten content is kept as a version, the extra file is in the
trash, no temporary files remain.
Opening a repository lists index/ once and downloads only index files
that are not cached; packs written by this server are cached on write,
stale cache rows are dropped. Each resumable step therefore no longer
downloads every index file from the location.
BackupService starts a run per location (database dump in one
transaction, then config + data directory as a resumable file run),
stores the state in ngb_runs after every step, locks a run while a step
executes and records the snapshot. Previews, logs, updater backups and
per-user cache/uploads are excluded (TreeWalker now supports patterns).
BackupJob (every 5 min) starts the daily schedule and steps running runs
with a short budget under web cron and a long one under system cron.
occ: backup:run, backup:status, backup:list.
Tested on NC34/PostgreSQL: first run 128 files / 1 GiB in 64 s (443 MiB
uploaded to S3), unchanged second run 2 s / 0 bytes; a background run to
SFTP completed through cron.php.
TreeWalker walks one or more roots in a stable segment-wise order with
exclusions and resumes after a cursor; ParentCursor merge-joins the
previous snapshot in the same order. BackupRun no longer keeps a file
list or the parent's entries in memory or in its state.
- migration: ngb_targets, ngb_runs, ngb_snapshots
- KeyService: master key stored encrypted with the instance secret
(unattended backups), passphrase-wrapped copy in every repository and
in the recovery kit, recorded admin confirmation per key fingerprint
- TargetService: locations built from files_external backends without a
mount; files_external is loaded through IAppManager::loadApp() and its
enabled state is left alone; credentials encrypted with ICrypto;
existing repositories are adopted only if made with this key, and a
repository cannot be registered twice
- Repository: initWithKey/openWithKey with an encrypted key check
- occ: backup:key:init|kit|confirm, backup:target:backends|add|list|remove
Dev tooling via composer (phpunit, psalm, nextcloud/ocp); vendor/ is
mounted from outside the repository by a helper script. 28 unit tests
ported from the phase-0 spikes (round trips, corruption, wrong keys,
dedup, incremental runs, partial restore, tampering, object binding,
time-boxed steps, file changed between steps).
- detect files changing during reading or between resumable steps and
re-read them (size/mtime/ctime/inode), spike F overwrites mid-run
- drop pending blob ids when a pack or index upload fails
- dump and restore PostgreSQL sequence state exactly, bound parameters,
validated catalog names; spike C checks all sequences exactly
- keep invalid UTF-8 byte-exact (tagged base64)
- refuse large tables without a primary key
- StorageBackend: check write/flush/close results, validate base path
- user_migration import/export: root paths, intermediate folders,
partial writes, read errors
- spikes: never touch an existing account, own fixtures, 512M note
BackupRun is a resumable state machine: each step works until a deadline,
uploads its pack and tree segment, and returns the state; large files are
resumed at the next blob. Snapshot is written last.
Spike F: 200 MiB file resumed across steps in separate processes; with 6
random kill -9 the run completes and restores bit-identically.
spikes/RESULTS.md summarises phase 0 and the findings for the plan.
RepositoryExportDestination / RepositoryImportSource implement the
public OCP\UserMigration interfaces on top of the repository, so
user_migration's migrators (calendar, contacts, trashbin, account,
files) export straight into the encrypted repository.
Spike E (NC34): export 0.8s, unchanged second export stores nothing;
after deleting the user, import restores account, files (bit-identical),
calendar event and contact. Findings: calendars/address books come back
as 'migrated-*' with new object URIs; importing over an existing user
duplicates contacts and adds a migrated birthday calendar; creating the
user on import needs >128 MB (password_policy word list).
ExternalStorageFactory builds storages with files_external's own
backends and auth mechanisms from an unsaved StorageConfig (as its
ConfigAdapter does), and StorageBackend adapts any IStorage to IBackend
(writeStream, seekable range reads, temp name + rename except on S3).
IBackend::put() now owns and always closes the stream, because
files_external's S3 writeStream closes it.
Spike D (NC34): S3 (SeaweedFS) and SFTP: 120 MiB backup ~6s, unchanged
run uploads nothing, restore bit-identical, no files_external mount
created, no temp files (max 12 KiB in /tmp). Works with files_external
disabled when its classes are loaded. Backup peak memory 85-89 MiB on
these targets (to investigate).
DbDumper reads every prefixed table through the Nextcloud connection in
one REPEATABLE READ (read-only) transaction, with keyset pagination on
the primary key, and streams each table as JSON lines into its own blob
stream (BlobWriter), so unchanged tables are deduplicated. Restore
replaces table contents in one transaction and resets PostgreSQL
sequences.
Spike C (NC34, PostgreSQL, standard image without pg_dump/pgsql ext):
300k-row table with bytea: dump 6.3s / 50 MiB peak, 94 MiB JSON -> 20 MiB
stored; unchanged second dump stores nothing; restore makes every table
checksum-identical and all 110 sequences continue above the maximum.
Content-addressed repository on an IBackend (local implementation):
4 MiB blobs, deflate when it helps, each blob encrypted with its id as
associated data, ~32 MiB packs with an encrypted header, one index file
per pack, snapshots as encrypted JSON lines written last.
Spike B: 390 MiB tree, peak 54 MiB; unchanged run reads nothing; a 1 MiB
change in a 300 MiB file uploads one blob; full and partial restore are
bit-identical; tampering is detected.