# SPDX-License-Identifier: AGPL-3.0-or-later """Configure Nextcloud.""" import contextlib import json import pathlib import secrets import shutil import string import subprocess import time import augeas from plinth import action_utils from plinth.actions import privileged, secret_str CONTAINER_NAME = 'nextcloud-freedombox' SERVICE_NAME = 'nextcloud-freedombox' VOLUME_NAME = 'nextcloud-freedombox' IMAGE_NAME = 'registry.freedombox.org/library/nextcloud:stable-fpm' WWW_DATA_UID = '33' DB_HOST = 'localhost' DB_NAME = 'nextcloud_fbx' DB_USER = 'nextcloud_fbx' GUI_ADMIN = 'nextcloud-admin' REDIS_DB = 8 # Don't clash with other redis apps _data_path = pathlib.Path('/var/lib/nextcloud/') DB_BACKUP_FILE = pathlib.Path( '/var/lib/plinth/backups-data/nextcloud-database.sql') @privileged def setup(): """Setup Nextcloud configuration.""" # Setup redis for caching _redis_listen_socket() volumes = { '/run/mysqld/mysqld.sock': '/run/mysqld/mysqld.sock', '/run/redis/redis-server.sock': '/run/redis/redis-server.sock', '/run/slapd/ldapi': '/run/slapd/ldapi', VOLUME_NAME: '/var/www/html' } env = {'OVERWRITEWEBROOT': '/nextcloud'} binds_to = ['mariadb.service', 'redis-server.service', 'slapd.service'] action_utils.podman_create(container_name=CONTAINER_NAME, image_name=IMAGE_NAME, volume_name=VOLUME_NAME, volume_path=str(_data_path), volumes=volumes, env=env, binds_to=binds_to) action_utils.service_start(CONTAINER_NAME) _nextcloud_wait_until_ready() # Setup database _create_database() database_password = _get_database_password() if not database_password: database_password = _generate_secret_key(16) _set_database_privileges(database_password) # Setup redis configuration _create_redis_config() # Run setup wizard _nextcloud_setup_wizard(database_password) # Setup LDAP configuraiton _configure_ldap() def _run_in_container( *args, check: bool = True, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: """Run a command inside the container.""" env_args = [f'--env={key}={value}' for key, value in (env or {}).items()] command = ['podman', 'exec', '--user', WWW_DATA_UID ] + env_args + [CONTAINER_NAME] + list(args) return action_utils.run(command, check=check) def _run_occ(*args, **kwargs) -> subprocess.CompletedProcess: """Run the Nextcloud occ command inside the container.""" return _run_in_container('/var/www/html/occ', *args, **kwargs) @privileged def is_enabled() -> bool: """Return if the systemd container service is enabled.""" return action_utils.podman_is_enabled(CONTAINER_NAME) @privileged def enable(): """Enable the systemd container service.""" action_utils.podman_enable(CONTAINER_NAME) @privileged def disable(): """Disable the systemd container service.""" action_utils.podman_disable(CONTAINER_NAME) @privileged def get_override_domain(): """Return the domain name that Nextcloud is configured to override with.""" try: domain = _run_occ('config:system:get', 'overwritehost') return domain.stdout.decode().strip() except subprocess.CalledProcessError: return None @privileged def set_override_domain(domain_name: str): """Set the domain name that Nextcloud will use to override all domains.""" protocol = 'https' if domain_name.endswith('.onion') or not domain_name: protocol = 'http' if domain_name: _run_occ('config:system:set', 'overwritehost', '--value', domain_name) _run_occ('config:system:set', 'overwriteprotocol', '--value', protocol) _run_occ('config:system:set', 'overwrite.cli.url', '--value', f'{protocol}://{domain_name}/nextcloud') else: _run_occ('config:system:delete', 'overwritehost') _run_occ('config:system:delete', 'overwriteprotocol') _run_occ('config:system:set', 'overwrite.cli.url', '--value', f'{protocol}://localhost/nextcloud') # Restart to apply changes immediately action_utils.service_restart('nextcloud-freedombox') @privileged def set_trusted_domains(domains: list[str]): """Set the list of trusted domains.""" _run_occ('config:system:delete', 'trusted_domains') for index, domain in enumerate(domains): _run_occ('config:system:set', 'trusted_domains', str(index), '--value', domain) @privileged def set_admin_password(password: secret_str): """Set password for owncloud-admin""" _run_occ('user:resetpassword', '--password-from-env', GUI_ADMIN, env={'OC_PASS': password}) @privileged def get_default_phone_region(): """"Get the value of default_phone_region.""" try: default_phone_region = _run_occ('config:system:get', 'default_phone_region') return default_phone_region.stdout.decode().strip() except subprocess.CalledProcessError: return None @privileged def set_default_phone_region(region: str): """"Set the value of default_phone_region.""" _run_occ('config:system:set', 'default_phone_region', '--value', region) def _database_query(query: str): """Run a database query.""" action_utils.run(['mysql'], input=query.encode(), check=True) def _create_database(): """Create an empty MySQL database for Nextcloud.""" # SQL injection is avoided due to known input. _db_file_path = pathlib.Path('/var/lib/mysql/nextcloud_fbx') if _db_file_path.exists(): return query = f'CREATE DATABASE IF NOT EXISTS {DB_NAME} ' \ 'CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;' _database_query(query) def _set_database_privileges(db_password: str): """Create user, set password and provide permissions on the database.""" queries = [ f"CREATE USER IF NOT EXISTS '{DB_USER}'@'localhost';", f"GRANT ALL PRIVILEGES ON {DB_NAME}.* TO '{DB_USER}'@'localhost';", f"ALTER USER '{DB_USER}'@'localhost' IDENTIFIED BY '{db_password}';", ] for query in queries: _database_query(query) def _nextcloud_wait_until_ready(): """Wait for Nextcloud container to get ready.""" def _versions_match(): """Return if versions in shipped and runtime directories match. Nextcloud container ships with source in /usr/source/nextcloud. This is copied to /var/www/html/ when there is a mismatch between their version.php. The last step in the coping process is the copy of version.php file itself. """ try: source_version = '/usr/src/nextcloud/version.php' runtime_version = '/var/www/html/version.php' _run_in_container('diff', source_version, runtime_version) return True except subprocess.CalledProcessError: return False # Nextcloud copies sources from /usr/src/nextcloud to /var/www/html inside # the container. Nextcloud is served from the latter location. This happens # on first run of the container and when upgrade happen. Checking for # existence of version.php is easy and works for first install. For # upgrades, we must wait until source is copied to the runtime directory. # The last file to be copied the version.php. Only after this is checking # for the lock file below meaningful. start_time = time.time() while time.time() < start_time + 300: if (_data_path / 'version.php').exists() and _versions_match(): break time.sleep(1) # Wait while Nextcloud is syncing files, running install or performing an # upgrade by trying to obtain an exclusive on its init-sync.lock. Wrap the # echo command with the lock so that the lock is immediately released after # obtaining. We are unable to obtain the lock for 5 minutes, fail and stop # the setup process. lock_file = _data_path / 'nextcloud-init-sync.lock' action_utils.run( ['flock', '--exclusive', '--wait', '300', lock_file, 'echo'], check=True) def _nextcloud_get_status(): """Return Nextcloud status such installed, in maintenance, etc.""" output = _run_occ('status', '--output=json') return json.loads(output.stdout) def _nextcloud_setup_wizard(db_password: str): """Run the Nextcloud installation wizard and enable cron jobs.""" if not _nextcloud_get_status()['installed']: admin_password = _generate_secret_key(16) _run_occ('maintenance:install', '--database=mysql', '--database-host=localhost:/run/mysqld/mysqld.sock', f'--database-name={DB_NAME}', f'--database-user={DB_USER}', f'--database-pass={db_password}', f'--admin-user={GUI_ADMIN}', f'--admin-pass={admin_password}') # For the server to work properly, it's important to configure background # jobs correctly. Cron is the recommended setting. _run_occ('background:cron') # Enable pretty URLs without /index.php in them. _run_occ('config:system:set', 'htaccess.RewriteBase', '--value', '/nextcloud') _run_occ('config:system:set', 'htaccess.IgnoreFrontController', '--type=boolean', '--value=true') # Update the .htaccess file to contain mod_rewrite rules needed for pretty # URLs. This is automatically re-run by scripts when upgrading to next # version. _run_occ('maintenance:update:htaccess') def _configure_ldap(): _run_occ('app:enable', 'user_ldap') # Check if LDAP has already been configured. This is necessary because # if the setup proccess is rerun when updating the FredomBox app another # redundant LDAP config would be created. output = _run_occ('ldap:test-config', 's01', check=False) if 'Invalid configID' in output.stdout.decode(): _run_occ('ldap:create-empty-config') ldap_settings = { 'ldapBase': 'dc=thisbox', 'ldapBaseGroups': 'dc=thisbox', 'ldapBaseUsers': 'dc=thisbox', 'ldapConfigurationActive': '1', 'ldapGroupDisplayName': 'cn', 'ldapGroupFilter': '(&(|(objectclass=posixGroup)))', 'ldapGroupFilterMode': '0', 'ldapGroupFilterObjectclass': 'posixGroup', 'ldapGroupMemberAssocAttr': 'memberUid', 'ldapHost': 'ldapi:///', 'ldapLoginFilter': '(&(|(objectclass=posixAccount))(uid=%uid))', 'ldapLoginFilterEmail': '0', 'ldapLoginFilterMode': '0', 'ldapLoginFilterUsername': '1', 'ldapNestedGroups': '0', 'ldapUserDisplayName': 'cn', 'ldapUserFilter': '(|(objectclass=posixAccount))', 'ldapUserFilterMode': '0', 'ldapUserFilterObjectclass': 'account', 'ldapUuidGroupAttribute': 'auto', 'ldapUuidUserAttribute': 'auto', 'turnOffCertCheck': '0', 'turnOnPasswordChange': '0', 'useMemberOfToDetectMembership': '0' } for key, value in ldap_settings.items(): _run_occ('ldap:set-config', 's01', key, value) @privileged def uninstall(): """Uninstall Nextcloud""" _drop_database() action_utils.podman_uninstall(container_name=CONTAINER_NAME, volume_name=VOLUME_NAME, image_name=IMAGE_NAME, volume_path=str(_data_path)) def _drop_database(): """Drop the database that was created during install.""" with action_utils.service_ensure_running('mysql'): _database_query(f'DROP DATABASE IF EXISTS {DB_NAME};') _database_query(f"DROP USER IF EXISTS '{DB_USER}'@'localhost';") def _generate_secret_key(length=64, chars=None): """Generate a new random secret key for use with Nextcloud.""" chars = chars or (string.ascii_letters + string.digits) return ''.join(secrets.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') @contextlib.contextmanager def _maintenance_mode(): """Context to set maintenance mode temporarily.""" try: _set_maintenance_mode(True) yield finally: _set_maintenance_mode(False) @privileged def dump_database(): """Dump database to file.""" with _maintenance_mode(): with DB_BACKUP_FILE.open('w', encoding='utf-8') as file_handle: action_utils.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) @privileged def restore_database(): """Restore database from file.""" with DB_BACKUP_FILE.open('r', encoding='utf-8') as file_handle: action_utils.run(['mysql', '--user', 'root'], stdin=file_handle, check=True) action_utils.run(['redis-cli', '-n', str(REDIS_DB), 'FLUSHDB', 'SYNC'], check=False) _set_database_privileges(_get_database_password()) # After updating the configuration, a restart seems to be required for the # new DB password be used. action_utils.service_try_restart(SERVICE_NAME) _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_database_password(): """Return the database password from config.php or '' if not set. OCC cannot run unless Nextcloud can already connect to the database. """ code = 'if (file_exists("/var/www/html/config/config.php")) {' \ 'include_once("/var/www/html/config/config.php");' \ 'print($CONFIG["dbpassword"] ?? ""); }' return _run_in_container('php', '-r', code).stdout.decode().strip() def _create_redis_config(): """Create a php file for Redis configuration.""" config_file = _data_path / 'config/freedombox.config.php' file_content = fr''' '\OC\Memcache\Redis', 'memcache.locking' => '\OC\Memcache\Redis', 'redis' => ['host' => '/run/redis/redis-server.sock', 'dbindex' => {REDIS_DB}], ]; ''' config_file.write_text(file_content) shutil.chown(config_file, 'www-data', 'www-data') def _load_augeas(): """Initialize Augeas.""" aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD + augeas.Augeas.NO_MODL_AUTOLOAD) redis_config = '/etc/redis/redis.conf' aug.transform('Spacevars', redis_config) aug.set('/augeas/context', '/files' + redis_config) aug.load() return aug def _redis_listen_socket(): """Configure Redis to listen on a UNIX socket.""" aug = _load_augeas() value = '/etc/redis/conf.d/*.conf' found = any((aug.get(match_) == value for match_ in aug.match('include'))) if not found: aug.set('include[last() + 1]', value) aug.save() action_utils.service_restart('redis-server')