nextcloud: Add new app based on podman container

Nextcloud has long been a desired app for FreedomBox, however, due to
difficulties around Debian packaging, it hasn't yet been implemented. This
branch creates an app for Nextcloud with the help of Podman. Podman is a
containarization technology, like Docker, but with some extra features
that make its integration into FreedomBox convenient. If the podman
approach turns out to be favorable, we should consider writing a podman
specific module.

How does this work?

The app installs the podman and default-mysql-server Debian packages.
In other apps, such as MediaWiki, FreedomBox chooses SQLite
which eases maintenance and backup/restore. However, this
would bring a significant performance degrade for Nextcloud, hence the
choice of Mysql. Other apps, like Wordpress already use Mysql, so it is
installed on the host as opposed to installing it in a sepatate
container. A firewalld rich rule is created, so the Nextcloud container
can communicate with the db and OpenLDAP.

The podman package comes with a systemd service and timer for
automatically upgrading containers that are labeled as
"io.containers.autoupdate=registry". podman-auto-update.timer is managed
by FreedomBox. We might add a drop-in file for the timer to make it
more consistent with unattended-upgrades.

Podman natively supports creating systemd services for individual
containers. The generated systemd service is then managed by FreedomBox.

The current container image is based on Debian and runs apache inside the container.
To avoid running apache redundantly (both on the host and inside the
container) it would be preferable to use the nextcloud:stable-fpm image
instead, which seems to require creating a new virtual host.

Configure /.well-known URIs to redirect to /nextcloud. There is a conflict with
Radicale if both apps are running.

Put the podman1 interface into the trusted firewalld zone. This results in the
container gaining Internet access which is necessary to downloading Nextcloud
applications and federating with other Nextcloud instances. After applying the
patches, I opened my instance to the Internet to make sure this configuration
doesn't accidentally expose services. I scanned TCP ports 3306 and 6379 (after
installing and binding redis-server to the bridge interface). After that, I
installed the Tor app and put the default WAN interface to the external zone to
confirm that port 9050/TCP does not get exposed through the bridge interface.

To-do:
* test the fpm image
* test running the container in rootless mode for better security

Signed-off-by: Benedek Nagy <contact@nbenedek.me>
[sunil: Add missing docstrings]
[sunil: Make some methods private to module]
[sunil: Run yapf for formatting]
[sunil: Remove a comment to hide form when app is disabled]
[sunil: Update form labels]
[sunil: I18N for client names]
[sunil: Reduce number success messages in form for easy i18n and consistency]
[sunil: Reorganize patch series, squash fixes]
[sunil: Tweak auto update daemon component's ID]
[sunil: Add blank lines for formatting]
[sunil: Minor refactoring for _run_occ method]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Benedek Nagy 2023-09-20 16:06:26 +02:00 committed by Sunil Mohan Adapa
parent c169537975
commit 3d8967a20a
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
13 changed files with 630 additions and 0 deletions

6
debian/copyright vendored
View File

@ -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

View File

@ -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'<strong>"{privileged.GUI_ADMIN}"</strong> 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

View File

@ -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
<Location /nextcloud>
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
</Location>

View File

@ -0,0 +1,7 @@
[INCLUDES]
before = common.conf
[Definition]
_daemon = apache-access
prefregex = %(__prefix_line)s
failregex = \S+ <HOST>(?::\d+)? - \S+ \[[^\]]*\] "GET /nextcloud/login\?direct=1&user=\S+ HTTP/\S+" 200

View File

@ -0,0 +1,4 @@
[nextcloud-freedombox]
enabled = true
filter = nextcloud-freedombox
journalmatch = SYSLOG_IDENTIFIER=apache-access

View File

@ -0,0 +1 @@
plinth.modules.nextcloud

View File

@ -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 <strong>10 characters'
'</strong>. Leave this field blank to keep the current password.'),
required=False, widget=forms.PasswordInput, min_length=10)

View File

@ -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 = {}

View File

@ -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))

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -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')
]

View File

@ -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)