M1: key management, backup locations and occ commands
- 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
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?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\KeyService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
class KeyConfirm extends Command {
|
||||
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
|
||||
. 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.';
|
||||
public const CONFIRMATION = 'Yes, I confirm';
|
||||
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:key:confirm')
|
||||
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
|
||||
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Key fingerprint: <info>' . $this->keys->fingerprint() . '</info>');
|
||||
$output->writeln('');
|
||||
$output->writeln(wordwrap(self::STATEMENT, 78));
|
||||
$output->writeln('');
|
||||
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
||||
if (trim($answer) !== self::CONFIRMATION) {
|
||||
$output->writeln('<error>Not confirmed.</error>');
|
||||
return 1;
|
||||
}
|
||||
$this->keys->confirmRecoveryKit((string)$input->getOption('user'));
|
||||
$output->writeln('Confirmed. Backups can now run.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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\KeyService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
class KeyInit extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:key:init')
|
||||
->setDescription('Create the backup key, protected by a passphrase you keep safe')
|
||||
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
|
||||
->setHelp(<<<'HELP'
|
||||
Creates the master key that encrypts all backups of this installation.
|
||||
|
||||
The server keeps the key (encrypted with this instance's secret) so scheduled backups can run.
|
||||
A copy protected by your passphrase goes into every backup location and into the recovery kit
|
||||
(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase.
|
||||
|
||||
Example:
|
||||
occ backup:key:init
|
||||
occ backup:key:kit --output=/root/ng_backup-recovery-kit.json
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if ($this->keys->isInitialized()) {
|
||||
$output->writeln('<error>A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').</error>');
|
||||
return 1;
|
||||
}
|
||||
$file = $input->getOption('passphrase-file');
|
||||
if ($file !== null) {
|
||||
$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);
|
||||
$pass = (string)$helper->ask($input, $output, $q);
|
||||
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
||||
if ($pass !== $again) {
|
||||
$output->writeln('<error>The passphrases do not match.</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$this->keys->initialize($pass);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$output->writeln('Next: save the recovery kit with <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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\KeyService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class KeyKit extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:key:kit')
|
||||
->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout')
|
||||
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||
return 1;
|
||||
}
|
||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
|
||||
$file = $input->getOption('output');
|
||||
if ($file === null) {
|
||||
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
||||
return 0;
|
||||
}
|
||||
if (file_put_contents($file, $json) === false) {
|
||||
$output->writeln("<error>Cannot write $file</error>");
|
||||
return 1;
|
||||
}
|
||||
@chmod($file, 0600);
|
||||
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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\KeyService;
|
||||
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;
|
||||
|
||||
class TargetAdd extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:target:add')
|
||||
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
|
||||
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
|
||||
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
|
||||
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
|
||||
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
|
||||
->setHelp(<<<'HELP'
|
||||
The location is reached with files_external's own connection code, but it is never added as an
|
||||
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
|
||||
|
||||
Examples:
|
||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
|
||||
|
||||
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||
return 1;
|
||||
}
|
||||
$options = [];
|
||||
foreach ($input->getOption('option') as $pair) {
|
||||
if (!str_contains($pair, '=')) {
|
||||
$output->writeln("<error>Option '$pair' must be key=value</error>");
|
||||
return 1;
|
||||
}
|
||||
[$k, $v] = explode('=', $pair, 2);
|
||||
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
|
||||
}
|
||||
if ($input->getOption('auth') === null) {
|
||||
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
|
||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class TargetBackends extends Base {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this->setName('backup:target:backends')
|
||||
->setDescription('List the storage backends (from files_external) that can be used as backup location');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$backends = $this->targets->availableBackends();
|
||||
if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) {
|
||||
$this->writeMixedInOutputFormat($input, $output, $backends);
|
||||
return 0;
|
||||
}
|
||||
foreach ($backends as $b) {
|
||||
$output->writeln(sprintf('<info>%s</info> (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth'])));
|
||||
foreach ($b['parameters'] as $key => $p) {
|
||||
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OC\Core\Command\Base;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class TargetList extends Base {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
parent::configure();
|
||||
$this->setName('backup:target:list')
|
||||
->setDescription('List backup locations')
|
||||
->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$rows = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
|
||||
if ($input->getOption('test')) {
|
||||
try {
|
||||
$repo = $this->targets->repository($t);
|
||||
$row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots';
|
||||
} catch (\Throwable $e) {
|
||||
$row['status'] = 'error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
|
||||
if ($rows === []) {
|
||||
$output->writeln('No backup locations yet (occ backup:target:add).');
|
||||
return 0;
|
||||
}
|
||||
$table = new \Symfony\Component\Console\Helper\Table($output);
|
||||
$table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render();
|
||||
} else {
|
||||
$this->writeMixedInOutputFormat($input, $output, $rows);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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\Command;
|
||||
|
||||
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\Output\OutputInterface;
|
||||
|
||||
class TargetRemove extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:target:remove')
|
||||
->setDescription('Remove a backup location from NG Backup (the backups on it are kept)')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Name or id');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$t = $this->targets->get($input->getArgument('target'));
|
||||
} catch (\Throwable) {
|
||||
$output->writeln('<error>No such location.</error>');
|
||||
return 1;
|
||||
}
|
||||
$this->targets->remove($t);
|
||||
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user