diff --git a/debian/copyright b/debian/copyright index 582f94643..dcf0cbb39 100644 --- a/debian/copyright +++ b/debian/copyright @@ -175,6 +175,12 @@ Comment: https://github.com/mumble-voip/mumble/blob/master/icons/mumble.svg https://github.com/mumble-voip/mumble/blob/master/LICENSE License: BSD-3-clause +Files: plinth/modules/nextcloud/static/icons/nextcloud.png + plinth/modules/nextcloud/static/icons/nextcloud.svg +Copyright: 2016 Nextcloud GmbH. +Comment: https://nextcloud.com/trademarks/ +License: AGPL-3+ + Files: plinth/modules/openvpn/static/icons/openvpn.png Copyright: 2017 Kishan Raval Comment: https://github.com/thekishanraval/Logos diff --git a/plinth/modules/nextcloud/__init__.py b/plinth/modules/nextcloud/__init__.py new file mode 100644 index 000000000..c71e80c6b --- /dev/null +++ b/plinth/modules/nextcloud/__init__.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app to configure Nextcloud.""" + +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +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.firewall.components import (Firewall, + FirewallLocalProtection) +from plinth.package import Packages + +from . import manifest, privileged + +_description = [ + _('Nextcloud is a self-hosted productivity platform which provides ' + 'private and secure functions for file sharing, collaborative work, ' + 'and more. Nextcloud includes the Nextcloud server, client applications ' + 'for desktop computers, and mobile clients. The Nextcloud server ' + 'provides a well integrated web interface.'), + _('All users of FreedomBox can use Nextcloud.'), + _('To perform administrative actions, use the ' + f'"{privileged.GUI_ADMIN}" user.'), + _('You can set a new password in the "Configuration" section below.'), + _('Please note, that Nextcloud isn\'t maintained by Debian, which means ' + 'security and feature updates are applied independently.') +] + + +class NextcloudApp(app_module.App): + """FreedomBox app for Nextcloud.""" + + app_id = 'nextcloud' + + _version = 1 + + configure_when_disabled = False + + def __init__(self): + """Create components for the app.""" + super().__init__() + + info = app_module.Info( + app_id=self.app_id, version=self._version, name=_('Nextcloud'), + icon_filename='nextcloud', + short_description=_('File Storage & Collaboration'), + description=_description, manual_page='Nextcloud', + clients=manifest.clients) + self.add(info) + + menu_item = menu.Menu('menu-nextcloud', info.name, + info.short_description, info.icon_filename, + 'nextcloud:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-nextcloud', info.name, + short_description=info.short_description, + icon=info.icon_filename, + url='/nextcloud/', clients=info.clients, + login_required=True) + self.add(shortcut) + + packages = Packages('packages-nextcloud', + ['podman', 'default-mysql-server'], + conflicts=['libpam-tmpdir'], + conflicts_action=Packages.ConflictsAction.REMOVE) + self.add(packages) + + dropin_configs = DropinConfigs('dropin-configs-nextcloud', [ + '/etc/apache2/conf-available/nextcloud-freedombox.conf', + '/etc/fail2ban/jail.d/nextcloud-freedombox.conf', + '/etc/fail2ban/filter.d/nextcloud-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-nextcloud', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + firewall_local_protection = FirewallLocalProtection( + 'firewall-local-protection-nextcloud', ['8181']) + self.add(firewall_local_protection) + + webserver = Webserver('webserver-nextcloud', 'nextcloud-freedombox', + urls=['https://{host}/nextcloud/login']) + self.add(webserver) + + daemon = Daemon('daemon-nextcloud', 'nextcloud-fbx') + self.add(daemon) + + daemon = Daemon('daemon-nextcloud-timer', 'nextcloud-cron-fbx.timer') + self.add(daemon) + + daemon = SharedDaemon('shared-daemon-podman-auto-update', + 'podman-auto-update.timer') + self.add(daemon) + + daemon = SharedDaemon('shared-daemon-nextcloud-redis', 'redis-server', + listen_ports=[(6379, 'tcp4')]) + self.add(daemon) + + daemon = SharedDaemon('shared-daemon-nextcloud-mysql', 'mysql') + self.add(daemon) + + def setup(self, old_version): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + if not old_version: + self.enable() + + def uninstall(self): + """De-configure and uninstall the app.""" + privileged.uninstall() + super().uninstall() + + def diagnose(self): + """Run diagnostics and return the results.""" + results = super().diagnose() + results.append(diagnose_url('docker.com')) + return results diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf new file mode 100644 index 000000000..8d3758f54 --- /dev/null +++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/apache2/conf-available/nextcloud-freedombox.conf @@ -0,0 +1,25 @@ +## +## On all sites, provide Nextcloud on a default path: /nextcloud +## +## Requires the following Apache modules to be enabled: +## mod_headers +## mod_proxy +## mod_proxy_http +## + +# Redirect .well-known URLs on the server to Nextcloud to enable auto-discovery +# of calendars, contacts, etc. without having to provide full server URLs. If +# another app providing similar functionality is enabled, only one of them will +# work based on the sort order of Apache configuration files. +Redirect 301 /.well-known/carddav /nextcloud/remote.php/dav +Redirect 301 /.well-known/caldav /nextcloud/remote.php/dav +Redirect 301 /.well-known/webfinger /nextcloud/index.php/.well-known/webfinger +Redirect 301 /.well-known/nodeinfo /nextcloud/index.php/.well-known/nodeinfo + + + ProxyPass http://127.0.0.1:8181 + + ## Send the scheme from user's request to enable Nextcloud to redirect URLs, + ## set cookies, set absolute URLs (if any) properly. + RequestHeader set X-Forwarded-Proto 'https' env=HTTPS + diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf new file mode 100644 index 000000000..7f2f0ad4b --- /dev/null +++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/filter.d/nextcloud-freedombox.conf @@ -0,0 +1,7 @@ +[INCLUDES] +before = common.conf + +[Definition] +_daemon = apache-access +prefregex = %(__prefix_line)s +failregex = \S+ (?::\d+)? - \S+ \[[^\]]*\] "GET /nextcloud/login\?direct=1&user=\S+ HTTP/\S+" 200 diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf new file mode 100644 index 000000000..a634a6ef9 --- /dev/null +++ b/plinth/modules/nextcloud/data/usr/share/freedombox/etc/fail2ban/jail.d/nextcloud-freedombox.conf @@ -0,0 +1,4 @@ +[nextcloud-freedombox] +enabled = true +filter = nextcloud-freedombox +journalmatch = SYSLOG_IDENTIFIER=apache-access diff --git a/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud b/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud new file mode 100644 index 000000000..4a1247e87 --- /dev/null +++ b/plinth/modules/nextcloud/data/usr/share/freedombox/modules-enabled/nextcloud @@ -0,0 +1 @@ +plinth.modules.nextcloud diff --git a/plinth/modules/nextcloud/forms.py b/plinth/modules/nextcloud/forms.py new file mode 100644 index 000000000..d2c0dcb64 --- /dev/null +++ b/plinth/modules/nextcloud/forms.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Nextcloud configuration form.""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + + +class NextcloudForm(forms.Form): + """Nextcloud configuration form.""" + + domain = forms.CharField( + label=_('Domain'), required=False, help_text=_( + 'Examples: "myfreedombox.example.org" or "example.onion".')) + + admin_password = forms.CharField( + label=_('Administrator password'), help_text=_( + 'Optional. Set a new password for Nextcloud\'s administrator ' + 'account (nextcloud-admin). The password cannot be a common one ' + 'and the minimum required length is 10 characters' + '. Leave this field blank to keep the current password.'), + required=False, widget=forms.PasswordInput, min_length=10) diff --git a/plinth/modules/nextcloud/manifest.py b/plinth/modules/nextcloud/manifest.py new file mode 100644 index 000000000..005bea377 --- /dev/null +++ b/plinth/modules/nextcloud/manifest.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Application manifest for Nextcloud.""" + +from django.utils.translation import gettext_lazy as _ + +from plinth.clients import store_url + +_nextcloud_android_package_id = 'com.nextcloud.client' + +clients = [{ + 'name': _('Nextcloud'), + 'platforms': [{ + 'type': 'web', + 'url': '/nextcloud/' + }] +}, { + 'name': + _('Nextcloud'), + 'platforms': [{ + 'type': 'download', + 'os': 'gnu-linux', + 'url': 'https://nextcloud.com/install/#install-clients' + }, { + 'type': 'download', + 'os': 'macos', + 'url': 'https://nextcloud.com/install/#install-clients' + }, { + 'type': 'download', + 'os': 'windows', + 'url': 'https://nextcloud.com/install/#install-clients' + }, { + 'type': 'store', + 'os': 'ios', + 'store_name': 'app-store', + 'url': 'https://itunes.apple.com/us/app/nextcloud/id1125420102?mt=8' + }, { + 'type': 'store', + 'os': 'android', + 'store_name': 'google-play', + 'url': store_url('google-play', _nextcloud_android_package_id) + }, { + 'type': 'store', + 'os': 'android', + 'store_name': 'f-droid', + 'url': store_url('f-droid', _nextcloud_android_package_id) + }] +}] + +backup = {} diff --git a/plinth/modules/nextcloud/privileged.py b/plinth/modules/nextcloud/privileged.py new file mode 100644 index 000000000..ec8ae4a4e --- /dev/null +++ b/plinth/modules/nextcloud/privileged.py @@ -0,0 +1,274 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure Nextcloud.""" + +import os +import pathlib +import random +import string +import subprocess +import time + +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') + + +@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) + # 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) + + +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 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;''' + 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""" + _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.""" + 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)) diff --git a/plinth/modules/nextcloud/static/icons/nextcloud.png b/plinth/modules/nextcloud/static/icons/nextcloud.png new file mode 100644 index 000000000..27eadda87 Binary files /dev/null and b/plinth/modules/nextcloud/static/icons/nextcloud.png differ diff --git a/plinth/modules/nextcloud/static/icons/nextcloud.svg b/plinth/modules/nextcloud/static/icons/nextcloud.svg new file mode 100644 index 000000000..a04c5763b --- /dev/null +++ b/plinth/modules/nextcloud/static/icons/nextcloud.svg @@ -0,0 +1,55 @@ + + diff --git a/plinth/modules/nextcloud/urls.py b/plinth/modules/nextcloud/urls.py new file mode 100644 index 000000000..1132e071f --- /dev/null +++ b/plinth/modules/nextcloud/urls.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the Nextcloud module.""" + +from django.urls import re_path + +from .views import NextcloudAppView + +urlpatterns = [ + re_path(r'^apps/nextcloud/$', NextcloudAppView.as_view(), name='index') +] diff --git a/plinth/modules/nextcloud/views.py b/plinth/modules/nextcloud/views.py new file mode 100644 index 000000000..aed885bce --- /dev/null +++ b/plinth/modules/nextcloud/views.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django views for Nextcloud app.""" + +import logging + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ + +from plinth.modules.nextcloud.forms import NextcloudForm +from plinth.views import AppView + +from . import privileged + +logger = logging.getLogger(__name__) + + +class NextcloudAppView(AppView): + """Show Nextcloud app main view.""" + + app_id = 'nextcloud' + form_class = NextcloudForm + + def get_initial(self): + """Return the values to fill in the form.""" + initial = super().get_initial() + initial.update({'domain': privileged.get_domain()}) + return initial + + def form_valid(self, form): + """Apply the changes submitted in the form.""" + old_config = self.get_initial() + new_config = form.cleaned_data + + is_changed = False + + def _value_changed(key): + return old_config.get(key) != new_config.get(key) + + if _value_changed('domain'): + privileged.set_domain(new_config['domain']) + is_changed = True + + if new_config['admin_password']: + try: + privileged.set_admin_password(new_config['admin_password']) + is_changed = True + except Exception: + messages.error( + self.request, + _('Password update failed. Please choose a stronger ' + 'password.')) + if is_changed: + messages.success(self.request, _('Configuration updated.')) + + return super().form_valid(form)