569077fa69
On a server without a backup key, backup:target:add finds the backups at the location and takes their key from its config with one of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The deletion delay and the repository id are now stored on the location, sealed with the key, so the delay survives a restore without a kit and --repair-config can write a lost config again from the kit. The key page warns against creating a new key when restoring a server. New test: tests/integration/recovery-scenarios.sh, and a no-kit mode in full-restore-roundtrip.sh.
436 lines
18 KiB
PHP
436 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\NgBackup\Service;
|
|
|
|
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
|
use OCA\NgBackup\Backend\IBackend;
|
|
use OCA\NgBackup\Backend\StorageBackend;
|
|
use OCA\NgBackup\Db\Snapshot;
|
|
use OCA\NgBackup\Db\SnapshotMapper;
|
|
use OCA\NgBackup\Db\Target;
|
|
use OCA\NgBackup\Db\TargetMapper;
|
|
use OCA\NgBackup\Repository\Repository;
|
|
use OCP\App\IAppManager;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use OCP\Files\Storage\IStorage;
|
|
use OCP\Security\ICrypto;
|
|
use OCP\Server;
|
|
|
|
/**
|
|
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
|
|
* object is built only while it is used; nothing is mounted. files_external is loaded for its
|
|
* classes but its enabled/disabled state is left as the admin set it.
|
|
*/
|
|
final class TargetService {
|
|
public function __construct(
|
|
private TargetMapper $mapper,
|
|
private KeyService $keys,
|
|
private ICrypto $crypto,
|
|
private IAppManager $appManager,
|
|
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
|
|
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
|
|
private SnapshotMapper $snapshots,
|
|
private AlertService $alerts,
|
|
) {
|
|
}
|
|
|
|
/** @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array}> */
|
|
public function availableBackends(): array {
|
|
return $this->factory()->describeBackends();
|
|
}
|
|
|
|
/**
|
|
* Add a target: connect, create the repository there (or adopt an existing one made with this
|
|
* installation's key), then save it.
|
|
*
|
|
* @param array<string, mixed> $options
|
|
* @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all
|
|
* @return array{target: Target, created: bool}
|
|
*/
|
|
public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null, bool $repairConfig = false): array {
|
|
$this->validateName($name);
|
|
$this->requireUsableBackend($backend);
|
|
$keys = $this->keys->keyRing();
|
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, !$repairConfig);
|
|
if ($repairConfig) {
|
|
Repository::repairConfig($storageBackend, $keys, $this->keys->wrappedKey());
|
|
}
|
|
if ($storageBackend->exists('config')) {
|
|
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress);
|
|
$created = false;
|
|
} else {
|
|
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
|
$created = true;
|
|
}
|
|
$this->recordSettings($repo);
|
|
|
|
foreach ($this->mapper->findAll() as $existing) {
|
|
if ($existing->getRepositoryId() === $repo->id()) {
|
|
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
|
|
}
|
|
}
|
|
|
|
$target = new Target();
|
|
$target->setName($name);
|
|
$target->setBackend($backend);
|
|
$target->setAuth($auth);
|
|
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
|
$target->setBasePath(trim($basePath, '/'));
|
|
$target->setRepositoryId($repo->id());
|
|
$target->setCreatedAt(time());
|
|
// Like the location before it: a backup scheme is usually one scheme.
|
|
$first = $this->mapper->findAll()[0] ?? null;
|
|
$target->setSchedule($first?->getSchedule() ?? '');
|
|
$target->setScheduleZone($first?->getScheduleZone());
|
|
$target->setScheduleSince(time());
|
|
$target = $this->mapper->insert($target);
|
|
if (!$created) {
|
|
// Disaster recovery: occ backup:list reads this local cache, not the repository
|
|
// directly, so a snapshot made by another installation must be backfilled here too.
|
|
$this->adoptSnapshots($target, $repo);
|
|
}
|
|
return ['target' => $target, 'created' => $created];
|
|
}
|
|
|
|
public const LOCATION_REPOSITORY = 'repository';
|
|
public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config';
|
|
public const LOCATION_EMPTY = 'empty';
|
|
|
|
/**
|
|
* What these settings lead to: a repository, backup data whose config is gone, or nothing
|
|
* of NG Backup. Creates nothing.
|
|
*
|
|
* @return self::LOCATION_*
|
|
*/
|
|
public function locationState(string $backend, string $auth, array $options, string $basePath): string {
|
|
$this->requireUsableBackend($backend);
|
|
$storage = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
|
if ($storage->exists('config')) {
|
|
return self::LOCATION_REPOSITORY;
|
|
}
|
|
return Repository::holdsData($storage) ? self::LOCATION_DATA_WITHOUT_CONFIG : self::LOCATION_EMPTY;
|
|
}
|
|
|
|
/**
|
|
* Disaster recovery without the recovery kit, on an installation without a backup key: take
|
|
* the key from the location's config with one of its passphrases, then add the location as
|
|
* usual. The key is only kept when it really is the one the repository was made with.
|
|
*
|
|
* @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
|
|
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
|
|
*/
|
|
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
|
|
#[\SensitiveParameter] string $passphrase, ?callable $progress = null): array {
|
|
if ($this->keys->isInitialized()) {
|
|
throw new \RuntimeException('A backup key already exists here');
|
|
}
|
|
$this->validateName($name);
|
|
$this->requireUsableBackend($backend);
|
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
|
if (!$storageBackend->exists('config')) {
|
|
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'");
|
|
}
|
|
$wrapped = Repository::wrappedKeyAt($storageBackend);
|
|
$keys = \OCA\NgBackup\Crypto\KeyRing::unwrapAny($wrapped, $passphrase);
|
|
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;
|
|
$delay = is_int($delay) ? $delay : null;
|
|
$this->keys->adoptKey($keys, $wrapped, $delay);
|
|
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay];
|
|
}
|
|
|
|
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
|
|
public function recordSettings(Repository $repo): void {
|
|
try {
|
|
$repo->recordSettings(['delete_delay_days' => $this->keys->deleteDelayDays()]);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change how an existing location is reached (credentials, host, folder, even the connection
|
|
* type) or its name. The new settings must lead to the same repository: the history stays
|
|
* and a typo cannot silently start a new, empty repository. A null option value keeps the
|
|
* stored one, so the web interface never has to send stored passwords back.
|
|
*
|
|
* @param array<string, mixed> $options
|
|
*/
|
|
public function update(Target $target, string $name, string $backend, string $auth, array $options, string $basePath): Target {
|
|
$this->validateName($name, $target->getId());
|
|
$this->requireUsableBackend($backend);
|
|
$stored = $this->options($target);
|
|
foreach ($options as $key => $value) {
|
|
if ($value === null) {
|
|
if (array_key_exists($key, $stored)) {
|
|
$options[$key] = $stored[$key];
|
|
} else {
|
|
unset($options[$key]);
|
|
}
|
|
}
|
|
}
|
|
// Do not create the folder: a typo in the path must not leave an empty folder behind.
|
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
|
if (!$storageBackend->exists('config')) {
|
|
if (!$storageBackend->canConnect()) {
|
|
throw new \InvalidArgumentException('Cannot connect with these settings. Check the host, user name and password');
|
|
}
|
|
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'. To start an empty repository, add a new location instead");
|
|
}
|
|
$repo = Repository::openWithKey($storageBackend, $this->keys->keyRing(), $this->indexCache, $this->anchor);
|
|
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
|
throw new \InvalidArgumentException('These settings lead to a different repository than the one of this location');
|
|
}
|
|
$target->setName($name);
|
|
$target->setBackend($backend);
|
|
$target->setAuth($auth);
|
|
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
|
$target->setBasePath(trim($basePath, '/'));
|
|
return $this->mapper->update($target);
|
|
}
|
|
|
|
/**
|
|
* The stored options for an edit form: password-type values are replaced by null, which
|
|
* update() reads as "keep the stored value".
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function editableOptions(Target $target): array {
|
|
$secret = [];
|
|
foreach ($this->availableBackends() as $b) {
|
|
if ($b['id'] !== $target->getBackend()) {
|
|
continue;
|
|
}
|
|
$params = $b['parameters'];
|
|
foreach ($b['auth'] as $a) {
|
|
if ($a['id'] === $target->getAuth()) {
|
|
$params += $a['parameters'];
|
|
}
|
|
}
|
|
foreach ($params as $key => $p) {
|
|
if (($p['type'] ?? 0) === 2) {
|
|
$secret[$key] = true;
|
|
}
|
|
}
|
|
}
|
|
$options = $this->options($target);
|
|
foreach ($options as $key => $value) {
|
|
if (isset($secret[$key])) {
|
|
$options[$key] = null;
|
|
}
|
|
}
|
|
return $options;
|
|
}
|
|
|
|
private function validateName(string $name, ?int $ownId = null): void {
|
|
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
|
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
|
}
|
|
try {
|
|
$other = $this->mapper->findByName($name);
|
|
} catch (DoesNotExistException) {
|
|
return;
|
|
}
|
|
if ($other->getId() !== $ownId) {
|
|
throw new \InvalidArgumentException("A target named '$name' already exists");
|
|
}
|
|
}
|
|
|
|
private function requireUsableBackend(string $backend): void {
|
|
// Without the native extension files_external falls back to the smbclient binary, which
|
|
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
|
|
// up front instead of letting the admin find out from a failed run.
|
|
if ($backend === 'smb' && !extension_loaded('smbclient')) {
|
|
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
|
|
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
|
|
* would otherwise leave every target permanently undecryptable. Call $applySecretChange
|
|
* (which must be the only thing that changes the secret) through here instead: every
|
|
* target's options are decrypted first, then re-encrypted under whatever secret is active
|
|
* once $applySecretChange returns.
|
|
*/
|
|
public function reencryptOptionsAround(callable $applySecretChange): mixed {
|
|
$plain = [];
|
|
foreach ($this->mapper->findAll() as $target) {
|
|
$plain[$target->getId()] = $this->options($target);
|
|
}
|
|
$result = $applySecretChange();
|
|
foreach ($this->mapper->findAll() as $target) {
|
|
if (!isset($plain[$target->getId()])) {
|
|
continue; // added by $applySecretChange itself; nothing to re-encrypt
|
|
}
|
|
$target->setOptions($this->crypto->encrypt(json_encode($plain[$target->getId()], JSON_THROW_ON_ERROR)));
|
|
$this->mapper->update($target);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
|
|
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
|
|
try {
|
|
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
|
|
} catch (DoesNotExistException) {
|
|
return;
|
|
}
|
|
if ($note !== null) {
|
|
$s->setNote($note !== '' ? $note : null);
|
|
}
|
|
if ($pinned !== null) {
|
|
$s->setPinned($pinned);
|
|
}
|
|
$this->snapshots->update($s);
|
|
}
|
|
|
|
private function adoptSnapshots(Target $target, Repository $repo): void {
|
|
$notes = $repo->snapshotNotes();
|
|
$pinned = array_flip($repo->pinnedSnapshots());
|
|
foreach ($repo->listSnapshots() as $snapshotId) {
|
|
try {
|
|
$this->snapshots->findOne($target->getId(), $snapshotId);
|
|
continue; // already known locally
|
|
} catch (DoesNotExistException) {
|
|
}
|
|
// One unreadable/corrupt snapshot must not stop the rest of the location's
|
|
// snapshots from being backfilled, nor abort adding the target itself.
|
|
try {
|
|
$meta = $repo->snapshotMeta($snapshotId);
|
|
$s = new Snapshot();
|
|
$s->setTargetId($target->getId());
|
|
$s->setSnapshotId($snapshotId);
|
|
$s->setKind((string)($meta['kind'] ?? 'full'));
|
|
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
|
|
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
|
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
|
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
|
$s->setNote($notes[$snapshotId] ?? null);
|
|
$s->setPinned(isset($pinned[$snapshotId]));
|
|
$this->snapshots->insert($s);
|
|
} catch (\Throwable $e) {
|
|
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return list<Target> */
|
|
public function list(): array {
|
|
return $this->mapper->findAll();
|
|
}
|
|
|
|
public function get(string $nameOrId): Target {
|
|
try {
|
|
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
|
} catch (DoesNotExistException) {
|
|
throw new \InvalidArgumentException("No such location: $nameOrId");
|
|
}
|
|
}
|
|
|
|
/** Size limit for the data stored on this location (null = none). */
|
|
/**
|
|
* @param string $schedule see LocationSchedule
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
public function setSchedule(Target $target, string $schedule, string $zone): Target {
|
|
if (!Schedule::validZone($zone)) {
|
|
throw new \InvalidArgumentException('Unknown time zone: ' . $zone);
|
|
}
|
|
$new = LocationSchedule::normalize($schedule);
|
|
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
|
|
$target->setSchedule($new);
|
|
$target->setScheduleZone($zone);
|
|
$target->setScheduleSince(time());
|
|
$target = $this->mapper->update($target);
|
|
if ($turnedOff) {
|
|
$this->alerts->securityEvent('schedule_off', ['target' => $target->getName()]);
|
|
}
|
|
return $target;
|
|
}
|
|
|
|
public function setMaxBytes(Target $target, ?int $maxBytes): Target {
|
|
$target->setMaxBytes($maxBytes);
|
|
return $this->mapper->update($target);
|
|
}
|
|
|
|
public function setAppendOnly(Target $target, bool $appendOnly): Target {
|
|
$target->setAppendOnly($appendOnly);
|
|
return $this->mapper->update($target);
|
|
}
|
|
|
|
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
|
public function remove(Target $target): void {
|
|
$this->mapper->delete($target);
|
|
// The cached index of its repository is only of use while a location points at it
|
|
$repositoryId = $target->getRepositoryId();
|
|
if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) {
|
|
$this->indexCache->forget($repositoryId);
|
|
}
|
|
}
|
|
|
|
public function backend(Target $target): IBackend {
|
|
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
|
|
}
|
|
|
|
public function repository(Target $target): Repository {
|
|
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache, $this->anchor);
|
|
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
|
throw new \RuntimeException('The target now contains a different repository than when it was added');
|
|
}
|
|
$repo->setMaxBytes($target->getMaxBytes());
|
|
return $repo;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function options(Target $target): array {
|
|
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
|
|
$create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
|
|
$storage = new StorageBackend($create(), $basePath, $createBase, $create);
|
|
$storage->readAheadConnections = Server::get(\OCP\IAppConfig::class)
|
|
->getValueInt('ng_backup', 'restore_sftp_connections', \OCA\NgBackup\Backend\SftpRangeFetcher::DEFAULT_CONNECTIONS);
|
|
return $storage;
|
|
}
|
|
|
|
private ?ExternalStorageFactory $factory = null;
|
|
|
|
/**
|
|
* files_external's connection code, without changing the app's state:
|
|
* - enabled: it is booted by Nextcloud; use its own BackendService;
|
|
* - disabled: do NOT boot it (booting registers its mount provider and listeners, which use
|
|
* its database tables; on installations where it was never enabled those do not exist).
|
|
* Only its classes are loaded, and a private BackendService gets its backends and auth
|
|
* mechanisms from its Application class.
|
|
*/
|
|
private function factory(): ExternalStorageFactory {
|
|
if ($this->factory !== null) {
|
|
return $this->factory;
|
|
}
|
|
if ($this->appManager->isEnabledForAnyone('files_external')) {
|
|
$this->appManager->loadApp('files_external');
|
|
return $this->factory = new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
|
}
|
|
if (!class_exists(\OCA\Files_External\AppInfo\Application::class)) {
|
|
$path = $this->appManager->getAppPath('files_external');
|
|
require_once $path . '/composer/autoload.php';
|
|
}
|
|
$service = new \OCA\Files_External\Service\BackendService(
|
|
Server::get(\OCP\IAppConfig::class), Server::get(\Psr\Log\LoggerInterface::class));
|
|
$app = new \OCA\Files_External\AppInfo\Application();
|
|
$service->registerBackendProvider($app);
|
|
$service->registerAuthMechanismProvider($app);
|
|
return $this->factory = new ExternalStorageFactory($service);
|
|
}
|
|
}
|