Ask before installing user_migration; add the beta how-to

backup:user:backup and backup:user:restore now ask the administrator before
installing or enabling user_migration (or take --install-user-migration), so
nothing changes on the server without consent. The restore warns that
user_migration imports only its allowlisted app settings.

Also: a clear error for unknown locations instead of a raw database message;
prune reports the number of affected users, not the number of kept ones; and
--user-last rejects values that are not whole numbers. Adds docs/HOWTO.md and
updates the README and PLAN.md for the beta.
This commit is contained in:
2026-10-06 16:57:56 +02:00
parent ba7f70e0f8
commit 943c95bf07
9 changed files with 268 additions and 8 deletions
+6 -2
View File
@@ -350,8 +350,12 @@ minimale volledige restore (`occ backup:restore:full` op een verse installatie)
(standaard 3, per installatie, net als de bestaande snapshot-policy), toegepast door
`occ backup:prune` (geen prullenbak voor user-exports, in tegenstelling tot snapshots — on-demand,
niet het primaire vangnet).
5. Automatische user-export inpluggen in het reguliere schema (of expliciet besluit dat het on-demand blijft)
9. SMB/NFS intern testen op de eigen OMV-instantie — nog open (intern, niet in CI)
5. Automatische user-export in het reguliere schema — **naar backlog** (niet voor deze release; on-demand blijft)
9. SMB-roundtrip (protocol, niet een specifieke server) — **klaar** (2026-10-06): CI-matrix, 2 GB-roundtrip en
user-export via SMB; NFS via de OMV-share getest (`local`-backend op een gemounte share, 2026-10-06)
10. `backup:user:restore` zonder user_migration: vraagt om toestemming om de app te installeren en te activeren (of
`--install-user-migration`) — **klaar** (2026-10-06); waarschuwing over de settings-allowlist — **klaar**;
agenda/contacten bij migratie naar een server zonder die gebruiker — **getest** (2026-10-06, naar `migrated-personal`/`migrated-contacts`)
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
+10 -3
View File
@@ -7,7 +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: in development (phase 1). Not ready for production use.** See `PLAN.md` for the design.
**Status: pre-release. Not yet tested on production.** See `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.
@@ -30,7 +31,7 @@ License: AGPL-3.0-or-later.
occ backup:key:init --label="Safe" # first passphrase, deletion delay (default 7 days)
occ backup:key:slot:add --label="CTO" # optional second and third passphrase
occ backup:key:slot:add --label="Head of IT"
occ backup:key:kit --output=/root/ng_backup-kit.json
occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
occ backup:key:confirm
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=...
occ backup:run
@@ -106,7 +107,9 @@ What you should do on the location (strongly recommended):
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
("Restored <date>"), merged into the original place, or replacing it.
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
- **Whole server** (disaster recovery onto a fresh installation): planned for phase 2.
- **Whole server** (disaster recovery onto a fresh installation): `occ backup:restore:full`, see the how-to.
- **One user** (account, settings, files, calendars, contacts via user_migration): `occ backup:user:backup` and
`occ backup:user:restore`, replacing the account or restoring it as `<uid>-bak`. See the how-to.
## Storage backend experiences
@@ -154,3 +157,7 @@ Contributions welcome via PR — add what you ran into with your own provider.
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
| `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) |
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
| `backup:key:import` | import a recovery kit on a fresh installation |
| `backup:user:backup`, `backup:user:restore` | one user via user_migration (asks before installing it) |
+149
View File
@@ -0,0 +1,149 @@
# NG Backup how-to
Step by step: from first install to a tested restore. All commands run as the web user
(`sudo -u www-data php occ ...`, or `docker exec -u www-data <container> php occ ...` in
All-in-One and Docker setups). Replace names in `<angle brackets>` with your own.
## 1. Create the key and the recovery kit
```
occ backup:key:init --label="Main" --delete-delay-days=7
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.
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
Any one passphrase is enough to restore.
- 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.
## 2. Add a backup location
NG Backup uses Nextcloud's External storage backends. Check what your server supports first:
```
occ backup:target:backends
```
Examples:
```
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret>
occ backup:target:add nas sftp -a sftp::password -o host=<host> -o user=<user> -o password=<pw> -o root=home
occ backup:target:add local local -a null::null -o datadir=/mnt/backup
```
- **SFTP on a Hetzner Storage Box sub-account:** use `-o root=home`, not `/` (see README).
- **FTP:** use a server that accepts absolute paths (for example `pure-ftpd`).
- **SMB:** requires the native `smbclient` PHP extension. The CLI binary alone is not enough (see README).
`backup:target:add` refuses SMB when the extension is missing.
- **NFS:** not a backend of its own. Mount the export on the server and use the `local` backend
with the mount point as `datadir`.
Verify the location before relying on it:
```
occ backup:target:list
occ backup:status
```
## 3. Run the first backup
```
occ backup:run offsite -l first
occ backup:list
```
Later runs upload only changed data. An unchanged run uploads (almost) nothing; the output shows
the number of uploaded bytes, so check that value when you test incremental behaviour.
Schedule `occ backup:run` from system cron (or use the Administration settings, if you prefer).
## 4. Check the backup regularly
```
occ backup:verify offsite <snapshot> # shallow: all blocks are present
occ backup:verify offsite <snapshot> --deep # also downloads and decrypts every block
```
Run the deep check at least once after setting up a location, and after changing it.
## 5. Restore files and folders
In the web interface, or with occ:
```
occ backup:browse offsite <snapshot>
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=new-folder
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=merge
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=replace
occ backup:restore offsite <snapshot> config --to-directory=/var/tmp/restore-config
```
- `new-folder` (default): puts the files in "Restored <date>" next to the original. Safest choice.
- `merge`: adds missing files, keeps current ones.
- `replace`: overwrites the folder. Replaced files keep their old content as a version, and removed
files go to the trash.
- `--to-directory`: writes raw files to a local directory (for example `config/`).
## 6. Back up and restore one user
Needs the `user_migration` app. NG Backup asks before installing it:
```
occ backup:user:backup offsite <uid>
occ backup:user:restore offsite users/<uid>/<exportId> # replace (default)
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
```
- If `user_migration` is missing, the command asks to install and enable it. For non-interactive
use, add `--install-user-migration` to approve that explicitly. The command stops after the
installation; run it once more.
- `replace` first makes a safety export of the current account, then recreates the user under the
same id. `as-backup` restores into `<uid>-bak` and leaves the original untouched.
- A restore onto a server where the user does not exist works. Calendars and address books are
placed in `migrated-personal` and `migrated-contacts`, not in the original names.
- **Settings:** on restore, `user_migration` imports only the app settings on its own allowlist
(for example calendar view and reminder settings). Other app settings of the user are skipped.
NG Backup reuses `user_migration`'s importer, so this list is not extended.
- Keep the last few exports per user: `occ backup:retention --user-last=3`.
## 7. Disaster recovery: restore the whole server
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
the same location available:
```
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
occ backup:target:add offsite <backend> ... # same details as on the original server
occ backup:list offsite # the snapshots must be listed
occ backup:restore:full offsite <snapshot>
```
Afterwards, check that users, files and the database are complete, and run a backup of the restored
server. The restored installation keeps its own app configuration and secret.
## 8. Retention
```
occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12
occ backup:prune offsite --dry-run # show first
occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first
occ backup:trash # list the trash
occ backup:trash:restore offsite <id>
```
Snapshots are kept according to the policy, and their data is deleted only after the deletion delay
(set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be
removed without changing anything.
## 9. Checklist before you rely on it
- [ ] Key created, passphrase stored off-server, recovery kit stored off-server
- [ ] Location added; `backup:status` shows it reachable
- [ ] First backup done and listed in `backup:list`
- [ ] `backup:verify --deep` succeeds
- [ ] One file restore tested (`new-folder`)
- [ ] Disaster recovery tested on a second, fresh installation (or in a test environment)
+5
View File
@@ -9,10 +9,12 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserBackupService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use OCP\IUserManager;
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 BackupUser extends Command {
@@ -20,6 +22,7 @@ final class BackupUser extends Command {
private TargetService $targets,
private UserBackupService $userBackup,
private IUserManager $userManager,
private UserMigrationInstaller $installer,
) {
parent::__construct();
}
@@ -29,6 +32,7 @@ final class BackupUser extends Command {
->setDescription('Back up one user via user_migration (account, settings, files and everything else a migrator covers)')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('user', InputArgument::REQUIRED, 'User id')
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Streams a user_migration export straight into the encrypted repository: no zip, no copy in the
user's own storage. Shares data with the regular backup; this needs the user_migration app
@@ -47,6 +51,7 @@ HELP);
return 1;
}
try {
$this->installer->ensureEnabled($input, $output);
$target = $this->targets->get($input->getArgument('target'));
$manifest = $this->userBackup->backupUser($target, $user);
} catch (\Throwable $e) {
+2 -1
View File
@@ -48,8 +48,9 @@ final class Prune extends Command {
continue;
}
if ($ru['forgotten'] !== []) {
$affected = count(array_unique(array_map(static fn (string $path): string => explode('/', $path)[1] ?? '', $ru['forgotten'])));
$output->writeln(sprintf('<info>%s</info>: %s %d user export(s) for %d user(s)',
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), count($ru['kept'])));
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), $affected));
}
try {
$r = $this->prune->apply($t, $dry);
+5
View File
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserRestoreService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@@ -19,6 +20,7 @@ final class RestoreUser extends Command {
public function __construct(
private TargetService $targets,
private UserRestoreService $userRestore,
private UserMigrationInstaller $installer,
) {
parent::__construct();
}
@@ -29,6 +31,7 @@ final class RestoreUser extends Command {
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Never imports over a live account. Modes:
replace (default) safety-exports the current account first, then deletes and recreates it
@@ -45,6 +48,8 @@ HELP);
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->installer->ensureEnabled($input, $output);
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
$target = $this->targets->get($input->getArgument('target'));
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), (string)$input->getOption('mode'));
} catch (\Throwable $e) {
+6 -1
View File
@@ -50,8 +50,13 @@ final class Retention extends Command {
}
}
if ($input->getOption('user-last') !== null) {
$userLast = (string)$input->getOption('user-last');
if (!ctype_digit($userLast)) {
$output->writeln('<error>--user-last must be a whole number</error>');
return 1;
}
try {
$this->prune->setUserExportRetention((int)$input->getOption('user-last'));
$this->prune->setUserExportRetention((int)$userLast);
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
+5 -1
View File
@@ -152,7 +152,11 @@ final class TargetService {
}
public function get(string $nameOrId): Target {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
try {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
} catch (DoesNotExistException) {
throw new \InvalidArgumentException("No such location: $nameOrId");
}
}
/** Size limit for the data stored on this location (null = none). */
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\UserMigration;
use OCA\NgBackup\AppInfo\Application;
use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* Installs or enables the optional user_migration app, but only when the administrator asked for it
* (occ prompt or --install-user-migration). Runs occ in a child process, as the admin would.
*/
final class UserMigrationInstaller {
private const APP = 'user_migration';
public function __construct(
private IAppManager $appManager,
) {
}
public function isEnabled(): bool {
return $this->appManager->isEnabledForAnyone(self::APP);
}
private function codeIsPresent(): bool {
try {
$this->appManager->getAppPath(self::APP);
return true;
} catch (AppPathNotFoundException) {
return false;
}
}
/** Installs (from the app store, if missing) and enables user_migration; throws on failure. */
public function install(): void {
$args = $this->codeIsPresent() ? ['app:enable', self::APP] : ['app:install', self::APP];
$occ = dirname($this->appManager->getAppPath(Application::APP_ID), 2) . '/occ';
$command = implode(' ', array_map('escapeshellarg', [PHP_BINARY, $occ, ...$args])) . ' 2>&1';
exec($command, $lines, $code);
if ($code !== 0) {
throw new \RuntimeException("occ " . implode(' ', $args) . " failed:\n" . implode("\n", $lines));
}
}
/**
* Returns when user_migration is enabled. Otherwise asks the administrator (interactive shell) or
* requires --install-user-migration, and throws when neither gives consent.
*
* @throws \RuntimeException
*/
public function ensureEnabled(InputInterface $input, OutputInterface $output): void {
if ($this->isEnabled()) {
return;
}
$consent = $input->getOption('install-user-migration') === true;
if (!$consent && $input->isInteractive()) {
$consent = (new QuestionHelper())->ask($input, $output, new ConfirmationQuestion(
'The user_migration app is not enabled. Install and enable it now? [y/N] ', false)) === true;
}
if (!$consent) {
throw new \RuntimeException('The user_migration app is not enabled; per-user backup/restore needs it. Run "occ '
. 'app:install ' . self::APP . '" and "occ app:enable ' . self::APP . '", or repeat with --install-user-migration');
}
$output->writeln('Installing and enabling user_migration (approved by the administrator)...');
$this->install();
$this->appManager->clearAppsCache();
if (!$this->isEnabled()) {
throw new \RuntimeException('user_migration was installed; run this command again (check with "occ app:list" if it stays disabled)');
}
}
}