M5: notifications, setup check, audit log and password confirmation
- 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
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||||
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
||||||
<version>0.0.6</version>
|
<version>0.0.8</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author>Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class Application extends App implements IBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function register(IRegistrationContext $context): void {
|
public function register(IRegistrationContext $context): void {
|
||||||
|
$context->registerNotifierService(\OCA\NgBackup\Notification\Notifier::class);
|
||||||
|
$context->registerSetupCheck(\OCA\NgBackup\SetupCheck\BackupStatusCheck::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(IBootContext $context): void {
|
public function boot(IBootContext $context): void {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class BackupJob extends TimedJob {
|
|||||||
ITimeFactory $time,
|
ITimeFactory $time,
|
||||||
private BackupService $backups,
|
private BackupService $backups,
|
||||||
private \OCA\NgBackup\Service\PruneService $prune,
|
private \OCA\NgBackup\Service\PruneService $prune,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private RunMapper $runs,
|
private RunMapper $runs,
|
||||||
private IAppConfig $appConfig,
|
private IAppConfig $appConfig,
|
||||||
@@ -47,6 +48,9 @@ class BackupJob extends TimedJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->weeklyPrune();
|
$this->weeklyPrune();
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$this->alerts->checkStale($t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
||||||
@@ -60,7 +64,7 @@ class BackupJob extends TimedJob {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$this->prune->apply($t);
|
$this->prune->apply($t, false, true);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RestoreJob extends QueuedJob {
|
|||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private RestoreService $restore,
|
private RestoreService $restore,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
parent::__construct($time);
|
parent::__construct($time);
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,7 @@ class RestoreJob extends QueuedJob {
|
|||||||
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
$run->setStatus(Run::FAILED);
|
$run->setStatus(Run::FAILED);
|
||||||
$run->setError($e->getMessage());
|
$run->setError($e->getMessage());
|
||||||
|
$this->alerts->runFailed($run, '#' . $run->getTargetId());
|
||||||
}
|
}
|
||||||
$run->setFinishedAt(time());
|
$run->setFinishedAt(time());
|
||||||
$run->setUpdatedAt(time());
|
$run->setUpdatedAt(time());
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
class KeyKit extends Command {
|
class KeyKit extends Command {
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private KeyService $keys,
|
private KeyService $keys,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ class KeyKit extends Command {
|
|||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
$this->alerts->securityEvent('kit_downloaded');
|
||||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
|
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
|
||||||
$file = $input->getOption('output');
|
$file = $input->getOption('output');
|
||||||
if ($file === null) {
|
if ($file === null) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
class TargetAppendOnly extends Command {
|
class TargetAppendOnly extends Command {
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,9 @@ class TargetAppendOnly extends Command {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on');
|
$t = $this->targets->setAppendOnly($this->targets->get($input->getArgument('target')), $state === 'on');
|
||||||
|
if ($state === 'off') {
|
||||||
|
$this->alerts->securityEvent('append_only_off', ['target' => $t->getName()]);
|
||||||
|
}
|
||||||
$output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.');
|
$output->writeln($t->getName() . ' is ' . ($t->getAppendOnly() ? 'append-only' : 'normal') . '.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
class TargetRemove extends Command {
|
class TargetRemove extends Command {
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ class TargetRemove extends Command {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$this->targets->remove($t);
|
$this->targets->remove($t);
|
||||||
|
$this->alerts->securityEvent('target_removed', ['target' => $t->getName()]);
|
||||||
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
|
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use OCA\NgBackup\Service\RestoreService;
|
|||||||
use OCA\NgBackup\Service\TargetService;
|
use OCA\NgBackup\Service\TargetService;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
|
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
|
||||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\BackgroundJob\IJobList;
|
use OCP\BackgroundJob\IJobList;
|
||||||
@@ -42,6 +44,7 @@ class AdminApiController extends Controller {
|
|||||||
private IAppConfig $appConfig,
|
private IAppConfig $appConfig,
|
||||||
private IJobList $jobs,
|
private IJobList $jobs,
|
||||||
private IUserSession $session,
|
private IUserSession $session,
|
||||||
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ class AdminApiController extends Controller {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
public function initKey(string $passphrase): JSONResponse {
|
public function initKey(string $passphrase): JSONResponse {
|
||||||
try {
|
try {
|
||||||
$this->keys->initialize($passphrase);
|
$this->keys->initialize($passphrase);
|
||||||
@@ -73,10 +77,18 @@ class AdminApiController extends Controller {
|
|||||||
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
|
return new JSONResponse(['fingerprint' => $this->keys->fingerprint()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain download link (no CSRF token on a navigation), therefore guarded by a recent password
|
||||||
|
* confirmation instead, logged to the audit log and announced to all administrators. The kit
|
||||||
|
* only holds the passphrase-wrapped key: useless without the passphrase.
|
||||||
|
*/
|
||||||
|
#[NoCSRFRequired]
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
public function downloadKit(): DataDownloadResponse|JSONResponse {
|
public function downloadKit(): DataDownloadResponse|JSONResponse {
|
||||||
if (!$this->keys->isInitialized()) {
|
if (!$this->keys->isInitialized()) {
|
||||||
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
|
return new JSONResponse(['error' => 'No backup key yet'], Http::STATUS_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
$this->alerts->securityEvent('kit_downloaded');
|
||||||
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
||||||
}
|
}
|
||||||
@@ -103,8 +115,11 @@ class AdminApiController extends Controller {
|
|||||||
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
public function removeTarget(int $id): JSONResponse {
|
public function removeTarget(int $id): JSONResponse {
|
||||||
$this->targets->remove($this->targets->get((string)$id));
|
$target = $this->targets->get((string)$id);
|
||||||
|
$this->targets->remove($target);
|
||||||
|
$this->alerts->securityEvent('target_removed', ['target' => $target->getName()]);
|
||||||
return new JSONResponse([]);
|
return new JSONResponse([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +167,7 @@ class AdminApiController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
|
/** Queue a restore of user files; it runs as a background job and shows up in the runs. */
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
||||||
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|
||||||
|| !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|
|| !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Notification;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\L10N\IFactory;
|
||||||
|
use OCP\Notification\INotification;
|
||||||
|
use OCP\Notification\INotifier;
|
||||||
|
use OCP\Notification\UnknownNotificationException;
|
||||||
|
|
||||||
|
class Notifier implements INotifier {
|
||||||
|
public function __construct(
|
||||||
|
private IFactory $l10n,
|
||||||
|
private IURLGenerator $url,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getID(): string {
|
||||||
|
return Application::APP_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->l10n->get(Application::APP_ID)->t('NG Backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepare(INotification $notification, string $languageCode): INotification {
|
||||||
|
if ($notification->getApp() !== Application::APP_ID) {
|
||||||
|
throw new UnknownNotificationException();
|
||||||
|
}
|
||||||
|
$l = $this->l10n->get(Application::APP_ID, $languageCode);
|
||||||
|
$p = $notification->getSubjectParameters();
|
||||||
|
switch ($notification->getSubject()) {
|
||||||
|
case 'run_failed':
|
||||||
|
$notification->setParsedSubject($p['kind'] === 'restore'
|
||||||
|
? $l->t('Restore from %s failed', [$p['target']])
|
||||||
|
: $l->t('Backup to %s failed', [$p['target']]));
|
||||||
|
$notification->setParsedMessage((string)($p['error'] ?? ''));
|
||||||
|
break;
|
||||||
|
case 'backup_stale':
|
||||||
|
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
|
||||||
|
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
|
||||||
|
break;
|
||||||
|
case 'security_event':
|
||||||
|
$who = (string)($p['actor'] ?? '');
|
||||||
|
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
||||||
|
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
||||||
|
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
|
||||||
|
'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'] ?? '']),
|
||||||
|
default => $l->t('NG Backup security event'),
|
||||||
|
});
|
||||||
|
$notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.'));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new UnknownNotificationException();
|
||||||
|
}
|
||||||
|
$notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']));
|
||||||
|
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')));
|
||||||
|
return $notification;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Db\Target;
|
||||||
|
use OCP\IGroupManager;
|
||||||
|
use OCP\Notification\IManager;
|
||||||
|
|
||||||
|
/** Notifies administrators about failed runs and locations without a recent successful backup. */
|
||||||
|
class AlertService {
|
||||||
|
public const STALE_AFTER = 2 * 86400;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IManager $notifications,
|
||||||
|
private IGroupManager $groups,
|
||||||
|
private RunMapper $runs,
|
||||||
|
private \OCP\IAppConfig $appConfig,
|
||||||
|
private \OCP\EventDispatcher\IEventDispatcher $events,
|
||||||
|
private \OCP\IUserSession $session,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security-relevant change (recovery kit downloaded, location removed, append-only off,
|
||||||
|
* retention changed, snapshots forgotten): written to the audit log (admin_audit) and sent
|
||||||
|
* to every administrator, so a single compromised admin account cannot do it unnoticed.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $params
|
||||||
|
*/
|
||||||
|
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
|
||||||
|
$actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
||||||
|
$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]));
|
||||||
|
if ($notify) {
|
||||||
|
$this->notifyAdmins('security_event', $event . '-' . time(), ['event' => $event] + $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runFailed(Run $run, string $targetName): void {
|
||||||
|
$this->notifyAdmins('run_failed', 'run-' . $run->getId(),
|
||||||
|
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most one notification per location per day. */
|
||||||
|
public function checkStale(Target $target): void {
|
||||||
|
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
|
||||||
|
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
|
||||||
|
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$key = 'stale_notified_' . $target->getId();
|
||||||
|
if ($this->appConfig->getValueString(Application::APP_ID, $key, '') === date('Y-m-d')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
|
||||||
|
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
|
||||||
|
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyAdmins(string $subject, string $objectId, array $params): void {
|
||||||
|
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $admin) {
|
||||||
|
$n = $this->notifications->createNotification();
|
||||||
|
$n->setApp(Application::APP_ID)
|
||||||
|
->setUser($admin->getUID())
|
||||||
|
->setDateTime(new \DateTime())
|
||||||
|
->setObject($subject, $objectId)
|
||||||
|
->setSubject($subject, $params);
|
||||||
|
$this->notifications->notify($n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ class BackupService {
|
|||||||
private IAppManager $appManager,
|
private IAppManager $appManager,
|
||||||
private ILockingProvider $locking,
|
private ILockingProvider $locking,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
|
private AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +133,12 @@ class BackupService {
|
|||||||
$run->setError($e->getMessage());
|
$run->setError($e->getMessage());
|
||||||
$run->setFinishedAt(time());
|
$run->setFinishedAt(time());
|
||||||
$run->setUpdatedAt(time());
|
$run->setUpdatedAt(time());
|
||||||
return $this->runs->update($run);
|
$run = $this->runs->update($run);
|
||||||
|
try {
|
||||||
|
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
return $run;
|
||||||
} finally {
|
} finally {
|
||||||
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class PruneService {
|
|||||||
private SnapshotMapper $snapshots,
|
private SnapshotMapper $snapshots,
|
||||||
private IAppConfig $appConfig,
|
private IAppConfig $appConfig,
|
||||||
private ILockingProvider $locking,
|
private ILockingProvider $locking,
|
||||||
|
private AlertService $alerts,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +43,17 @@ class PruneService {
|
|||||||
if ($policy->last < 1) {
|
if ($policy->last < 1) {
|
||||||
throw new \InvalidArgumentException('Keep at least the last snapshot');
|
throw new \InvalidArgumentException('Keep at least the last snapshot');
|
||||||
}
|
}
|
||||||
|
$old = $this->policy()->toArray();
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
|
$this->appConfig->setValueString(Application::APP_ID, 'retention', json_encode($policy->toArray(), JSON_THROW_ON_ERROR));
|
||||||
|
if ($old !== $policy->toArray()) {
|
||||||
|
$this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
|
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
|
||||||
*/
|
*/
|
||||||
public function apply(Target $target, bool $dryRun = false): array {
|
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
|
||||||
if ($target->getAppendOnly()) {
|
if ($target->getAppendOnly()) {
|
||||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
||||||
}
|
}
|
||||||
@@ -79,6 +84,10 @@ class PruneService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
|
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
|
||||||
|
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);
|
||||||
|
}
|
||||||
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
|
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
|
||||||
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
|
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\SetupCheck;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Service\AlertService;
|
||||||
|
use OCA\NgBackup\Service\BackupService;
|
||||||
|
use OCA\NgBackup\Service\KeyService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCP\IL10N;
|
||||||
|
use OCP\IURLGenerator;
|
||||||
|
use OCP\SetupCheck\ISetupCheck;
|
||||||
|
use OCP\SetupCheck\SetupResult;
|
||||||
|
|
||||||
|
/** Shows the backup state in the admin overview (Security & setup warnings). */
|
||||||
|
class BackupStatusCheck implements ISetupCheck {
|
||||||
|
public function __construct(
|
||||||
|
private IL10N $l,
|
||||||
|
private IURLGenerator $url,
|
||||||
|
private KeyService $keys,
|
||||||
|
private TargetService $targets,
|
||||||
|
private RunMapper $runs,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategory(): string {
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->l->t('NG Backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): SetupResult {
|
||||||
|
$link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']);
|
||||||
|
if (!$this->keys->isInitialized() || $this->keys->recoveryKitConfirmation() === null) {
|
||||||
|
return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link);
|
||||||
|
}
|
||||||
|
$targets = $this->targets->list();
|
||||||
|
if ($targets === []) {
|
||||||
|
return SetupResult::warning($this->l->t('No backup location is configured.'), $link);
|
||||||
|
}
|
||||||
|
$problems = [];
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||||
|
if ($last === null) {
|
||||||
|
$problems[] = $this->l->t('%s: no successful backup yet', [$t->getName()]);
|
||||||
|
} elseif (time() - (int)$last->getFinishedAt() > AlertService::STALE_AFTER) {
|
||||||
|
$problems[] = $this->l->t('%1$s: last successful backup %2$s', [$t->getName(), date('Y-m-d H:i', (int)$last->getFinishedAt())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->runs->findRecent(10) as $r) {
|
||||||
|
if ($r->getStatus() === Run::FAILED && $r->getStartedAt() > time() - AlertService::STALE_AFTER) {
|
||||||
|
$problems[] = $this->l->t('A %1$s run failed: %2$s', [$r->getKind(), mb_strimwidth((string)$r->getError(), 0, 120, '…')]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $problems === []
|
||||||
|
? SetupResult::success($this->l->t('Recent backups to all locations succeeded.'))
|
||||||
|
: SetupResult::warning(implode("\n", $problems), $link);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+19
@@ -12,6 +12,7 @@
|
|||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
"@nextcloud/dialogs": "^7.4.0",
|
"@nextcloud/dialogs": "^7.4.0",
|
||||||
"@nextcloud/l10n": "^3.4.0",
|
"@nextcloud/l10n": "^3.4.0",
|
||||||
|
"@nextcloud/password-confirmation": "^6.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.0.0",
|
"@nextcloud/vue": "^9.0.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
@@ -885,6 +886,24 @@
|
|||||||
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
|
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nextcloud/password-confirmation": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-N2ChXDbPcUcQCoAjj1yc65zfc61yiKpdM3qm/y3Y/y//NG7XWNNINwBg85lqJ2SISgmVStpsQ1d0blpFCoQXkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nextcloud/auth": "^2.5.3",
|
||||||
|
"@nextcloud/axios": "^2.5.2",
|
||||||
|
"@nextcloud/l10n": "^3.4.1",
|
||||||
|
"@nextcloud/logger": "^3.0.3",
|
||||||
|
"@nextcloud/router": "^3.1.0",
|
||||||
|
"@nextcloud/vue": "^9.6.0",
|
||||||
|
"vue": "^3.5.30"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || ^24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nextcloud/paths": {
|
"node_modules/@nextcloud/paths": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
"@nextcloud/dialogs": "^7.4.0",
|
"@nextcloud/dialogs": "^7.4.0",
|
||||||
"@nextcloud/l10n": "^3.4.0",
|
"@nextcloud/l10n": "^3.4.0",
|
||||||
|
"@nextcloud/password-confirmation": "^6.1.0",
|
||||||
"@nextcloud/router": "^3.0.1",
|
"@nextcloud/router": "^3.0.1",
|
||||||
"@nextcloud/vue": "^9.0.0",
|
"@nextcloud/vue": "^9.0.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<NcNoteCard type="warning">
|
<NcNoteCard type="warning">
|
||||||
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
|
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
|
||||||
</NcNoteCard>
|
</NcNoteCard>
|
||||||
<NcButton :href="kitUrl" download @click="downloaded = true">
|
<NcButton @click="downloadKit">
|
||||||
{{ t('ng_backup', 'Download recovery kit') }}
|
{{ t('ng_backup', 'Download recovery kit') }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
|
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
||||||
<a :href="kitUrl" download>{{ t('ng_backup', 'Download the kit again') }}</a>
|
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
|
||||||
|
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
@@ -50,6 +51,8 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit
|
|||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||||
|
import '@nextcloud/password-confirmation/style.css'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
@@ -63,10 +66,22 @@ const busy = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const kitUrl = api.kitUrl()
|
const kitUrl = api.kitUrl()
|
||||||
|
|
||||||
|
async function downloadKit() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await confirmPassword()
|
||||||
|
window.location.href = kitUrl
|
||||||
|
downloaded.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createKey() {
|
async function createKey() {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
|
await confirmPassword()
|
||||||
await api.initKey(pass1.value)
|
await api.initKey(pass1.value)
|
||||||
pass1.value = pass2.value = ''
|
pass1.value = pass2.value = ''
|
||||||
emit('changed')
|
emit('changed')
|
||||||
@@ -92,6 +107,7 @@ async function confirm() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.ngb-muted { color: var(--color-text-maxcontrast); margin-inline-start: 8px; }
|
||||||
.ngb-form, .ngb-confirm {
|
.ngb-form, .ngb-confirm {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
|||||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
|
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||||
|
import '@nextcloud/password-confirmation/style.css'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
|
||||||
defineProps({ status: { type: Object, required: true } })
|
defineProps({ status: { type: Object, required: true } })
|
||||||
@@ -107,7 +109,13 @@ async function remove(target) {
|
|||||||
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
|
if (!window.confirm(t('ng_backup', 'Remove "{name}" from NG Backup? The backups on the location itself are kept.', { name: target.name }))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await api.removeTarget(target.id)
|
try {
|
||||||
|
await confirmPassword()
|
||||||
|
await api.removeTarget(target.id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || ''
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('changed')
|
emit('changed')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
|||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
|
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||||
|
import '@nextcloud/password-confirmation/style.css'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
import { formatSize } from '../format.js'
|
import { formatSize } from '../format.js'
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ async function restore() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = message.value = ''
|
error.value = message.value = ''
|
||||||
try {
|
try {
|
||||||
|
await confirmPassword()
|
||||||
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
|
await api.startRestore(target.value.id, snapshot.value.id, selected.value, mode.value)
|
||||||
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
|
message.value = t('ng_backup', 'Restore queued. It runs with the next cron job; progress is shown under Backups.')
|
||||||
emit('changed')
|
emit('changed')
|
||||||
|
|||||||
Reference in New Issue
Block a user