Sunil Mohan Adapa 12374d8783
nextcloud: Don't set password on redis server
- Before we can enable this added protection, we need to address a couple of
issues:

  - Redis password is restored after a restore. This means that if nextcloud's
  backup is restored on a machine where redis server already has a password set,
  then the password won't match with the password configured for other apps that
  are using redis.

  - When email server is already installed before this patch and then nextcloud
  is installed. rspamd will fail to connect to redis server. This even with the
  changes intended on the email server as the setup version in those changes has
  not been incremented.

- Restart redis-server only when needed. This avoids major disruption caused due
un-persisted cache and locks removed.

- Don't use Redis for caching of server-local data as this APCu seems to be
preferred by upstream containers.

- Don't set filelocking.enabled=true as this is already the default.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2024-04-19 06:41:47 -04:00

398 lines
13 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Configure Nextcloud."""
import json
import pathlib
import random
import shutil
import string
import subprocess
import time
import augeas
from plinth import action_utils
from plinth.actions import privileged
NETWORK_NAME = 'nextcloud-fbx'
BRIDGE_IP = '172.16.16.1'
CONTAINER_IP = '172.16.16.2'
CONTAINER_NAME = 'nextcloud-freedombox'
VOLUME_NAME = 'nextcloud-volume-freedombox'
IMAGE_NAME = 'docker.io/library/nextcloud:stable-apache'
DB_HOST = 'localhost'
DB_NAME = 'nextcloud_fbx'
DB_USER = 'nextcloud_fbx'
GUI_ADMIN = 'nextcloud-admin'
_volume_path = pathlib.Path(
'/var/lib/containers/storage/volumes/') / VOLUME_NAME
_systemd_location = pathlib.Path('/etc/systemd/system/')
_cron_service_file = _systemd_location / 'nextcloud-cron-freedombox.service'
_cron_timer_file = _systemd_location / 'nextcloud-cron-freedombox.timer'
DB_BACKUP_FILE = pathlib.Path(
'/var/lib/plinth/backups-data/nextcloud-database.sql')
@privileged
def setup():
"""Setup Nextcloud configuration."""
database_password = _generate_secret_key(16)
administrator_password = _generate_secret_key(16)
# Setup database
_create_database()
_set_database_privileges(database_password)
# Setup redis for caching
_redis_listen_socket()
action_utils.podman_run(
network_name=NETWORK_NAME, subnet='172.16.16.0/24',
bridge_ip=BRIDGE_IP, host_port='8181', container_port='80',
container_ip=CONTAINER_IP, container_name=CONTAINER_NAME,
image_name=IMAGE_NAME, extra_run_options=[
'--volume=/run/mysqld/mysqld.sock:/run/mysqld/mysqld.sock',
'--volume=/run/redis/redis-server.sock:'
'/run/redis/redis-server.sock',
'--volume=/run/slapd/ldapi:/run/slapd/ldapi',
f'--volume={VOLUME_NAME}:/var/www/html',
f'--env=TRUSTED_PROXIES={BRIDGE_IP}',
'--env=OVERWRITEWEBROOT=/nextcloud'
])
_configure_firewall(action='add', interface_name=NETWORK_NAME)
# OCC isn't immediately available after the container is spun up.
# Wait until CAN_INSTALL file is available.
timeout = 300
while timeout > 0:
if (_volume_path / '_data/config/CAN_INSTALL').exists():
break
timeout = timeout - 1
time.sleep(1)
_nextcloud_setup_wizard(database_password, administrator_password)
_create_redis_config()
_configure_ldap()
_configure_systemd()
def _run_in_container(
*args, capture_output: bool = False, 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'
] + env_args + [CONTAINER_NAME] + list(args)
return subprocess.run(command, capture_output=capture_output, 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 get_domain():
"""Return domain name set in Nextcloud."""
try:
domain = _run_occ('config:system:get', 'overwritehost',
capture_output=True)
return domain.stdout.decode().strip()
except subprocess.CalledProcessError:
return None
@privileged
def set_domain(domain_name: str):
"""Set Nextcloud domain name."""
protocol = 'https'
if domain_name.endswith('.onion'):
protocol = 'http'
if domain_name:
_run_occ('config:system:set', 'overwritehost', '--value', domain_name)
_run_occ('config:system:set', 'overwrite.cli.url', '--value',
f'{protocol}://{domain_name}/nextcloud')
_run_occ('config:system:set', 'overwriteprotocol', '--value', protocol)
# Restart to apply changes immediately
action_utils.service_restart('nextcloud-freedombox')
@privileged
def set_admin_password(password: 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',
capture_output=True)
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 _configure_firewall(action, interface_name):
subprocess.run([
'firewall-cmd', '--permanent', '--zone=trusted',
f'--{action}-interface={interface_name}'
], check=True)
action_utils.service_restart('firewalld')
def _database_query(query: str):
"""Run a database query."""
subprocess.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 {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_get_status():
"""Return Nextcloud status such installed, in maintenance, etc."""
output = _run_occ('status', '--output=json', capture_output=True)
return json.loads(output.stdout)
def _nextcloud_setup_wizard(db_password, admin_password):
"""Run the Nextcloud installation wizard and enable cron jobs."""
if not _nextcloud_get_status()['installed']:
_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')
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', capture_output=True,
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)
def _configure_systemd():
"""Create systemd units files for container and cron jobs."""
# Create service and timer for running periodic php jobs.
nextcloud_cron_service_content = f'''
[Unit]
Description=Nextcloud cron.php job
[Service]
ExecCondition=/usr/bin/podman exec --user www-data {CONTAINER_NAME} /var/www/html/occ status -e
ExecStart=/usr/bin/podman exec --user www-data {CONTAINER_NAME} php /var/www/html/cron.php
KillMode=process
''' # noqa: E501
nextcloud_cron_timer_content = '''[Unit]
Description=Run Nextcloud cron.php every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Unit=nextcloud-cron-freedombox.service
[Install]
WantedBy=timers.target
'''
_cron_service_file.write_text(nextcloud_cron_service_content)
_cron_timer_file.write_text(nextcloud_cron_timer_content)
action_utils.service_daemon_reload()
@privileged
def uninstall():
"""Uninstall Nextcloud"""
_drop_database()
_configure_firewall(action='remove', interface_name=NETWORK_NAME)
action_utils.podman_uninstall(container_name=CONTAINER_NAME,
network_name=NETWORK_NAME,
volume_name=VOLUME_NAME,
image_name=IMAGE_NAME)
for path in [_cron_service_file, _cron_timer_file]:
path.unlink(missing_ok=True)
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)
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_database_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.
"""
code = 'include_once("/var/www/html/config/config.php");' \
'print($CONFIG["dbpassword"]);'
return _run_in_container('php', '-r', code,
capture_output=True).stdout.decode().strip()
def _create_redis_config():
"""Create a php file for Redis configuration."""
config_file = _volume_path / '_data/config/freedombox.config.php'
file_content = r'''<?php
$CONFIG = [
'memcache.distributed' => '\OC\Memcache\Redis',
'memcache.locking' => '\OC\Memcache\Redis',
'redis' => ['host' => '/run/redis/redis-server.sock'],
];
'''
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')