M5: deletion delay with trash, passphrase slots, ransomware documentation
- 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
This commit is contained in:
@@ -25,6 +25,8 @@ class KeyInit extends Command {
|
||||
$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')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label of the first passphrase slot (who/where), e.g. "Safe"', 'Slot 1')
|
||||
->addOption('delete-delay-days', null, InputOption::VALUE_REQUIRED, 'Days a removed snapshot stays in the trash before its data is deleted (1-365, fixed after this)', (string)KeyService::DEFAULT_DELETE_DELAY)
|
||||
->setHelp(<<<'HELP'
|
||||
Creates the master key that encrypts all backups of this installation.
|
||||
|
||||
@@ -57,12 +59,14 @@ HELP);
|
||||
}
|
||||
}
|
||||
try {
|
||||
$this->keys->initialize($pass);
|
||||
$this->keys->initialize($pass, (int)$input->getOption('delete-delay-days'), (string)$input->getOption('label'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$output->writeln('Removed snapshots stay in the trash on the location for <info>' . $this->keys->deleteDelayDays() . ' days</info> (cannot be changed later).');
|
||||
$output->writeln('Tip: add up to two more passphrases for other people with occ backup:key:slot:add (e.g. CTO, head of IT).');
|
||||
$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,17 @@
|
||||
<?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\KeySlotService;
|
||||
|
||||
class KeySlotAdd extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'add');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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\KeySlotService;
|
||||
|
||||
class KeySlotRemove extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'remove');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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\KeySlotService;
|
||||
|
||||
class KeySlotReplace extends KeySlots {
|
||||
public function __construct(KeyService $keys, KeySlotService $slots) {
|
||||
parent::__construct($keys, $slots, 'replace');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?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\KeySlotService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
/** backup:key:slots, backup:key:slot:add, backup:key:slot:replace, backup:key:slot:remove */
|
||||
class KeySlots extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private KeySlotService $slots,
|
||||
private string $action = 'list',
|
||||
) {
|
||||
// list is the default; subclasses pick another action
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$help = <<<'HELP'
|
||||
Up to three passphrases ("slots") can open the backup key, so that one person leaving or dying
|
||||
does not lock you out. Example: one passphrase in the safe, one with the CTO, one with the head
|
||||
of IT. Each holder keeps a copy of the same recovery kit and only knows their own passphrase.
|
||||
After every change: occ backup:key:kit and occ backup:key:confirm.
|
||||
HELP;
|
||||
match ($this->action) {
|
||||
'add' => $this->setName('backup:key:slot:add')->setDescription('Add a passphrase slot')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Who or where, e.g. "CTO" or "Safe"', 'Slot'),
|
||||
'replace' => $this->setName('backup:key:slot:replace')->setDescription('Set a new passphrase for a slot (e.g. its holder left)')
|
||||
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'New label'),
|
||||
'remove' => $this->setName('backup:key:slot:remove')->setDescription('Remove a passphrase slot (not the last one)')
|
||||
->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)'),
|
||||
default => $this->setName('backup:key:slots')->setDescription('List the passphrase slots of the backup key'),
|
||||
};
|
||||
if ($this->action === 'add' || $this->action === 'replace') {
|
||||
$this->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file');
|
||||
}
|
||||
$this->setHelp($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;
|
||||
}
|
||||
try {
|
||||
switch ($this->action) {
|
||||
case 'add':
|
||||
$n = $this->slots->add($this->passphrase($input, $output), (string)$input->getOption('label'));
|
||||
$output->writeln("Slot $n added.");
|
||||
break;
|
||||
case 'replace':
|
||||
$this->slots->replace((int)$input->getArgument('slot'), $this->passphrase($input, $output), $input->getOption('label'));
|
||||
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' has a new passphrase.');
|
||||
break;
|
||||
case 'remove':
|
||||
$this->slots->remove((int)$input->getArgument('slot'));
|
||||
$output->writeln('Slot ' . (int)$input->getArgument('slot') . ' removed.');
|
||||
break;
|
||||
default:
|
||||
(new Table($output))->setHeaders(['slot', 'label', 'set on'])
|
||||
->setRows(array_map(fn ($s) => [$s['slot'], $s['label'], $s['created'] ? date('Y-m-d H:i', $s['created']) : ''], $this->keys->slots()))->render();
|
||||
return 0;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Now download and confirm the new recovery kit: occ backup:key:kit -o FILE && occ backup:key:confirm');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function passphrase(InputInterface $input, OutputInterface $output): string {
|
||||
if ($file = $input->getOption('passphrase-file')) {
|
||||
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));
|
||||
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
|
||||
throw new \InvalidArgumentException('The passphrases do not match');
|
||||
}
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Trash extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:trash')
|
||||
->setDescription('List removed snapshots that can still be restored (backup:trash:restore)')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
||||
$rows = [];
|
||||
foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) {
|
||||
foreach ($this->targets->repository($t)->trash() as $id => $info) {
|
||||
$rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'],
|
||||
date('Y-m-d H:i', $info['forgottenAt'] + $delay)];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
$output->writeln('The trash is empty.');
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render();
|
||||
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 OCA\NgBackup\Service\PruneService;
|
||||
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 TrashRestore extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:trash:restore')
|
||||
->setDescription('Bring a removed snapshot back from the trash')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:trash)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$this->prune->untrash($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'));
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Snapshot restored from the trash.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ class AdminApiController extends Controller {
|
||||
private IJobList $jobs,
|
||||
private IUserSession $session,
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
||||
private \OCA\NgBackup\Service\PruneService $prune,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
@@ -58,7 +60,9 @@ class AdminApiController extends Controller {
|
||||
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
||||
return new JSONResponse([
|
||||
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
||||
'confirmation' => $this->keys->recoveryKitConfirmation()],
|
||||
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
|
||||
'slots' => $this->keys->isInitialized() ? $this->keys->slots() : [], 'maxSlots' => \OCA\NgBackup\Service\KeyService::MAX_SLOTS,
|
||||
'deleteDelayDays' => $this->keys->isInitialized() ? $this->keys->deleteDelayDays() : \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY],
|
||||
'statement' => KeyConfirm::STATEMENT,
|
||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||
'targets' => $targets,
|
||||
@@ -68,9 +72,9 @@ class AdminApiController extends Controller {
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function initKey(string $passphrase): JSONResponse {
|
||||
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
|
||||
try {
|
||||
$this->keys->initialize($passphrase);
|
||||
$this->keys->initialize($passphrase, $deleteDelayDays, $label);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
@@ -102,6 +106,59 @@ class AdminApiController extends Controller {
|
||||
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function addSlot(string $passphrase, string $label): JSONResponse {
|
||||
try {
|
||||
return new JSONResponse(['slot' => $this->slots->add($passphrase, $label)]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function replaceSlot(int $slot, string $passphrase, ?string $label = null): JSONResponse {
|
||||
try {
|
||||
$this->slots->replace($slot, $passphrase, $label);
|
||||
return new JSONResponse([]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function removeSlot(int $slot): JSONResponse {
|
||||
try {
|
||||
$this->slots->remove($slot);
|
||||
return new JSONResponse([]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
public function trash(int $targetId): JSONResponse {
|
||||
try {
|
||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
||||
$out = [];
|
||||
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
|
||||
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
|
||||
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function untrash(int $targetId, string $snapshotId): JSONResponse {
|
||||
try {
|
||||
$this->prune->untrash($this->targets->get((string)$targetId), $snapshotId);
|
||||
return new JSONResponse([]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
public function backends(): JSONResponse {
|
||||
return new JSONResponse($this->targets->availableBackends());
|
||||
}
|
||||
|
||||
@@ -76,6 +76,23 @@ final class KeyRing {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a key field that is either one wrapped key or {"slots": [{slot, label, key}, ...]}:
|
||||
* every slot holds the same master key under its own passphrase.
|
||||
*/
|
||||
public static function unwrapAny(array $field, #[\SensitiveParameter] string $passphrase): self {
|
||||
if (!isset($field['slots'])) {
|
||||
return self::unwrap($field, $passphrase);
|
||||
}
|
||||
foreach ($field['slots'] as $slot) {
|
||||
try {
|
||||
return self::unwrap($slot['key'], $passphrase);
|
||||
} catch (CryptoException) {
|
||||
}
|
||||
}
|
||||
throw new CryptoException('Wrong passphrase or damaged key');
|
||||
}
|
||||
|
||||
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
||||
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
||||
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
|
||||
@@ -53,7 +53,11 @@ class Notifier implements INotifier {
|
||||
'target_removed' => $l->t('%1$s removed 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 removed %2$s snapshots from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']),
|
||||
'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'] ?? '']),
|
||||
'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']),
|
||||
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
|
||||
default => $l->t('NG Backup security event'),
|
||||
});
|
||||
$notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.'));
|
||||
|
||||
@@ -101,13 +101,20 @@ final class Repository {
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */
|
||||
public function updateWrappedKey(array $wrappedKey): void {
|
||||
$config = self::readConfig($this->backend);
|
||||
$config['key'] = $wrappedKey;
|
||||
self::putString($this->backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function id(): string {
|
||||
return $this->repositoryId;
|
||||
}
|
||||
|
||||
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||
$config = self::readConfig($backend);
|
||||
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
|
||||
$repo = new self($backend, KeyRing::unwrapAny($config['key'], $passphrase), $config['id']);
|
||||
$repo->index->load();
|
||||
return $repo;
|
||||
}
|
||||
@@ -273,19 +280,70 @@ final class Repository {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Remove a snapshot (and its database dump). Its data is freed by the next prune(). */
|
||||
public function forget(string $snapshotId): void {
|
||||
try {
|
||||
$meta = $this->snapshotMeta($snapshotId);
|
||||
} catch (\Throwable) {
|
||||
$meta = [];
|
||||
/**
|
||||
* Move a snapshot (and its database dump) to the trash on the location. Its data stays in
|
||||
* use until purgeTrash() removes it after the deletion delay, so a malicious or mistaken
|
||||
* cleanup can be undone with untrash() within that time.
|
||||
*/
|
||||
public function forget(string $snapshotId, string $by = ''): void {
|
||||
$meta = $this->snapshotMeta($snapshotId);
|
||||
$dbInTrash = null;
|
||||
if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) {
|
||||
$dbInTrash = 'trash/' . $meta['db'];
|
||||
$this->putObject($dbInTrash, $this->getObject($meta['db']));
|
||||
}
|
||||
if (isset($meta['db']) && is_string($meta['db']) && str_starts_with($meta['db'], 'db/')) {
|
||||
// The snapshot object is bound to its id (not its path), so it can be copied as-is.
|
||||
$this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId));
|
||||
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
||||
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
||||
if ($dbInTrash !== null) {
|
||||
$this->backend->delete($meta['db']);
|
||||
}
|
||||
$this->backend->delete('snapshots/' . $snapshotId);
|
||||
}
|
||||
|
||||
/** Undo forget(): move a snapshot back from the trash. */
|
||||
public function untrash(string $snapshotId): void {
|
||||
$info = $this->trashInfo($snapshotId);
|
||||
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
|
||||
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
||||
$this->backend->delete($info['dbInTrash']);
|
||||
}
|
||||
$this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId));
|
||||
$this->backend->delete('trash/snapshots/' . $snapshotId);
|
||||
$this->backend->delete('trash/info/' . $snapshotId);
|
||||
}
|
||||
|
||||
/** @return array<string, array{forgottenAt:int, by:string, db:?string, dbInTrash:?string, time:?string, label:string}> */
|
||||
public function trash(): array {
|
||||
$out = [];
|
||||
foreach ($this->backend->list('trash/info') as $path) {
|
||||
$out[basename($path)] = $this->trashInfo(basename($path));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Permanently delete trash entries older than $delaySeconds. @return list<string> purged snapshot ids */
|
||||
public function purgeTrash(int $delaySeconds): array {
|
||||
$purged = [];
|
||||
foreach ($this->trash() as $id => $info) {
|
||||
if (time() - $info['forgottenAt'] < $delaySeconds) {
|
||||
continue;
|
||||
}
|
||||
if ($info['dbInTrash'] !== null) {
|
||||
$this->backend->delete($info['dbInTrash']);
|
||||
}
|
||||
$this->backend->delete('trash/snapshots/' . $id);
|
||||
$this->backend->delete('trash/info/' . $id);
|
||||
$purged[] = $id;
|
||||
}
|
||||
return $purged;
|
||||
}
|
||||
|
||||
private function trashInfo(string $snapshotId): array {
|
||||
return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete packs no snapshot or user export uses any more, and repack packs where more than
|
||||
* $repackThreshold of the bytes are unused. Must not run while a backup to this repository
|
||||
@@ -314,6 +372,21 @@ final class Repository {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Snapshots in the trash keep their data until purgeTrash() removed them.
|
||||
foreach ($this->trash() as $sid => $info) {
|
||||
foreach ($this->streamEntries($sid, 'trash/snapshots/') as $e) {
|
||||
foreach ($e['b'] as $b) {
|
||||
$used[$b] = true;
|
||||
}
|
||||
}
|
||||
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
|
||||
foreach (json_decode($this->getObject($info['dbInTrash']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) {
|
||||
foreach ($t['blobs'] as $b) {
|
||||
$used[$b] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($this->backend->list('users') as $path) {
|
||||
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
|
||||
foreach ($e['b'] ?? [] as $b) {
|
||||
@@ -465,9 +538,9 @@ final class Repository {
|
||||
}
|
||||
|
||||
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
|
||||
private function streamEntries(string $snapshotId): \Generator {
|
||||
private function streamEntries(string $snapshotId, string $dir = 'snapshots/'): \Generator {
|
||||
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||
$fh = $this->backend->get('snapshots/' . $snapshotId);
|
||||
$fh = $this->backend->get($dir . $snapshotId);
|
||||
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
||||
fclose($fh);
|
||||
rewind($plain);
|
||||
|
||||
@@ -35,8 +35,12 @@ class AlertService {
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function actor(): string {
|
||||
return $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
||||
}
|
||||
|
||||
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
|
||||
$actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
||||
$actor = $this->actor();
|
||||
$params = ['actor' => $actor] + $params;
|
||||
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
|
||||
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
|
||||
|
||||
@@ -46,7 +46,7 @@ class BackupService {
|
||||
}
|
||||
|
||||
public function start(Target $target, string $label = ''): Run {
|
||||
if ($this->keys->recoveryKitConfirmation() === null) {
|
||||
if (!$this->keys->everConfirmed()) {
|
||||
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
||||
}
|
||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||
|
||||
+126
-9
@@ -27,6 +27,9 @@ class KeyService {
|
||||
private const KEY_WRAPPED = 'master_key_wrapped';
|
||||
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
||||
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
||||
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||
public const DEFAULT_DELETE_DELAY = 7;
|
||||
|
||||
private ?KeyRing $cached = null;
|
||||
|
||||
@@ -40,7 +43,15 @@ class KeyService {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
|
||||
}
|
||||
|
||||
public function initialize(#[\SensitiveParameter] string $passphrase): void {
|
||||
/**
|
||||
* @param int $deleteDelayDays how long forgotten snapshots stay in the trash on the location
|
||||
* before their data is really deleted. Fixed here, at installation:
|
||||
* there is deliberately no way to change it later from NG Backup.
|
||||
*/
|
||||
public function initialize(#[\SensitiveParameter] string $passphrase, int $deleteDelayDays = self::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): void {
|
||||
if ($deleteDelayDays < 1 || $deleteDelayDays > 365) {
|
||||
throw new \InvalidArgumentException('The deletion delay must be between 1 and 365 days');
|
||||
}
|
||||
if ($this->isInitialized()) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
@@ -50,9 +61,11 @@ class KeyService {
|
||||
$keys = KeyRing::generate();
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED,
|
||||
json_encode($keys->wrap($passphrase), JSON_THROW_ON_ERROR), true, true);
|
||||
$this->cached = $keys;
|
||||
$this->saveSlots([['slot' => 1, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $keys->wrap($passphrase)]]);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $deleteDelayDays, true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
|
||||
$this->cached = $keys;
|
||||
}
|
||||
@@ -68,9 +81,99 @@ class KeyService {
|
||||
return $this->cached;
|
||||
}
|
||||
|
||||
/** Deletion delay in days, fixed at installation (installations from before this setting get the default once). */
|
||||
public function deleteDelayDays(): int {
|
||||
$days = $this->appConfig->getValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, 0, true);
|
||||
if ($days === 0 && $this->isInitialized()) {
|
||||
$days = self::DEFAULT_DELETE_DELAY;
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $days, true);
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
public const MAX_SLOTS = 3;
|
||||
|
||||
/** @return list<array{slot:int, label:string, created:int}> passphrase slots, without key material */
|
||||
public function slots(): array {
|
||||
return array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots']);
|
||||
}
|
||||
|
||||
/** Add a passphrase slot (e.g. one per person who must be able to restore). Returns the slot number. */
|
||||
public function addSlot(#[\SensitiveParameter] string $passphrase, string $label): int {
|
||||
self::checkPassphrase($passphrase);
|
||||
$slots = $this->wrappedKey()['slots'];
|
||||
if (count($slots) >= self::MAX_SLOTS) {
|
||||
throw new \InvalidArgumentException('At most ' . self::MAX_SLOTS . ' passphrase slots');
|
||||
}
|
||||
$used = array_column($slots, 'slot');
|
||||
$n = min(array_diff(range(1, self::MAX_SLOTS), $used));
|
||||
$slots[] = ['slot' => $n, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $this->keyRing()->wrap($passphrase)];
|
||||
usort($slots, fn ($a, $b) => $a['slot'] <=> $b['slot']);
|
||||
$this->saveSlots($slots);
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** New passphrase for a slot (e.g. its holder left). The master key and all backups stay the same. */
|
||||
public function replaceSlot(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
||||
self::checkPassphrase($passphrase);
|
||||
$slots = $this->wrappedKey()['slots'];
|
||||
$found = false;
|
||||
foreach ($slots as &$s) {
|
||||
if ($s['slot'] === $slot) {
|
||||
$s['key'] = $this->keyRing()->wrap($passphrase);
|
||||
$s['created'] = time();
|
||||
if ($label !== null) {
|
||||
$s['label'] = self::cleanLabel($label);
|
||||
}
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
unset($s);
|
||||
if (!$found) {
|
||||
throw new \InvalidArgumentException("There is no slot $slot");
|
||||
}
|
||||
$this->saveSlots($slots);
|
||||
}
|
||||
|
||||
public function removeSlot(int $slot): void {
|
||||
$slots = array_values(array_filter($this->wrappedKey()['slots'], fn ($s) => $s['slot'] !== $slot));
|
||||
if (count($slots) === count($this->wrappedKey()['slots'])) {
|
||||
throw new \InvalidArgumentException("There is no slot $slot");
|
||||
}
|
||||
if ($slots === []) {
|
||||
throw new \InvalidArgumentException('The last passphrase slot cannot be removed');
|
||||
}
|
||||
$this->saveSlots($slots);
|
||||
}
|
||||
|
||||
/** Every change to the slots asks for a new recovery kit (and its confirmation). */
|
||||
private function saveSlots(array $slots): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode(['slots' => $slots], JSON_THROW_ON_ERROR), true, true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, $this->kitVersion() + 1, true);
|
||||
}
|
||||
|
||||
private static function checkPassphrase(string $passphrase): void {
|
||||
if (strlen($passphrase) < 12) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||
}
|
||||
}
|
||||
|
||||
private static function cleanLabel(string $label): string {
|
||||
$label = trim(preg_replace('/[\x00-\x1f]/', '', $label) ?? '');
|
||||
return mb_substr($label === '' ? 'Slot' : $label, 0, 64);
|
||||
}
|
||||
|
||||
public function kitVersion(): int {
|
||||
return $this->appConfig->getValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
||||
}
|
||||
|
||||
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
|
||||
public function wrappedKey(): array {
|
||||
return json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
||||
$data = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!isset($data['slots'])) {
|
||||
$data = ['slots' => $data === [] ? [] : [['slot' => 1, 'label' => 'Slot 1', 'created' => 0, 'key' => $data]]];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Short identifier of the key, shown in the UI and the kit (not secret). */
|
||||
@@ -84,23 +187,37 @@ class KeyService {
|
||||
'version' => 1,
|
||||
'app' => 'NG Backup',
|
||||
'fingerprint' => $this->fingerprint(),
|
||||
'kit_version' => $this->kitVersion(),
|
||||
'created' => gmdate('c'),
|
||||
'wrapped_key' => $this->wrappedKey(),
|
||||
'instructions' => 'Keep this file and your passphrase safe, outside this server. To restore on a new server: install NG Backup, '
|
||||
. 'add the backup location, and enter this kit with your passphrase. Without both, the backups cannot be decrypted.',
|
||||
'slots' => $this->slots(),
|
||||
'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. '
|
||||
. 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. '
|
||||
. 'Without the kit (or the location) and a passphrase, the backups cannot be decrypted.',
|
||||
];
|
||||
}
|
||||
|
||||
public function confirmRecoveryKit(string $uid): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
|
||||
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint()], JSON_THROW_ON_ERROR), true);
|
||||
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
|
||||
}
|
||||
|
||||
/** Confirmation for the current key, or null. */
|
||||
/**
|
||||
* Whether a recovery kit for this key was ever confirmed (any passphrase version). Backups
|
||||
* keep running after a passphrase change, so changing it cannot be used to stop backups; the
|
||||
* missing re-confirmation is reported as an error in the admin overview instead.
|
||||
*/
|
||||
public function everConfirmed(): bool {
|
||||
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
||||
$data = $raw === '' ? null : json_decode($raw, true);
|
||||
return $data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint();
|
||||
}
|
||||
|
||||
/** Confirmation for the current key and kit version, or null. */
|
||||
public function recoveryKitConfirmation(): ?array {
|
||||
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
||||
$data = $raw === '' ? null : json_decode($raw, true);
|
||||
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint()) ? $data : null;
|
||||
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint() && ($data['kit_version'] ?? 1) === $this->kitVersion()) ? $data : null;
|
||||
}
|
||||
|
||||
private function fingerprintOf(KeyRing $keys): string {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/**
|
||||
* Passphrase slots (like LUKS): up to three passphrases, each opening the same master key, so
|
||||
* that one person leaving or dying does not lock the company out of its backups. Every change
|
||||
* is written to all locations and announced to all administrators; a new recovery kit must be
|
||||
* downloaded and confirmed afterwards (backups keep running meanwhile).
|
||||
*/
|
||||
class KeySlotService {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
}
|
||||
|
||||
public function add(#[\SensitiveParameter] string $passphrase, string $label): int {
|
||||
$slot = $this->keys->addSlot($passphrase, $label);
|
||||
$this->apply('slot_added', $slot);
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function replace(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
||||
$this->keys->replaceSlot($slot, $passphrase, $label);
|
||||
$this->apply('slot_replaced', $slot);
|
||||
}
|
||||
|
||||
public function remove(int $slot): void {
|
||||
$this->keys->removeSlot($slot);
|
||||
$this->apply('slot_removed', $slot);
|
||||
}
|
||||
|
||||
private function apply(string $event, int $slot): void {
|
||||
$wrapped = $this->keys->wrappedKey();
|
||||
$failed = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
try {
|
||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
||||
} catch (\Throwable $e) {
|
||||
$failed[] = $t->getName();
|
||||
}
|
||||
}
|
||||
$label = '';
|
||||
foreach ($this->keys->slots() as $s) {
|
||||
if ($s['slot'] === $slot) {
|
||||
$label = $s['label'];
|
||||
}
|
||||
}
|
||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
||||
if ($failed !== []) {
|
||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,33 @@ class PruneService {
|
||||
private IAppConfig $appConfig,
|
||||
private ILockingProvider $locking,
|
||||
private AlertService $alerts,
|
||||
private KeyService $keys,
|
||||
) {
|
||||
}
|
||||
|
||||
public function untrash(Target $target, string $snapshotId): void {
|
||||
$repo = $this->targets->repository($target);
|
||||
if (!array_key_exists($snapshotId, $repo->trash())) {
|
||||
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
|
||||
}
|
||||
$repo->untrash($snapshotId);
|
||||
// Put it back in the local snapshot list as well.
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
$s = new \OCA\NgBackup\Db\Snapshot();
|
||||
$s->setTargetId($target->getId());
|
||||
$s->setSnapshotId($snapshotId);
|
||||
$s->setKind((string)($meta['kind'] ?? 'full'));
|
||||
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
try {
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
$this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]);
|
||||
}
|
||||
|
||||
public static function lockKey(Target $target): string {
|
||||
return 'ng_backup/target/' . $target->getId();
|
||||
}
|
||||
@@ -51,7 +75,10 @@ class PruneService {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
|
||||
* Snapshots the policy drops go to the trash on the location; trash entries older than the
|
||||
* deletion delay are purged, and only then is their data freed.
|
||||
*
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}
|
||||
*/
|
||||
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
|
||||
if ($target->getAppendOnly()) {
|
||||
@@ -74,9 +101,11 @@ class PruneService {
|
||||
}
|
||||
$keep = $this->policy()->keep($times);
|
||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
||||
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
|
||||
$purged = [];
|
||||
if (!$dryRun) {
|
||||
foreach ($forget as $id) {
|
||||
$repo->forget($id);
|
||||
$repo->forget($id, $actor);
|
||||
try {
|
||||
$this->snapshots->delete($this->snapshots->findOne($target->getId(), $id));
|
||||
} catch (\Throwable) {
|
||||
@@ -84,12 +113,17 @@ class PruneService {
|
||||
}
|
||||
}
|
||||
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
|
||||
$delay = $this->keys->deleteDelayDays();
|
||||
if (!$dryRun && $forget !== []) {
|
||||
// Scheduled cleanup follows the policy: audit log only, no notification every week.
|
||||
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], !$scheduled);
|
||||
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget),
|
||||
'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled);
|
||||
}
|
||||
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
|
||||
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
|
||||
if (!$dryRun) {
|
||||
$purged = $repo->purgeTrash($delay * 86400);
|
||||
}
|
||||
$prune = $repo->prune($dryRun);
|
||||
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
|
||||
} finally {
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
|
||||
@@ -39,9 +39,12 @@ class BackupStatusCheck implements ISetupCheck {
|
||||
|
||||
public function run(): SetupResult {
|
||||
$link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']);
|
||||
if (!$this->keys->isInitialized() || $this->keys->recoveryKitConfirmation() === null) {
|
||||
if (!$this->keys->isInitialized() || !$this->keys->everConfirmed()) {
|
||||
return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link);
|
||||
}
|
||||
if ($this->keys->recoveryKitConfirmation() === null) {
|
||||
return SetupResult::error($this->l->t('The backup passphrase was changed: download and confirm the new recovery kit.'), $link);
|
||||
}
|
||||
$targets = $this->targets->list();
|
||||
if ($targets === []) {
|
||||
return SetupResult::warning($this->l->t('No backup location is configured.'), $link);
|
||||
|
||||
Reference in New Issue
Block a user