Review fixes: a missed passphrase change follows at the next backup, a key from before 1.1.0 counts as used, schedules edited in their own time zone
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
- A location that a passphrase change could not reach gets it at its next backup - A key from before 1.1.0 is never replaced as an unused one - An unreadable settings file on a location is told apart from a missing one - A schedule is shown and saved in the zone it was saved in - Saving an unchanged schedule keeps its moment; --timezone needs a schedule - Schedule coverage uses the time the backup began reading its data - Tests: catch-up scenario, upgrade test restores the new file, autumn clock change
This commit is contained in:
+8
-3
@@ -21,7 +21,8 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
|
|||||||
An older version of NG Backup can still read the location, but drops them when it writes to it.
|
An older version of NG Backup can still read the location, but drops them when it writes to it.
|
||||||
- A schedule per location: off, once a day, or every N hours from a given time, on the page under
|
- A schedule per location: off, once a day, or every N hours from a given time, on the page under
|
||||||
Backups or with `occ backup:target:schedule`. After the update every location keeps the time that
|
Backups or with `occ backup:target:schedule`. After the update every location keeps the time that
|
||||||
held for all of them; a new location starts with the schedule of the first one.
|
held for all of them; a new location starts with the schedule of the first one. A schedule is
|
||||||
|
shown and changed in the time zone it was saved in, which the page names next to the time.
|
||||||
- All folders get their original modification time back on a restore, not only the empty ones, from
|
- All folders get their original modification time back on a restore, not only the empty ones, from
|
||||||
the first backup made with this version. An older version of NG Backup can still read new restore
|
the first backup made with this version. An older version of NG Backup can still read new restore
|
||||||
points. A restore through Nextcloud into a folder that is already there leaves its time alone.
|
points. A restore through Nextcloud into a folder that is already there leaves its time alone.
|
||||||
@@ -33,7 +34,10 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
|
|||||||
`occ backup:target:add` finds the backups on the location and takes their key from there with one
|
`occ backup:target:add` finds the backups on the location and takes their key from there with one
|
||||||
of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The
|
of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The
|
||||||
deletion delay is now also stored on the location, sealed with the key, from its first backup
|
deletion delay is now also stored on the location, sealed with the key, from its first backup
|
||||||
with this version; a location backed up only by an older version gives the default of 7 days.
|
with this version; a location backed up only by an older version gives the default of 7 days,
|
||||||
|
and so does a settings file that cannot be read (the command then says so).
|
||||||
|
- A passphrase change that cannot reach a location (offline at the time) now reaches it at the
|
||||||
|
location's next backup, so a new server can open it with the new passphrase.
|
||||||
- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery
|
- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery
|
||||||
kit writes it again (for locations backed up by this version or newer). Before, the message said
|
kit writes it again (for locations backed up by this version or newer). Before, the message said
|
||||||
this was possible, but it was not.
|
this was possible, but it was not.
|
||||||
@@ -41,7 +45,8 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
|
|||||||
by mistake can still make way for the key of the backups, as long as it was never used (no
|
by mistake can still make way for the key of the backups, as long as it was never used (no
|
||||||
location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or
|
location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or
|
||||||
`occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that
|
`occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that
|
||||||
protected a location is never replaced. All administrators are notified of a replacement.
|
protected a location is never replaced, and neither is a key from before this version. All
|
||||||
|
administrators are notified of a replacement.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The list of backups stays within the page: a long label, note or error wraps onto more lines
|
- The list of backups stays within the page: a long label, note or error wraps onto more lines
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ final class BackupJob extends TimedJob {
|
|||||||
}
|
}
|
||||||
$attempts = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $moment);
|
$attempts = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $moment);
|
||||||
if (!\OCA\NgBackup\Service\LocationSchedule::isDue($moment, (int)$t->getScheduleSince(),
|
if (!\OCA\NgBackup\Service\LocationSchedule::isDue($moment, (int)$t->getScheduleSince(),
|
||||||
$this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)?->getStartedAt(),
|
$this->dataStarted($this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)),
|
||||||
count($attempts), ($attempts[0] ?? null)?->getFinishedAt(), $now)) {
|
count($attempts), ($attempts[0] ?? null)?->getFinishedAt(), $now)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -127,4 +127,15 @@ final class BackupJob extends TimedJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When a backup began reading its data: a queued one can start well after it was asked for. */
|
||||||
|
private function dataStarted(?\OCA\NgBackup\Db\Run $run): ?int {
|
||||||
|
if ($run === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$state = json_decode($run->getState(), true);
|
||||||
|
$started = is_array($state) ? ($state['files']['started'] ?? null) : null;
|
||||||
|
$at = is_string($started) ? strtotime($started) : false;
|
||||||
|
return $at !== false ? $at : $run->getStartedAt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,9 +161,12 @@ HELP);
|
|||||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||||
if ($passphrase !== null) {
|
if ($passphrase !== null) {
|
||||||
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||||
$output->writeln(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null
|
$days = 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days';
|
||||||
? 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the location).'
|
$output->writeln(match (true) {
|
||||||
: 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (the default: the location has none stored, its backups were made before NG Backup 1.1.0).');
|
($r['deleteDelayDays'] ?? null) !== null => $days . ' (from the location).',
|
||||||
|
$r['settingsUnreadable'] ?? false => '<comment>' . $days . ' (the default: the settings file on the location could not be read, it may be damaged). If this server used a longer delay, removed restore points leave the Bin sooner than before.</comment>',
|
||||||
|
default => $days . ' (the default: the location has none stored, its backups were made before NG Backup 1.1.0).',
|
||||||
|
});
|
||||||
$output->writeln('Next: <info>occ backup:list</info> and <info>occ backup:restore:full</info>. Before backups run again, download a new recovery kit and confirm it (<info>occ backup:key:kit</info>, <info>occ backup:key:confirm</info>).');
|
$output->writeln('Next: <info>occ backup:list</info> and <info>occ backup:restore:full</info>. Before backups run again, download a new recovery kit and confirm it (<info>occ backup:key:kit</info>, <info>occ backup:key:confirm</info>).');
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ final class TargetSchedule extends Command {
|
|||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
$t = $this->targets->get($input->getArgument('target'));
|
$t = $this->targets->get($input->getArgument('target'));
|
||||||
$schedule = $input->getArgument('schedule');
|
$schedule = $input->getArgument('schedule');
|
||||||
|
if ($schedule === null && $input->getOption('timezone') !== null) {
|
||||||
|
$output->writeln('<error>--timezone goes with a schedule, e.g. backup:target:schedule ' . $t->getName() . ' 02:30 --timezone=Europe/Amsterdam</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
$zone = $input->getOption('timezone') ?? $this->schedule->zoneOr($t->getScheduleZone());
|
$zone = $input->getOption('timezone') ?? $this->schedule->zoneOr($t->getScheduleZone());
|
||||||
if ($schedule !== null) {
|
if ($schedule !== null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Migration;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use OCA\NgBackup\Service\KeyService;
|
||||||
|
use OCP\Migration\IOutput;
|
||||||
|
use OCP\Migration\SimpleMigrationStep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key from before 1.1.0 counts as used: those versions did not keep track, and it may protect
|
||||||
|
* backups on a location that was removed from this server later (user exports included). Only a
|
||||||
|
* key made with 1.1.0 or newer can be replaced as an unused one.
|
||||||
|
*/
|
||||||
|
final class Version000009Date20261108000000 extends SimpleMigrationStep {
|
||||||
|
public function __construct(
|
||||||
|
private KeyService $keys,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||||
|
if ($this->keys->isInitialized()) {
|
||||||
|
$this->keys->markUsed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,8 +239,8 @@ final class Repository {
|
|||||||
/**
|
/**
|
||||||
* Settings of the installation that never change (the deletion delay), sealed with the
|
* Settings of the installation that never change (the deletion delay), sealed with the
|
||||||
* master key so that, unlike the config and the recovery kit, nobody can alter them unseen.
|
* master key so that, unlike the config and the recovery kit, nobody can alter them unseen.
|
||||||
* Empty when the location has none yet (made before 1.1.0, until its next backup) or they
|
* Empty when the location has none yet (made before 1.1.0, until its next backup); only
|
||||||
* cannot be read.
|
* ['unreadable' => true] when they are there but cannot be read.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -253,9 +253,9 @@ final class Repository {
|
|||||||
$raw = (string)stream_get_contents($fh);
|
$raw = (string)stream_get_contents($fh);
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
$settings = json_decode((new StreamCipher($keys))->decryptString($raw, 'settings'), true, 16, JSON_THROW_ON_ERROR);
|
$settings = json_decode((new StreamCipher($keys))->decryptString($raw, 'settings'), true, 16, JSON_THROW_ON_ERROR);
|
||||||
return is_array($settings) ? $settings : [];
|
return is_array($settings) ? $settings : ['unreadable' => true];
|
||||||
} catch (BackendException|\OCA\NgBackup\Crypto\CryptoException|\JsonException) {
|
} catch (BackendException|\OCA\NgBackup\Crypto\CryptoException|\JsonException) {
|
||||||
return [];
|
return ['unreadable' => true];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ final class BackupService {
|
|||||||
$state['files']['started'] = gmdate('c');
|
$state['files']['started'] = gmdate('c');
|
||||||
// Locations from before 1.1.0 get their sealed settings at their next backup.
|
// Locations from before 1.1.0 get their sealed settings at their next backup.
|
||||||
$this->targets->recordSettings($repo);
|
$this->targets->recordSettings($repo);
|
||||||
|
$this->targets->catchUpWrappedKey($target, $repo);
|
||||||
}
|
}
|
||||||
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
||||||
$dbStats = $state['dbStats'] ?? [];
|
$dbStats = $state['dbStats'] ?? [];
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class KeyService {
|
|||||||
private const KEY_KIT_CODE = 'recovery_kit_code';
|
private const KEY_KIT_CODE = 'recovery_kit_code';
|
||||||
/** Set once a location was created or adopted with this key: from then on it is never replaced. */
|
/** Set once a location was created or adopted with this key: from then on it is never replaced. */
|
||||||
private const KEY_USED = 'master_key_used';
|
private const KEY_USED = 'master_key_used';
|
||||||
|
private const KEY_WRAPPED_PENDING = 'master_key_wrapped_pending';
|
||||||
public const DEFAULT_DELETE_DELAY = 7;
|
public const DEFAULT_DELETE_DELAY = 7;
|
||||||
/**
|
/**
|
||||||
* The location holds the passphrase-wrapped key too, so whoever can read it can try
|
* The location holds the passphrase-wrapped key too, so whoever can read it can try
|
||||||
@@ -127,6 +128,24 @@ final class KeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locations that missed the last passphrase change (offline at the time): they still hold
|
||||||
|
* the previous slots until the change reaches them, which is tried again at each backup.
|
||||||
|
*/
|
||||||
|
public function wrappedKeyPending(int $targetId): bool {
|
||||||
|
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_WRAPPED_PENDING . '_' . $targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One flag per location, so that two processes never overwrite each other's. */
|
||||||
|
public function setWrappedKeyPending(int $targetId, bool $pending): void {
|
||||||
|
$key = self::KEY_WRAPPED_PENDING . '_' . $targetId;
|
||||||
|
if ($pending) {
|
||||||
|
$this->appConfig->setValueBool(Application::APP_ID, $key, true);
|
||||||
|
} elseif ($this->wrappedKeyPending($targetId)) {
|
||||||
|
$this->appConfig->deleteKey(Application::APP_ID, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A key made by mistake on a server that is being restored: remove it, so the key of the
|
* A key made by mistake on a server that is being restored: remove it, so the key of the
|
||||||
* backups can take its place. Only after the new key was opened with its passphrase, and
|
* backups can take its place. Only after the new key was opened with its passphrase, and
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ final class KeySlotService {
|
|||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
try {
|
try {
|
||||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
||||||
|
$this->keys->setWrappedKeyPending($t->getId(), false);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$failed[] = $t->getName();
|
$failed[] = $t->getName();
|
||||||
|
$this->keys->setWrappedKeyPending($t->getId(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$label = '';
|
$label = '';
|
||||||
@@ -55,7 +57,7 @@ final class KeySlotService {
|
|||||||
}
|
}
|
||||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
||||||
if ($failed !== []) {
|
if ($failed !== []) {
|
||||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases; the change is tried again at their next backup): ' . implode(', ', $failed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ final class TargetService {
|
|||||||
*
|
*
|
||||||
* @param array<string, mixed> $options
|
* @param array<string, mixed> $options
|
||||||
* @param ?callable(int, int): void $progress
|
* @param ?callable(int, int): void $progress
|
||||||
* @return array{target: Target, created: bool, deleteDelayDays: ?int} the deletion delay the location had stored, if any
|
* @return array{target: Target, created: bool, deleteDelayDays: ?int, settingsUnreadable: bool} the deletion delay the location had stored, if any, and whether its settings could not be read
|
||||||
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
|
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
|
||||||
*/
|
*/
|
||||||
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
|
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
|
||||||
@@ -194,13 +194,26 @@ final class TargetService {
|
|||||||
if (!Repository::madeWithKey($storageBackend, $keys)) {
|
if (!Repository::madeWithKey($storageBackend, $keys)) {
|
||||||
throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import <kit file>, then this command again.');
|
throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import <kit file>, then this command again.');
|
||||||
}
|
}
|
||||||
$delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null;
|
$settings = Repository::settingsAt($storageBackend, $keys);
|
||||||
|
$delay = $settings['delete_delay_days'] ?? null;
|
||||||
$delay = is_int($delay) ? $delay : null;
|
$delay = is_int($delay) ? $delay : null;
|
||||||
$this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey);
|
$this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey);
|
||||||
if ($old !== null) {
|
if ($old !== null) {
|
||||||
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
|
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
|
||||||
}
|
}
|
||||||
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay];
|
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay, 'settingsUnreadable' => ($settings['unreadable'] ?? false) === true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A passphrase change that missed this location (offline at the time) reaches it now. A failure is tried again at the next backup. */
|
||||||
|
public function catchUpWrappedKey(Target $target, Repository $repo): void {
|
||||||
|
if (!$this->keys->wrappedKeyPending($target->getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$repo->updateWrappedKey($this->keys->wrappedKey());
|
||||||
|
$this->keys->setWrappedKeyPending($target->getId(), false);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
|
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
|
||||||
@@ -402,6 +415,10 @@ final class TargetService {
|
|||||||
}
|
}
|
||||||
$new = LocationSchedule::normalize($schedule);
|
$new = LocationSchedule::normalize($schedule);
|
||||||
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
|
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
|
||||||
|
if ($new === (string)$target->getSchedule() && $zone === $target->getScheduleZone()) {
|
||||||
|
// Saved again unchanged: a moment that already passed still gets its backup
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
$target->setSchedule($new);
|
$target->setSchedule($new);
|
||||||
$target->setScheduleZone($zone);
|
$target->setScheduleZone($zone);
|
||||||
$target->setScheduleSince(time());
|
$target->setScheduleSince(time());
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<strong class="ngb-name">{{ target.name }}</strong>
|
<strong class="ngb-name">{{ target.name }}</strong>
|
||||||
<NcSelect v-model="kind" :options="kinds" label="label" :input-label="t('ng_backup', 'Schedule')" :clearable="false" />
|
<NcSelect v-model="kind" :options="kinds" label="label" :input-label="t('ng_backup', 'Schedule')" :clearable="false" />
|
||||||
<NcTextField v-if="kind.id !== 'off'" v-model="time" class="ngb-time" :label="kind.id === 'daily' ? t('ng_backup', 'At (HH:MM, {zone})', { zone: browserZone }) : t('ng_backup', 'From (HH:MM, {zone})', { zone: browserZone })" />
|
<NcTextField v-if="kind.id !== 'off'" v-model="time" class="ngb-time" :label="kind.id === 'daily' ? t('ng_backup', 'At (HH:MM, {zone})', { zone }) : t('ng_backup', 'From (HH:MM, {zone})', { zone })" />
|
||||||
<NcTextField v-if="kind.id === 'hours'" v-model="hours" class="ngb-hours" type="number" min="1" max="23" :label="t('ng_backup', 'Every … hours')" />
|
<NcTextField v-if="kind.id === 'hours'" v-model="hours" class="ngb-hours" type="number" min="1" max="23" :label="t('ng_backup', 'Every … hours')" />
|
||||||
<NcButton :disabled="busy || wanted === current" @click="save">{{ t('ng_backup', 'Save') }}</NcButton>
|
<NcButton :disabled="busy || wanted === current" @click="save">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,7 +19,6 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
|||||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
import { toZone } from '../format.js'
|
|
||||||
|
|
||||||
const props = defineProps({ target: { type: Object, required: true } })
|
const props = defineProps({ target: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['changed', 'error'])
|
const emit = defineEmits(['changed', 'error'])
|
||||||
@@ -29,9 +28,11 @@ const kinds = [
|
|||||||
{ id: 'daily', label: t('ng_backup', 'Once a day') },
|
{ id: 'daily', label: t('ng_backup', 'Once a day') },
|
||||||
{ id: 'hours', label: t('ng_backup', 'Every few hours') },
|
{ id: 'hours', label: t('ng_backup', 'Every few hours') },
|
||||||
]
|
]
|
||||||
// The time is saved in the administrator's own zone (the browser's); a schedule saved in another
|
// A schedule is shown and edited in the zone it was saved in, named next to the time: "every 6
|
||||||
// zone is shown in this one, so it reads as the moment it runs here.
|
// hours from 02:00" runs until midnight of that zone, so moving it to another zone would change
|
||||||
|
// how often it runs. A location without a schedule gets the administrator's own zone (the browser's).
|
||||||
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||||
|
const zone = computed(() => props.target.schedule !== '' && props.target.scheduleZone ? props.target.scheduleZone : browserZone)
|
||||||
const kind = ref(kinds[0])
|
const kind = ref(kinds[0])
|
||||||
const time = ref('02:00')
|
const time = ref('02:00')
|
||||||
const hours = ref('6')
|
const hours = ref('6')
|
||||||
@@ -39,7 +40,7 @@ const busy = ref(false)
|
|||||||
|
|
||||||
const current = computed(() => {
|
const current = computed(() => {
|
||||||
const [at, every] = props.target.schedule.split('/')
|
const [at, every] = props.target.schedule.split('/')
|
||||||
return at === '' ? '' : (props.target.scheduleZone !== browserZone ? toZone(at, props.target.scheduleZone, browserZone) : at) + (every ? '/' + every : '')
|
return at === '' ? '' : at + (every ? '/' + every : '')
|
||||||
})
|
})
|
||||||
const wanted = computed(() => {
|
const wanted = computed(() => {
|
||||||
if (kind.value.id === 'off') {
|
if (kind.value.id === 'off') {
|
||||||
@@ -59,7 +60,7 @@ async function save() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
emit('error', '')
|
emit('error', '')
|
||||||
try {
|
try {
|
||||||
await api.setTargetSchedule(props.target.id, wanted.value, browserZone)
|
await api.setTargetSchedule(props.target.id, wanted.value, zone.value)
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit('error', e.message)
|
emit('error', e.message)
|
||||||
|
|||||||
@@ -26,30 +26,3 @@ export function formatSize(bytes) {
|
|||||||
}
|
}
|
||||||
return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}`
|
return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A time of day "HH:MM" in zone `from`, as "HH:MM" in zone `to`, at today's offsets.
|
|
||||||
*
|
|
||||||
* @param {string} hhmm the time
|
|
||||||
* @param {string} from IANA zone it is in
|
|
||||||
* @param {string} to IANA zone to show it in
|
|
||||||
* @param {Date} now the day to convert on (today)
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
export function toZone(hhmm, from, to, now = new Date()) {
|
|
||||||
const [h, m] = hhmm.split(':').map(Number)
|
|
||||||
const parts = (zone, date) => Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
||||||
}).formatToParts(date).map((p) => [p.type, p.value]))
|
|
||||||
const offsetAt = (at) => {
|
|
||||||
const seen = parts(from, new Date(at))
|
|
||||||
return Date.UTC(+seen.year, +seen.month - 1, +seen.day, +seen.hour, +seen.minute) - at
|
|
||||||
}
|
|
||||||
const today = parts(from, now)
|
|
||||||
const wall = Date.UTC(+today.year, +today.month - 1, +today.day, h, m)
|
|
||||||
// the offset at the wall time itself: on a day the clocks change it differs from the one at
|
|
||||||
// midnight, so it is looked up a second time at the moment found first
|
|
||||||
const at = wall - offsetAt(wall - offsetAt(wall))
|
|
||||||
const there = parts(to, new Date(at))
|
|
||||||
return `${there.hour}:${there.minute}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class KeyFromLocationTest extends TestCase {
|
|||||||
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
|
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
|
||||||
Repository::openWithKey($this->backend, $this->keys)->recordSettings(['delete_delay_days' => 1]);
|
Repository::openWithKey($this->backend, $this->keys)->recordSettings(['delete_delay_days' => 1]);
|
||||||
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
|
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
|
||||||
$this->assertSame([], Repository::settingsAt($this->backend, KeyRing::generate()));
|
$this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, KeyRing::generate()), 'another key: there, but not readable');
|
||||||
|
unlink($this->dir . '/settings');
|
||||||
|
$this->assertSame([], Repository::settingsAt($this->backend, $this->keys), 'none at all: a location from before 1.1.0');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAlteredSettingsAreNotBelieved(): void {
|
public function testAlteredSettingsAreNotBelieved(): void {
|
||||||
@@ -59,7 +61,7 @@ class KeyFromLocationTest extends TestCase {
|
|||||||
$raw = file_get_contents($file);
|
$raw = file_get_contents($file);
|
||||||
$raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01";
|
$raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01";
|
||||||
file_put_contents($file, $raw);
|
file_put_contents($file, $raw);
|
||||||
$this->assertSame([], Repository::settingsAt($this->backend, $this->keys));
|
$this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, $this->keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheKitWritesALostConfigAgainAndTheBackupsOpen(): void {
|
public function testTheKitWritesALostConfigAgainAndTheBackupsOpen(): void {
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ class LocationScheduleTest extends TestCase {
|
|||||||
|
|
||||||
public function testAClockChangeInTheNightStillGivesOneMoment(): void {
|
public function testAClockChangeInTheNightStillGivesOneMoment(): void {
|
||||||
$this->assertSame(self::at('2026-03-29 01:30 UTC'), S::lastMoment('02:30', self::BERLIN, self::at('2026-03-29 12:00 UTC')), '02:30 does not exist that night');
|
$this->assertSame(self::at('2026-03-29 01:30 UTC'), S::lastMoment('02:30', self::BERLIN, self::at('2026-03-29 12:00 UTC')), '02:30 does not exist that night');
|
||||||
$this->assertNotNull(S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC')));
|
$autumn = S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC'));
|
||||||
|
$this->assertNotNull($autumn);
|
||||||
|
$local = (new \DateTimeImmutable('@' . $autumn))->setTimezone(new \DateTimeZone(self::BERLIN));
|
||||||
|
$this->assertSame('2026-10-25 02:30', $local->format('Y-m-d H:i'), '02:30 happens twice that night: one of them, on that day');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testChoosingATimeDoesNotStartABackup(): void {
|
public function testChoosingATimeDoesNotStartABackup(): void {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
# unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase
|
# unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase
|
||||||
# of the old server; a wrong one keeps the unused key
|
# of the old server; a wrong one keeps the unused key
|
||||||
# used-key a key that protected a location is never replaced, also after the location was removed
|
# used-key a key that protected a location is never replaced, also after the location was removed
|
||||||
|
# slot-catch-up a passphrase added while a location was unreachable reaches it at its next
|
||||||
|
# backup; a new server then gets the key from it with that passphrase
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IMAGE="nextcloud:${1:-35-apache}"
|
IMAGE="nextcloud:${1:-35-apache}"
|
||||||
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
@@ -269,4 +271,31 @@ expect "$OUT" "never replaced" "used-key key:import"
|
|||||||
[ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed"
|
[ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed"
|
||||||
ok "used-key: never replaced, by kit or passphrase, also after its location was removed"
|
ok "used-key: never replaced, by kit or passphrase, also after its location was removed"
|
||||||
|
|
||||||
|
# slot-catch-up: a passphrase change that missed a location reaches it at the next backup
|
||||||
|
start ngb-rs-new
|
||||||
|
docker exec ngb-rs-new sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2 && rm -rf /mnt/shared/catchup && mkdir -p /mnt/shared/catchup && chown www-data /mnt/shared/catchup"
|
||||||
|
run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass --label=Safe >/dev/null || fail "slot-catch-up: key init"
|
||||||
|
CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
|
||||||
|
run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm"
|
||||||
|
# shellcheck disable=SC2046
|
||||||
|
run_on ngb-rs-new backup:target:add offsite $(location catchup) >/dev/null || fail "slot-catch-up: target add"
|
||||||
|
run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: first backup"
|
||||||
|
BEFORE=$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)
|
||||||
|
docker exec ngb-rs-new chmod 000 /mnt/shared/catchup/repo
|
||||||
|
OUT=$(must_fail ngb-rs-new "slot-catch-up: slot add with the location unreachable" backup:key:slot:add --label=CTO --passphrase-file=/tmp/pass2)
|
||||||
|
expect "$OUT" "tried again at their next backup" "slot-catch-up: says the change follows"
|
||||||
|
docker exec ngb-rs-new chmod 755 /mnt/shared/catchup/repo
|
||||||
|
[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" = "$BEFORE" ] || fail "slot-catch-up: the location changed while unreachable"
|
||||||
|
CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
|
||||||
|
run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm the new kit"
|
||||||
|
run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: backup after the location came back"
|
||||||
|
[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" != "$BEFORE" ] || fail "slot-catch-up: the backup did not bring the new passphrase to the location"
|
||||||
|
CATCHFP=$(key_of ngb-rs-new)
|
||||||
|
start ngb-rs-other
|
||||||
|
docker exec ngb-rs-other sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2"
|
||||||
|
# shellcheck disable=SC2046
|
||||||
|
OUT=$(run_on ngb-rs-other backup:target:add offsite $(location catchup) --passphrase-file=/tmp/pass2 2>&1) || { printf '%s\n' "$OUT"; fail "slot-catch-up: a new server with the new passphrase"; }
|
||||||
|
expect "$OUT" "fingerprint $CATCHFP" "slot-catch-up: the new server"
|
||||||
|
ok "slot-catch-up: a passphrase that missed a location reaches it at the next backup and opens it on a new server"
|
||||||
|
|
||||||
echo "RECOVERY SCENARIOS PASS"
|
echo "RECOVERY SCENARIOS PASS"
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ VER=$(occ app:list --output=json | php -r '$a = json_decode(stream_get_contents(
|
|||||||
[ -z "$NEWVER" ] || [ "$VER" = "$NEWVER" ] || fail "after the upgrade the app has version '$VER'"
|
[ -z "$NEWVER" ] || [ "$VER" = "$NEWVER" ] || fail "after the upgrade the app has version '$VER'"
|
||||||
ok "occ upgrade to $VER"
|
ok "occ upgrade to $VER"
|
||||||
|
|
||||||
LIST=$(occ backup:target:list)
|
SCHED=$(occ backup:target:schedule offsite)
|
||||||
printf '%s\n' "$LIST" | grep offsite | grep -q "03:15" || { printf '%s\n' "$LIST"; fail "the location did not keep 03:15 as its schedule"; }
|
printf '%s\n' "$SCHED" | grep -q "^offsite: 03:15 " || { printf '%s\n' "$SCHED"; fail "the location did not keep 03:15 as its schedule"; }
|
||||||
occ backup:list offsite | grep -q "$OLDSNAP" || fail "the old restore point is not listed"
|
occ backup:list offsite | grep -q "$OLDSNAP" || fail "the old restore point is not listed"
|
||||||
occ backup:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify"
|
occ backup:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify"
|
||||||
occ backup:status >/dev/null || fail "backup:status"
|
occ backup:status >/dev/null || fail "backup:status"
|
||||||
@@ -91,9 +91,15 @@ ok "after the upgrade: schedule kept, old restore point listed, verified, a file
|
|||||||
docker exec -u www-data $N sh -c 'head -c 2000000 /dev/urandom > data/admin/files/Docs/after.bin'
|
docker exec -u www-data $N sh -c 'head -c 2000000 /dev/urandom > data/admin/files/Docs/after.bin'
|
||||||
occ files:scan admin >/dev/null
|
occ files:scan admin >/dev/null
|
||||||
occ backup:run offsite >/dev/null || fail "backup after the upgrade"
|
occ backup:run offsite >/dev/null || fail "backup after the upgrade"
|
||||||
[ "$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | sort -u | wc -l)" -ge 2 ] || fail "no new restore point after the upgrade"
|
NEWSNAP=$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | grep -v "$OLDSNAP" | head -1)
|
||||||
|
[ -n "$NEWSNAP" ] || fail "no new restore point after the upgrade"
|
||||||
|
SUM2=$(docker exec $N sha256sum /var/www/html/data/admin/files/Docs/after.bin | cut -d' ' -f1)
|
||||||
|
docker exec $N sh -c 'rm -rf /tmp/rst && mkdir /tmp/rst && chown www-data /tmp/rst'
|
||||||
|
occ backup:restore offsite "$NEWSNAP" data/admin/files/Docs/after.bin --to-directory=/tmp/rst >/dev/null || fail "restore from the new restore point"
|
||||||
|
GOT=$(docker exec $N sh -c 'find /tmp/rst -name after.bin -exec sha256sum {} \;' | cut -d' ' -f1)
|
||||||
|
[ "$GOT" = "$SUM2" ] || fail "the file restored from the new restore point differs"
|
||||||
docker exec $N test -f /mnt/shared/loc/repo/settings || fail "the location got no settings file"
|
docker exec $N test -f /mnt/shared/loc/repo/settings || fail "the location got no settings file"
|
||||||
OUT=$(occ backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) && fail "a used key from before the upgrade was offered for replacement"
|
OUT=$(occ backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) && fail "a used key from before the upgrade was offered for replacement"
|
||||||
printf '%s\n' "$OUT" | grep -q "never replaced" || { printf '%s\n' "$OUT"; fail "the refusal does not say the key is never replaced"; }
|
printf '%s\n' "$OUT" | grep -q "never replaced" || { printf '%s\n' "$OUT"; fail "the refusal does not say the key is never replaced"; }
|
||||||
ok "a new backup runs, the location got its settings, the key counts as used"
|
ok "a new backup runs and restores, the location got its settings, the key counts as used"
|
||||||
echo "UPGRADE $OLDVER -> ${VER:-working tree} PASS"
|
echo "UPGRADE $OLDVER -> ${VER:-working tree} PASS"
|
||||||
|
|||||||
Reference in New Issue
Block a user