mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
Configure caching for Nextcloud with Redis running on the host, create static php file under /var/lib/containers/storage/volumes/nextcloud-volume-fbx/_data/config. Nextcloud communicates with Redis via the bridge adapter, which isn't a loopback device. For this reason, setting a redis password is required. In addition, configure the email app to accomodate the changes. Signed-off-by: Benedek Nagy <contact@nbenedek.me> Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
433 lines
14 KiB
Python
433 lines
14 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Configure Nextcloud."""
|
|
|
|
import os
|
|
import pathlib
|
|
import random
|
|
import re
|
|
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-fbx'
|
|
VOLUME_NAME = 'nextcloud-volume-fbx'
|
|
IMAGE_NAME = 'docker.io/library/nextcloud:stable-apache'
|
|
|
|
DB_HOST = 'localhost'
|
|
DB_NAME = 'nextcloud_fbx'
|
|
DB_USER = 'nextcloud_fbx'
|
|
GUI_ADMIN = 'nextcloud-admin'
|
|
SOCKET_CONFIG_FILE = pathlib.Path('/etc/mysql/mariadb.conf.d/'
|
|
'99-freedombox.cnf')
|
|
SYSTEMD_LOCATION = '/etc/systemd/system/'
|
|
NEXTCLOUD_CONTAINER_SYSTEMD_FILE = pathlib.Path(
|
|
f'{SYSTEMD_LOCATION}{CONTAINER_NAME}.service')
|
|
NEXTCLOUD_CRON_SERVICE_FILE = pathlib.Path(
|
|
f'{SYSTEMD_LOCATION}nextcloud-cron-fbx.service')
|
|
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')
|
|
|
|
REDIS_CONFIG = '/etc/redis/redis.conf'
|
|
REDIS_CONFIG_AUG = f'/files{REDIS_CONFIG}'
|
|
|
|
|
|
@privileged
|
|
def setup():
|
|
"""Setup Nextcloud configuration."""
|
|
database_password = _generate_secret_key(16)
|
|
administrator_password = _generate_secret_key(16)
|
|
_configure_db_socket()
|
|
_configure_firewall(action='add', interface_name=NETWORK_NAME)
|
|
_create_database(database_password)
|
|
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, volume_name=VOLUME_NAME,
|
|
container_name=CONTAINER_NAME, image_name=IMAGE_NAME,
|
|
extra_run_options=[
|
|
'--env=TRUSTED_PROXIES={BRIDGE_IP}',
|
|
'--env=OVERWRITEWEBROOT=/nextcloud'
|
|
])
|
|
# OCC isn't immediately available after the container is spun up.
|
|
# Wait until CAN_INSTALL file is available.
|
|
timeout = 300
|
|
while timeout > 0:
|
|
if os.path.exists('/var/lib/containers/storage/volumes/'
|
|
'nextcloud-volume-fbx/_data/config/CAN_INSTALL'):
|
|
break
|
|
timeout = timeout - 1
|
|
time.sleep(1)
|
|
|
|
_nextcloud_setup_wizard(database_password, administrator_password)
|
|
_bind_redis(f'127.0.0.1 -::1 {BRIDGE_IP}')
|
|
_set_redis_password(_generate_secret_key(16))
|
|
action_utils.service_restart('redis-server')
|
|
_create_redis_config(_get_redis_password())
|
|
# 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.
|
|
is_ldap_configured = _run_occ('ldap:test-config', 's01',
|
|
capture_output=True)
|
|
if is_ldap_configured != ('The configuration is valid and the connection '
|
|
'could be established!'):
|
|
_configure_ldap()
|
|
|
|
_configure_systemd()
|
|
|
|
|
|
def _run_occ(*args, capture_output: bool = False):
|
|
"""Run the Nextcloud occ command inside the container."""
|
|
occ = [
|
|
'podman', 'exec', '--user', 'www-data', CONTAINER_NAME, 'php', 'occ'
|
|
] + list(args)
|
|
return subprocess.run(occ, capture_output=capture_output, check=False)
|
|
|
|
|
|
@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-fbx')
|
|
|
|
|
|
@privileged
|
|
def set_admin_password(password: str):
|
|
"""Set password for owncloud-admin"""
|
|
subprocess.run([
|
|
'podman', 'exec', '--user', 'www-data', f'--env=OC_PASS={password}',
|
|
'-it', CONTAINER_NAME, 'sh', '-c',
|
|
("/var/www/html/occ "
|
|
f"user:resetpassword --password-from-env {GUI_ADMIN}")
|
|
], check=True)
|
|
|
|
|
|
@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 _configure_db_socket():
|
|
file_content = f'''## This file is automatically generated by FreedomBox
|
|
## Enable database to create a socket for podman's bridge network
|
|
[mysqld]
|
|
bind-address = {BRIDGE_IP}
|
|
'''
|
|
SOCKET_CONFIG_FILE.write_text(file_content, encoding='utf-8')
|
|
action_utils.service_restart('mariadb')
|
|
|
|
|
|
def _create_database(db_password):
|
|
"""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;
|
|
'''
|
|
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)
|
|
|
|
|
|
def _nextcloud_setup_wizard(db_password, admin_password):
|
|
admin_data_dir = pathlib.Path(
|
|
'/var/lib/containers/storage/volumes/nextcloud-volume-fbx/'
|
|
f'_data/data/{GUI_ADMIN}')
|
|
if not admin_data_dir.exists():
|
|
_run_occ('maintenance:install', '--database=mysql',
|
|
f'--database-name={DB_NAME}', f'--database-host={BRIDGE_IP}',
|
|
'--database-port=3306', 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')
|
|
_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': BRIDGE_IP,
|
|
'ldapLoginFilter': '(&(|(objectclass=posixAccount))(uid=%uid))',
|
|
'ldapLoginFilterEmail': '0',
|
|
'ldapLoginFilterMode': '0',
|
|
'ldapLoginFilterUsername': '1',
|
|
'ldapNestedGroups': '0',
|
|
'ldapPort': '389',
|
|
'ldapTLS': '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():
|
|
systemd_content = subprocess.run(
|
|
['podman', 'generate', 'systemd', '--new', CONTAINER_NAME],
|
|
capture_output=True, check=True).stdout.decode()
|
|
# Create service and timer for running periodic php jobs.
|
|
NEXTCLOUD_CONTAINER_SYSTEMD_FILE.write_text(systemd_content,
|
|
encoding='utf-8')
|
|
nextcloud_cron_service_content = '''
|
|
[Unit]
|
|
Description=Nextcloud cron.php job
|
|
|
|
[Service]
|
|
ExecCondition=/usr/bin/podman exec --user www-data nextcloud-fbx php occ status -e
|
|
ExecStart=/usr/bin/podman exec --user www-data nextcloud-fbx 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-fbx.service
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
'''
|
|
NEXTCLOUD_CRON_SERVICE_FILE.write_text(nextcloud_cron_service_content)
|
|
NEXTCLOUD_CRON_TIMER_FILE.write_text(nextcloud_cron_timer_content)
|
|
action_utils.service_daemon_reload()
|
|
|
|
|
|
@privileged
|
|
def uninstall():
|
|
"""Uninstall Nextcloud"""
|
|
# Set bind setting back to default in case other apps
|
|
# are still using it
|
|
_bind_redis('127.0.0.1 -::1')
|
|
action_utils.service_restart('redis-server')
|
|
_drop_database()
|
|
_remove_db_socket()
|
|
_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)
|
|
files = [NEXTCLOUD_CRON_SERVICE_FILE, NEXTCLOUD_CRON_TIMER_FILE]
|
|
for file in files:
|
|
file.unlink(missing_ok=True)
|
|
|
|
|
|
def _remove_db_socket():
|
|
SOCKET_CONFIG_FILE.unlink(missing_ok=True)
|
|
action_utils.service_restart('mariadb')
|
|
|
|
|
|
def _drop_database():
|
|
"""Drop the mysql database that was created during install."""
|
|
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):
|
|
"""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_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)
|
|
|
|
|
|
def _create_redis_config(password):
|
|
"""Create a php file for Redis configuration."""
|
|
config_file = pathlib.Path(
|
|
'/var/lib/containers/storage/volumes/nextcloud-volume-fbx/_data/'
|
|
'config/freedombox.config.php')
|
|
file_content = f'''<?php
|
|
$CONFIG = [
|
|
'filelocking.enabled' => true,
|
|
'memcache.locking' => '\\\\OC\\\\Memcache\\\\Redis',
|
|
'memcache.distributed' => '\\\\OC\\\\Memcache\\\\Redis',
|
|
'redis' => [
|
|
'host' => '{BRIDGE_IP}',
|
|
'port' => '6379',
|
|
'password' => '{password}',
|
|
],
|
|
];
|
|
'''
|
|
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)
|
|
aug.transform('Spacevars', REDIS_CONFIG)
|
|
aug.set('/augeas/context', REDIS_CONFIG_AUG)
|
|
aug.load()
|
|
return aug
|
|
|
|
|
|
def _bind_redis(ip_address):
|
|
"""Configure Redis to listen on the podman bridge adapter."""
|
|
aug = _load_augeas()
|
|
aug.set(REDIS_CONFIG_AUG + '/bind', ip_address)
|
|
aug.save()
|
|
|
|
|
|
def _set_redis_password(password):
|
|
if _get_redis_password() is None:
|
|
aug = _load_augeas()
|
|
aug.set(REDIS_CONFIG_AUG + '/requirepass', password)
|
|
aug.save()
|
|
|
|
|
|
def _get_redis_password() -> str:
|
|
aug = _load_augeas()
|
|
password = aug.get(REDIS_CONFIG_AUG + '/requirepass')
|
|
return password
|