diff --git a/appinfo/info.xml b/appinfo/info.xml index 074a8b0..48a24a6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.6 + 0.0.8 agpl Dillard Blom NgBackup diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7a29518..73ba279 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -20,6 +20,8 @@ class Application extends App implements IBootstrap { } 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 { diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php index 12f8519..95c16cd 100644 --- a/lib/BackgroundJob/BackupJob.php +++ b/lib/BackgroundJob/BackupJob.php @@ -27,6 +27,7 @@ class BackupJob extends TimedJob { ITimeFactory $time, private BackupService $backups, private \OCA\NgBackup\Service\PruneService $prune, + private \OCA\NgBackup\Service\AlertService $alerts, private TargetService $targets, private RunMapper $runs, private IAppConfig $appConfig, @@ -47,6 +48,9 @@ class BackupJob extends TimedJob { } } $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. */ @@ -60,7 +64,7 @@ class BackupJob extends TimedJob { continue; } try { - $this->prune->apply($t); + $this->prune->apply($t, false, true); } catch (\Throwable $e) { $this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage()); } diff --git a/lib/BackgroundJob/RestoreJob.php b/lib/BackgroundJob/RestoreJob.php index b5a5ad3..55340ba 100644 --- a/lib/BackgroundJob/RestoreJob.php +++ b/lib/BackgroundJob/RestoreJob.php @@ -23,6 +23,7 @@ class RestoreJob extends QueuedJob { private TargetService $targets, private RestoreService $restore, private LoggerInterface $logger, + private \OCA\NgBackup\Service\AlertService $alerts, ) { parent::__construct($time); } @@ -46,6 +47,7 @@ class RestoreJob extends QueuedJob { $this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]); $run->setStatus(Run::FAILED); $run->setError($e->getMessage()); + $this->alerts->runFailed($run, '#' . $run->getTargetId()); } $run->setFinishedAt(time()); $run->setUpdatedAt(time()); diff --git a/lib/Command/KeyKit.php b/lib/Command/KeyKit.php index a149523..86c0e78 100644 --- a/lib/Command/KeyKit.php +++ b/lib/Command/KeyKit.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface; class KeyKit extends Command { public function __construct( private KeyService $keys, + private \OCA\NgBackup\Service\AlertService $alerts, ) { parent::__construct(); } @@ -31,6 +32,7 @@ class KeyKit extends Command { $output->writeln('No backup key yet: run occ backup:key:init first.'); return 1; } + $this->alerts->securityEvent('kit_downloaded'); $json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; $file = $input->getOption('output'); if ($file === null) { diff --git a/lib/Command/TargetAppendOnly.php b/lib/Command/TargetAppendOnly.php index 4ddeaad..e3927bd 100644 --- a/lib/Command/TargetAppendOnly.php +++ b/lib/Command/TargetAppendOnly.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface; class TargetAppendOnly extends Command { public function __construct( private TargetService $targets, + private \OCA\NgBackup\Service\AlertService $alerts, ) { parent::__construct(); } @@ -35,6 +36,9 @@ class TargetAppendOnly extends Command { return 1; } $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') . '.'); return 0; } diff --git a/lib/Command/TargetRemove.php b/lib/Command/TargetRemove.php index 800d27e..58de093 100644 --- a/lib/Command/TargetRemove.php +++ b/lib/Command/TargetRemove.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface; class TargetRemove extends Command { public function __construct( private TargetService $targets, + private \OCA\NgBackup\Service\AlertService $alerts, ) { parent::__construct(); } @@ -34,6 +35,7 @@ class TargetRemove extends Command { return 1; } $this->targets->remove($t); + $this->alerts->securityEvent('target_removed', ['target' => $t->getName()]); $output->writeln('Removed ' . $t->getName() . '. The backups on the location itself were not touched; add it again to use them.'); return 0; } diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index b17f688..30e5bdb 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -19,6 +19,8 @@ use OCA\NgBackup\Service\RestoreService; use OCA\NgBackup\Service\TargetService; use OCP\AppFramework\Controller; 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\JSONResponse; use OCP\BackgroundJob\IJobList; @@ -42,6 +44,7 @@ class AdminApiController extends Controller { private IAppConfig $appConfig, private IJobList $jobs, private IUserSession $session, + private \OCA\NgBackup\Service\AlertService $alerts, ) { parent::__construct(Application::APP_ID, $request); } @@ -64,6 +67,7 @@ class AdminApiController extends Controller { ]); } + #[PasswordConfirmationRequired] public function initKey(string $passphrase): JSONResponse { try { $this->keys->initialize($passphrase); @@ -73,10 +77,18 @@ class AdminApiController extends Controller { 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 { if (!$this->keys->isInitialized()) { 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); 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']]); } + #[PasswordConfirmationRequired] 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([]); } @@ -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. */ + #[PasswordConfirmationRequired] 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) || !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) { diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php new file mode 100644 index 0000000..ca26210 --- /dev/null +++ b/lib/Notification/Notifier.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/lib/Service/AlertService.php b/lib/Service/AlertService.php new file mode 100644 index 0000000..a4e54a0 --- /dev/null +++ b/lib/Service/AlertService.php @@ -0,0 +1,80 @@ + $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); + } + } +} diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index d54c30c..da92dd7 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -41,6 +41,7 @@ class BackupService { private IAppManager $appManager, private ILockingProvider $locking, private LoggerInterface $logger, + private AlertService $alerts, ) { } @@ -132,7 +133,12 @@ class BackupService { $run->setError($e->getMessage()); $run->setFinishedAt(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 { $this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE); } diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 56b072e..3de77ec 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -27,6 +27,7 @@ class PruneService { private SnapshotMapper $snapshots, private IAppConfig $appConfig, private ILockingProvider $locking, + private AlertService $alerts, ) { } @@ -42,13 +43,17 @@ class PruneService { if ($policy->last < 1) { 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)); + if ($old !== $policy->toArray()) { + $this->alerts->securityEvent('retention_changed', ['policy' => json_encode($policy->toArray())]); + } } /** * @return array{kept: array>, forgotten: list, 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()) { 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. + 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 : []); return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune]; } finally { diff --git a/lib/SetupCheck/BackupStatusCheck.php b/lib/SetupCheck/BackupStatusCheck.php new file mode 100644 index 0000000..d1c6a17 --- /dev/null +++ b/lib/SetupCheck/BackupStatusCheck.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/package-lock.json b/package-lock.json index 68af0e1..f2b5045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@nextcloud/axios": "^2.5.1", "@nextcloud/dialogs": "^7.4.0", "@nextcloud/l10n": "^3.4.0", + "@nextcloud/password-confirmation": "^6.1.0", "@nextcloud/router": "^3.0.1", "@nextcloud/vue": "^9.0.0", "vue": "^3.5.0" @@ -885,6 +886,24 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", diff --git a/package.json b/package.json index 78ec95f..811150f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@nextcloud/axios": "^2.5.1", "@nextcloud/dialogs": "^7.4.0", "@nextcloud/l10n": "^3.4.0", + "@nextcloud/password-confirmation": "^6.1.0", "@nextcloud/router": "^3.0.1", "@nextcloud/vue": "^9.0.0", "vue": "^3.5.0" diff --git a/src/components/KeySetup.vue b/src/components/KeySetup.vue index 652589f..2f3653d 100644 --- a/src/components/KeySetup.vue +++ b/src/components/KeySetup.vue @@ -23,7 +23,7 @@ {{ 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', 'Download recovery kit') }} @@ -35,7 +35,8 @@

{{ 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', 'Download the kit again') }} + {{ t('ng_backup', 'Download the kit again') }} + {{ t('ng_backup', 'Requires your password; all administrators are notified.') }}

{{ error }} @@ -50,6 +51,8 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import { confirmPassword } from '@nextcloud/password-confirmation' +import '@nextcloud/password-confirmation/style.css' import api from '../api.js' const props = defineProps({ status: { type: Object, required: true } }) @@ -63,10 +66,22 @@ const busy = ref(false) const error = ref('') 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() { busy.value = true error.value = '' try { + await confirmPassword() await api.initKey(pass1.value) pass1.value = pass2.value = '' emit('changed') @@ -92,6 +107,7 @@ async function confirm() {