diff --git a/plinth/modules/nextcloud/__init__.py b/plinth/modules/nextcloud/__init__.py index c71e80c6b..0db1b8250 100644 --- a/plinth/modules/nextcloud/__init__.py +++ b/plinth/modules/nextcloud/__init__.py @@ -8,6 +8,7 @@ from plinth import frontpage, menu from plinth.config import DropinConfigs from plinth.daemon import Daemon, SharedDaemon from plinth.modules.apache.components import Webserver, diagnose_url +from plinth.modules.backups.components import BackupRestore from plinth.modules.firewall.components import (Firewall, FirewallLocalProtection) from plinth.package import Packages @@ -104,6 +105,10 @@ class NextcloudApp(app_module.App): daemon = SharedDaemon('shared-daemon-nextcloud-mysql', 'mysql') self.add(daemon) + backup_restore = NextcloudBackupRestore('backup-restore-nextcloud', + **manifest.backup) + self.add(backup_restore) + def setup(self, old_version): """Install and configure the app.""" super().setup(old_version) @@ -121,3 +126,17 @@ class NextcloudApp(app_module.App): results = super().diagnose() results.append(diagnose_url('docker.com')) return results + + +class NextcloudBackupRestore(BackupRestore): + """Component to backup/restore Nextcloud.""" + + def backup_pre(self, packet): + """Save database contents.""" + super().backup_pre(packet) + privileged.dump_database() + + def restore_post(self, packet): + """Restore database contents.""" + super().restore_post(packet) + privileged.restore_database() diff --git a/plinth/modules/nextcloud/manifest.py b/plinth/modules/nextcloud/manifest.py index 005bea377..3967ed444 100644 --- a/plinth/modules/nextcloud/manifest.py +++ b/plinth/modules/nextcloud/manifest.py @@ -46,4 +46,14 @@ clients = [{ }] }] -backup = {} +backup = { + 'data': { + 'directories': [ + '/var/lib/containers/storage/volumes/nextcloud-volume-fbx/' + ], + 'files': [ + '/var/lib/plinth/backups-data/nextcloud-database.sql', + '/etc/redis/redis.conf' + ] + } +} diff --git a/plinth/modules/nextcloud/privileged.py b/plinth/modules/nextcloud/privileged.py index ec8ae4a4e..e3188cfac 100644 --- a/plinth/modules/nextcloud/privileged.py +++ b/plinth/modules/nextcloud/privileged.py @@ -4,6 +4,7 @@ import os import pathlib import random +import re import string import subprocess import time @@ -32,6 +33,9 @@ NEXTCLOUD_CRON_SERVICE_FILE = pathlib.Path( NEXTCLOUD_CRON_TIMER_FILE = pathlib.Path( f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.timer') +DB_BACKUP_FILE = pathlib.Path( + '/var/lib/plinth/backups-data/nextcloud-database.sql') + @privileged def setup(): @@ -146,11 +150,22 @@ def _create_database(db_password): _db_file_path = pathlib.Path('/var/lib/mysql/nextcloud_fbx') if _db_file_path.exists(): return - query = f'''CREATE USER '{DB_USER}'@'{CONTAINER_IP}' -IDENTIFIED BY'{db_password}'; -CREATE DATABASE {DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -GRANT ALL PRIVILEGES ON {DB_NAME}.* TO '{DB_USER}'@'{CONTAINER_IP}'; -FLUSH PRIVILEGES;''' + + query = f'''CREATE DATABASE {DB_NAME} CHARACTER SET utf8mb4 + COLLATE utf8mb4_general_ci; +''' + subprocess.run(['mysql', '--user', 'root'], input=query.encode(), + check=True) + _set_db_privileges(db_password) + + +def _set_db_privileges(db_password): + """Create user, set password and provide permissions on the database.""" + query = f'''GRANT ALL PRIVILEGES ON {DB_NAME}.* TO + '{DB_USER}'@'{CONTAINER_IP}' + IDENTIFIED BY'{db_password}'; +FLUSH PRIVILEGES; +''' subprocess.run(['mysql', '--user', 'root'], input=query.encode(), check=True) @@ -261,10 +276,11 @@ def _remove_db_socket(): def _drop_database(): """Drop the mysql database that was created during install.""" - query = f'''DROP DATABASE {DB_NAME}; -DROP User '{DB_USER}'@'{CONTAINER_IP}';''' - subprocess.run(['mysql', '--user', 'root'], input=query.encode(), - check=True) + with action_utils.service_ensure_running('mysql'): + query = f'''DROP DATABASE {DB_NAME}; + DROP User '{DB_USER}'@'{CONTAINER_IP}';''' + subprocess.run(['mysql', '--user', 'root'], input=query.encode(), + check=True) def _generate_secret_key(length=64, chars=None): @@ -272,3 +288,62 @@ def _generate_secret_key(length=64, chars=None): chars = chars or (string.ascii_letters + string.digits) rand = random.SystemRandom() return ''.join(rand.choice(chars) for _ in range(length)) + + +def _set_maintenance_mode(on: bool): + """Turn maintenance mode on or off.""" + _run_occ('maintenance:mode', '--on' if on else '--off') + + +@privileged +def dump_database(): + """Dump database to file.""" + _set_maintenance_mode(True) + DB_BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True) + with action_utils.service_ensure_running('mysql'): + with DB_BACKUP_FILE.open('w', encoding='utf-8') as file_handle: + subprocess.run([ + 'mysqldump', '--add-drop-database', '--add-drop-table', + '--add-drop-trigger', '--single-transaction', + '--default-character-set=utf8mb4', '--user', 'root', + '--databases', DB_NAME + ], stdout=file_handle, check=True) + _set_maintenance_mode(False) + + +@privileged +def restore_database(): + """Restore database from file.""" + with action_utils.service_ensure_running('mysql'): + with DB_BACKUP_FILE.open('r', encoding='utf-8') as file_handle: + subprocess.run(['mysql', '--user', 'root'], stdin=file_handle, + check=True) + + _set_db_privileges(_get_dbpassword()) + + action_utils.service_restart('redis-server') + _set_maintenance_mode(False) + + # Attempts to update UUIDs of user and group entries. By default, + # the command attempts to update UUIDs that have been invalidated by + # a migration step. + _run_occ('ldap:update-uuid') + + # Update the systems data-fingerprint after a backup is restored + _run_occ('maintenance:data-fingerprint') + + +def _get_dbpassword(): + """Return the database password from config.php. + + OCC cannot run unless Nextcloud can already connect to the database. + """ + config_file = ('/var/lib/containers/storage/volumes/nextcloud-volume-fbx' + '/_data/config/config.php') + with open(config_file, 'r', encoding='utf-8') as config: + config_contents = config.read() + + pattern = r"'{}'\s*=>\s*'([^']*)'".format(re.escape('dbpassword')) + match = re.search(pattern, config_contents) + + return match.group(1)