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.
|
||||
- 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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
kit writes it again (for locations backed up by this version or newer). Before, the message said
|
||||
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
|
||||
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
|
||||
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
|
||||
- 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);
|
||||
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)) {
|
||||
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'));
|
||||
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(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null
|
||||
? 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the location).'
|
||||
: '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).');
|
||||
$days = 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days';
|
||||
$output->writeln(match (true) {
|
||||
($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>).');
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -34,6 +34,10 @@ final class TargetSchedule extends Command {
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$t = $this->targets->get($input->getArgument('target'));
|
||||
$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());
|
||||
if ($schedule !== null) {
|
||||
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
|
||||
* 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
|
||||
* cannot be read.
|
||||
* Empty when the location has none yet (made before 1.1.0, until its next backup); only
|
||||
* ['unreadable' => true] when they are there but cannot be read.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -253,9 +253,9 @@ final class Repository {
|
||||
$raw = (string)stream_get_contents($fh);
|
||||
fclose($fh);
|
||||
$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) {
|
||||
return [];
|
||||
return ['unreadable' => true];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ final class BackupService {
|
||||
$state['files']['started'] = gmdate('c');
|
||||
// Locations from before 1.1.0 get their sealed settings at their next backup.
|
||||
$this->targets->recordSettings($repo);
|
||||
$this->targets->catchUpWrappedKey($target, $repo);
|
||||
}
|
||||
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
||||
$dbStats = $state['dbStats'] ?? [];
|
||||
|
||||
@@ -32,6 +32,7 @@ final class KeyService {
|
||||
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. */
|
||||
private const KEY_USED = 'master_key_used';
|
||||
private const KEY_WRAPPED_PENDING = 'master_key_wrapped_pending';
|
||||
public const DEFAULT_DELETE_DELAY = 7;
|
||||
/**
|
||||
* 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
|
||||
* 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) {
|
||||
try {
|
||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
||||
$this->keys->setWrappedKeyPending($t->getId(), false);
|
||||
} catch (\Throwable $e) {
|
||||
$failed[] = $t->getName();
|
||||
$this->keys->setWrappedKeyPending($t->getId(), true);
|
||||
}
|
||||
}
|
||||
$label = '';
|
||||
@@ -55,7 +57,7 @@ final class KeySlotService {
|
||||
}
|
||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
||||
if ($failed !== []) {
|
||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
||||
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 ?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
|
||||
*/
|
||||
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)) {
|
||||
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;
|
||||
$this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey);
|
||||
if ($old !== null) {
|
||||
$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. */
|
||||
@@ -402,6 +415,10 @@ final class TargetService {
|
||||
}
|
||||
$new = LocationSchedule::normalize($schedule);
|
||||
$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->setScheduleZone($zone);
|
||||
$target->setScheduleSince(time());
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="ngb-row">
|
||||
<strong class="ngb-name">{{ target.name }}</strong>
|
||||
<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')" />
|
||||
<NcButton :disabled="busy || wanted === current" @click="save">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||
</div>
|
||||
@@ -19,7 +19,6 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { toZone } from '../format.js'
|
||||
|
||||
const props = defineProps({ target: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed', 'error'])
|
||||
@@ -29,9 +28,11 @@ const kinds = [
|
||||
{ id: 'daily', label: t('ng_backup', 'Once a day') },
|
||||
{ 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
|
||||
// zone is shown in this one, so it reads as the moment it runs here.
|
||||
// A schedule is shown and edited in the zone it was saved in, named next to the time: "every 6
|
||||
// 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 zone = computed(() => props.target.schedule !== '' && props.target.scheduleZone ? props.target.scheduleZone : browserZone)
|
||||
const kind = ref(kinds[0])
|
||||
const time = ref('02:00')
|
||||
const hours = ref('6')
|
||||
@@ -39,7 +40,7 @@ const busy = ref(false)
|
||||
|
||||
const current = computed(() => {
|
||||
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(() => {
|
||||
if (kind.value.id === 'off') {
|
||||
@@ -59,7 +60,7 @@ async function save() {
|
||||
busy.value = true
|
||||
emit('error', '')
|
||||
try {
|
||||
await api.setTargetSchedule(props.target.id, wanted.value, browserZone)
|
||||
await api.setTargetSchedule(props.target.id, wanted.value, zone.value)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
emit('error', e.message)
|
||||
|
||||
@@ -26,30 +26,3 @@ export function formatSize(bytes) {
|
||||
}
|
||||
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']);
|
||||
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([], 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 {
|
||||
@@ -59,7 +61,7 @@ class KeyFromLocationTest extends TestCase {
|
||||
$raw = file_get_contents($file);
|
||||
$raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01";
|
||||
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 {
|
||||
|
||||
@@ -48,7 +48,10 @@ class LocationScheduleTest extends TestCase {
|
||||
|
||||
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->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 {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# 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
|
||||
# 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
|
||||
IMAGE="nextcloud:${1:-35-apache}"
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -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'"
|
||||
ok "occ upgrade to $VER"
|
||||
|
||||
LIST=$(occ backup:target:list)
|
||||
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"; }
|
||||
SCHED=$(occ backup:target:schedule offsite)
|
||||
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:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify"
|
||||
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'
|
||||
occ files:scan admin >/dev/null
|
||||
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"
|
||||
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"; }
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user