, 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 $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); try { 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; } } catch (\OCA\NgBackup\Repository\RepositoryException $e) { $otherKey = str_contains($e->getMessage(), 'different key') || str_contains($e->getMessage(), 'do not open with this key'); if ($otherKey && $this->keyIsUnused()) { throw new \OCA\NgBackup\Repository\RepositoryException(rtrim($e->getMessage(), '.') . '. ' . self::UNUSED_KEY_HINT, 0, $e); } throw $e; } $this->keys->markUsed(); $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 UNUSED_KEY_HINT = 'The backup key of this server was never used (no location, no backup), so it can make way for the key of these backups: ' . 'with the old server\'s recovery kit, occ backup:key:import --replace-unused-key; without the kit, ' . 'occ backup:target:add with the same details and --replace-unused-key (asks for a passphrase of the old server)'; /** * A key that never protected anything: no location was created or adopted with it, and the * database has no location and no run (installations from before the flag existed). Only such * a key may make way for the key of existing backups, e.g. after a new key was made by * mistake on a server that is being restored. */ public function keyIsUnused(): bool { return $this->keys->isInitialized() && !$this->keys->wasUsed() && $this->mapper->findAll() === [] && $this->runs->countAll() === 0; } /** * occ backup:key:import on a server whose own key was never used: the kit's key takes its place. * * @param array $kit * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase: nothing changed */ public function importKitReplacingUnusedKey(array $kit, #[\SensitiveParameter] string $passphrase): void { $old = $this->requireUnusedKey(); $this->keys->importRecoveryKit($kit, $passphrase, true); $this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]); } /** @return string the fingerprint of the unused key */ private function requireUnusedKey(): string { if (!$this->keys->isInitialized()) { throw new \RuntimeException('There is no backup key here to replace'); } if (!$this->keyIsUnused()) { throw new \RuntimeException('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced'); } return $this->keys->fingerprint(); } 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. * With $replaceUnusedKey, a key this server has but never used makes way (keyIsUnused()). * * @param array $options * @param ?callable(int, int): void $progress * @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, #[\SensitiveParameter] string $passphrase, ?callable $progress = null, bool $replaceUnusedKey = false): array { $old = null; if ($replaceUnusedKey) { $old = $this->requireUnusedKey(); } elseif ($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 , then this command again.'); } $settings = Repository::settingsAt($storageBackend, $keys); $stored = $settings['delete_delay_days'] ?? null; $delay = is_int($stored) && $stored >= 1 && $stored <= 365 ? $stored : null; // Settings that are there but cannot be read, or hold no valid delay (also none at all): the old server's delay // is unknown, so take the longest one; a shorter guess could empty the Bin too soon. $unknown = ($settings['unreadable'] ?? false) === true || ($settings !== [] && $delay === null); $this->keys->adoptKey($keys, $wrapped, $delay ?? ($unknown ? self::LONGEST_DELETE_DELAY : null), $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, 'settingsUnreadable' => $unknown]; } /** 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 $e) { Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: the last passphrase change still does not reach ' . $target->getName() . ' (it keeps the previous passphrases); tried again at its next backup: ' . $e->getMessage(), ['exception' => $e]); } } /** 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 $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 */ 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 */ 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() !== ''; 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()); $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 location is gone now: tidying up must not fail the removal, or the caller would skip // the removal event and a retry would no longer find the location $this->tidyUp($target, fn () => $this->keys->setWrappedKeyPending($target->getId(), false)); // The cached index of its repository is only of use while a location points at it $this->tidyUp($target, function () use ($target): void { $repositoryId = $target->getRepositoryId(); if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) { $this->indexCache->forget($repositoryId); } }); } /** One tidy-up step after a removal: a failure is logged (if even that works) and never thrown. */ private function tidyUp(Target $target, callable $step): void { try { $step(); } catch (\Throwable $e) { try { Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: location ' . $target->getName() . ' removed, tidying up after it failed: ' . $e->getMessage(), ['exception' => $e]); } catch (\Throwable) { // nothing left to tell it to } } } 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 */ 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); } }