diff --git a/debian/copyright b/debian/copyright index 52c0f3fce..06eea8862 100644 --- a/debian/copyright +++ b/debian/copyright @@ -275,6 +275,12 @@ Copyright: Interface (https://www.shareicon.net/author/interface) Comment: https://www.shareicon.net/universal-interface-interface-sharing-share-697502 License: CC-BY-3.0 +Files: plinth/modules/sogo/static/icons/sogo.png + plinth/modules/sogo/static/icons/sogo.svg +Copyright: 2024 Inverse inc./Alinto +Comment: https://github.com/Alinto/sogo/blob/master/COPYING.GPL +License: GPL-2 + Files: plinth/modules/syncthing/static/icons/syncthing.png Copyright: 2015 Jack Palevich 2014 The Syncthing Authors diff --git a/plinth/modules/sogo/__init__.py b/plinth/modules/sogo/__init__.py new file mode 100644 index 000000000..5ac09821f --- /dev/null +++ b/plinth/modules/sogo/__init__.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""FreedomBox app to configure SOGo.""" + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.config import DropinConfigs +from plinth.daemon import Daemon, SharedDaemon +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.package import Packages +from plinth.privileged import service as service_privileged +from plinth.utils import format_lazy + +from . import manifest, privileged + +_description = [ + _('SOGo is a groupware server that provides a rich web interface for ' + 'email, calendar, tasks, and contacts. Calendar, tasks, and contacts ' + 'can also be accessed with various mobile and desktop applications ' + 'using the CalDAV and CardDAV standards.'), + format_lazy( + _('Webmail works with the Postfix/Dovecot ' + 'email server app to retrieve, manage, and send email.'), + email_url=reverse_lazy('email:index')), + format_lazy( + _('All users on {box_name} can login into and use SOGo. Mails ' + 'delivered to their mailboxes by the email server app can be read ' + 'and new mail can be sent out.'), box_name=_(cfg.box_name)), +] + + +class SOGoApp(app_module.App): + """FreedomBox app for SOGo.""" + + app_id = 'sogo' + + _version = 1 + + def __init__(self) -> None: + """Create components for the app.""" + super().__init__() + + info = app_module.Info(app_id=self.app_id, version=self._version, + depends=['email'], name=_('SOGo'), + icon_filename='sogo', description=_description, + manual_page='SOGo', clients=manifest.clients, + tags=manifest.tags) + self.add(info) + + menu_item = menu.Menu('menu-sogo', info.name, info.icon_filename, + info.tags, 'sogo:index', parent_url_name='apps') + self.add(menu_item) + + shortcut = frontpage.Shortcut('shortcut-sogo', info.name, + icon=info.icon_filename, url='/SOGo/', + clients=info.clients, tags=info.tags) + self.add(shortcut) + + packages = Packages('packages-sogo', + ['sogo', 'postgresql', 'memcached']) + self.add(packages) + + dropin_configs = DropinConfigs('dropin-configs-sogo', [ + '/etc/apache2/conf-available/sogo-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-sogo', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-sogo', 'sogo-freedombox', + urls=['https://{host}/SOGo/']) + self.add(webserver) + + daemon1 = SharedDaemon('shared-daemon-sogo-memcached', 'memcached', + listen_ports=[(11211, 'tcp4')]) + self.add(daemon1) + + daemon2 = SharedDaemon('shared-daemon-sogo-postgresql', 'postgresql') + self.add(daemon2) + + daemon3 = Daemon('daemon-sogo', 'sogo', listen_ports=[(20000, 'tcp4')]) + self.add(daemon3) + + backup_restore = SOGoBackupRestore('backup-restore-sogo', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + service_privileged.try_restart('sogo') + service_privileged.try_restart('memcached') + + if not old_version: + self.enable() + + def uninstall(self): + """De-configure and uninstall the app.""" + super().uninstall() + privileged.uninstall() + + +class SOGoBackupRestore(BackupRestore): + """Component to backup/restore SOGo.""" + + def backup_pre(self, packet): + """Save database contents.""" + super().backup_pre(packet) + privileged.dump_database() + + def restore_post(self, packet): + """Restore database contents.""" + super().restore_post(packet) + privileged.restore_database() diff --git a/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf b/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf new file mode 100644 index 000000000..cde0401c1 --- /dev/null +++ b/plinth/modules/sogo/data/usr/lib/systemd/system/sogo.service.d/freedombox.conf @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Service] +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SETUID CAP_SETGID CAP_SETPCAP CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER CAP_NET_ADMIN CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE CAP_KILL CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_RAW CAP_LINUX_IMMUTABLE CAP_IPC_LOCK CAP_SYS_CHROOT CAP_BLOCK_SUSPEND CAP_LEASE CAP_SYS_PACCT CAP_SYS_TTY_CONFIG CAP_SYS_BOOT CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_SYS_NICE CAP_SYS_RESOURCE CAP_BPF +DevicePolicy=closed +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateMounts=yes +PrivateTmp=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +ReadWritePaths=/var/spool/sogo /var/log/sogo +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@resources +SystemCallFilter=~@privileged +SystemCallErrorNumber=EPERM diff --git a/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf b/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf new file mode 100644 index 000000000..421bf8a91 --- /dev/null +++ b/plinth/modules/sogo/data/usr/share/freedombox/etc/apache2/conf-available/sogo-freedombox.conf @@ -0,0 +1,36 @@ +## +## On all sites, provide SOGo on a default path: /SOGo +## https://www.sogo.nu/support/faq/how-to-configure-apache-as-frontend.html +## + +Alias /SOGo.woa/WebServerResources/ /usr/lib/GNUstep/SOGo/WebServerResources/ +Alias /SOGo/WebServerResources/ /usr/lib/GNUstep/SOGo/WebServerResources/ + +Redirect 301 /.well-known/caldav /SOGo/dav +Redirect 301 /.well-known/carddav /SOGo/dav + + + ProxyPass http://127.0.0.1:20000/SOGo retry=0 nocanon + ProxyPreserveHost On + SetEnv proxy-nokeepalive 1 + + + RequestHeader set "x-webobjects-server-port" "443" + SetEnvIf Host (.*) HTTP_HOST=$1 + RequestHeader set "x-webobjects-server-name" "%{HTTP_HOST}e" env=HTTP_HOST + RequestHeader set "x-webobjects-server-url" "https://%{HTTP_HOST}e" env=HTTP_HOST + + + AddDefaultCharset UTF-8 + + + + Require all granted + # Explicitly allow caching of static content to avoid browser specific + # behavior. A resource's URL MUST change in order to have the client load + # the new version. + + ExpiresActive On + ExpiresDefault "access plus 1 year" + + diff --git a/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo b/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo new file mode 100644 index 000000000..093376882 --- /dev/null +++ b/plinth/modules/sogo/data/usr/share/freedombox/modules-enabled/sogo @@ -0,0 +1 @@ +plinth.modules.sogo diff --git a/plinth/modules/sogo/forms.py b/plinth/modules/sogo/forms.py new file mode 100644 index 000000000..dadfb4d75 --- /dev/null +++ b/plinth/modules/sogo/forms.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Forms for the SOGo app.""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from plinth.modules.names.components import DomainName + + +def _get_domain_choices(): + """Double domain entries for inclusion in the choice field.""" + return ((domain.name, domain.name) for domain in DomainName.list()) + + +class DomainForm(forms.Form): + domain = forms.ChoiceField( + choices=_get_domain_choices, + label=_('Domain'), + help_text=_( + 'Mails are received for all domains configured in the system. ' + 'Among these, select the most important one.'), + required=True, + ) diff --git a/plinth/modules/sogo/manifest.py b/plinth/modules/sogo/manifest.py new file mode 100644 index 000000000..ed0cb11d1 --- /dev/null +++ b/plinth/modules/sogo/manifest.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.utils.translation import gettext_lazy as _ + +from plinth.clients import store_url + +from . import privileged + +clients = [ + { + 'name': _('SOGo'), + 'platforms': [{ + 'type': 'web', + 'url': '/SOGo/' + }] + }, + { + 'name': + _('Thunderbird + SOGo connector'), + 'platforms': [{ + 'type': 'download', + 'os': 'gnu-linux', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }, { + 'type': 'download', + 'os': 'macos', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }, { + 'type': 'download', + 'os': 'windows', + 'url': 'https://www.sogo.nu/download.html#/frontends' + }] + }, + { + 'name': + _('DAVx5'), + 'platforms': [{ + 'type': 'store', + 'os': 'android', + 'store_name': 'f-droid', + 'url': store_url('f-droid', 'at.bitfire.davdroid'), + }, { + 'type': 'store', + 'os': 'android', + 'store_name': 'google-play', + 'url': store_url('google-play', 'at.bitfire.davdroid'), + }] + }, + { + 'name': + _('GNOME Calendar'), + 'platforms': [{ + 'type': 'package', + 'format': 'deb', + 'name': 'gnome-calendar' + }] + }, +] + +backup = { + 'data': { + 'files': [str(privileged.DB_BACKUP_FILE)], + }, + 'services': ['sogo'], + 'secrets': { + 'directories': [str(privileged.CONFIG_FILE)] + }, +} + +tags = [ + _('Webmail'), + _('Groupware'), + _('Calender'), + _('Address book'), + _('CalDAV'), + _('CardDAV') +] diff --git a/plinth/modules/sogo/privileged.py b/plinth/modules/sogo/privileged.py new file mode 100644 index 000000000..0de0f8076 --- /dev/null +++ b/plinth/modules/sogo/privileged.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure SOGo.""" + +import pathlib +import re +import shutil +import subprocess +import tempfile + +from plinth import utils +from plinth.actions import privileged +from plinth.db import postgres +from plinth.modules.email.privileged.domain import \ + get_domains as get_email_domains + +DB_HOST = 'localhost' +DB_NAME = 'sogo_fbx' +DB_USER = 'sogo_fbx' +SERVICE_NAME = 'sogo' + +DB_BACKUP_FILE = pathlib.Path('/var/lib/plinth/backups-data/sogo-database.sql') +CONFIG_FILE = pathlib.Path('/etc/sogo/sogo.conf') + + +@privileged +def setup() -> None: + """Setup SOGo database and configuration.""" + database_password = utils.generate_password(16) + postgres.create_database(DB_NAME, DB_USER, database_password) + _create_config(database_password) + + +def _create_config(db_password: str): + """Configure /etc/sogo/sogo.conf""" + try: + domain = _get_config_value('SOGoMailDomain') + if not domain: + # Try to get the domain configured for the email app + domain = get_email_domains()['primary_domain'] + except FileNotFoundError: + domain = 'localhost' + + connection = f'postgresql://{DB_USER}:{db_password}@{DB_HOST}/{DB_NAME}' + + content = f''' +{{ + /* General */ + SOGoMailDomain = "{domain}"; + SOGoLanguage = "English"; + SOGoTimeZone = "UTC"; + SOGoCalendarDefaultRoles = ("PublicViewer", "ConfidentialDAndTViewer"); + SOGoAppointmentSendEMailNotifications = YES; + SOGoRefreshViewCheck = "every_minute"; + + /* Authentication */ + SOGoMaximumFailedLoginCount = "10"; + SOGoMaximumFailedLoginInterval = "300"; + SOGoFailedLoginBlockInterval = "300"; + + /* Database */ + SOGoProfileURL = "{connection}/sogo_user_profile"; + OCSFolderInfoURL = "{connection}/sogo_folder_info"; + OCSSessionsFolderURL = "{connection}/sogo_sessions_folder"; + OCSEMailAlarmsFolderURL = "{connection}/sogo_alarms_folder"; + OCSStoreURL = "{connection}/sogo_store"; + OCSAclURL = "{connection}/sogo_acl"; + OCSCacheFolderURL = "{connection}/sogo_cache_folder"; + OCSAdminURL = "{connection}/sogo_admin"; + + /* Cache */ + SOGoMemcachedHost = "127.0.0.1"; + + /* SMTP */ + SOGoMailingMechanism = "smtp"; + SOGoSMTPServer = "smtp://127.0.0.1:587/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + SOGoSMTPAuthenticationType = "PLAIN"; + + /* IMAP */ + SOGoDraftsFolderName = "Drafts"; + SOGoSentFolderName = "Sent"; + SOGoTrashFolderName = "Trash"; + SOGoJunkFolderName = "Junk"; + SOGoIMAPServer = "imap://127.0.0.1:143/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + SOGoSieveServer = "sieve://127.0.0.14190/?tls=YES&tlsVerifyMode=allowInsecureLocalhost"; + + /* LDAP */ + SOGoUserSources = ({{ + type = "ldap"; + CNFieldName = "cn"; + IDFieldName = "uid"; + UIDFieldName = "uid"; + baseDN = "ou=users,dc=thisbox"; + canAuthenticate = YES; + displayName = "Shared Addresses"; + hostname = "ldap://127.0.0.1:389"; + id = "directory"; + isAddressBook = YES; + }}); +}}''' # noqa: E501 + CONFIG_FILE.touch(0o640, exist_ok=True) # In case the file does not exist + CONFIG_FILE.chmod(0o640) # In case the file pre-existed + shutil.chown(CONFIG_FILE, 'root', 'sogo') + CONFIG_FILE.write_text(content, encoding='utf-8') + + +@privileged +def dump_database() -> None: + """Dump database to file.""" + postgres.dump_database(DB_BACKUP_FILE, DB_NAME) + + +@privileged +def restore_database() -> None: + """Restore database from file.""" + password = _read_db_password() + postgres.restore_database(DB_BACKUP_FILE, DB_NAME, DB_USER, password) + + +def _read_db_password() -> str: + """Extract the database password from /etc/sogo/sogo.conf using regex""" + pattern = r'postgresql://[^:]+:([^@]+)@localhost' + match = re.search(pattern, _get_config_value('SOGoProfileURL')) + if not match: + raise ValueError('Could not extract password') + + return match.group(1) + + +@privileged +def get_domain() -> str: + """Get the value of SOGoMailDomain from /etc/sogo/sogo.conf""" + return _get_config_value('SOGoMailDomain') + + +@privileged +def set_domain(domain: str): + """Set the value of SOGoMailDomain in /etc/sogo/sogo.conf""" + _set_config_value('SOGoMailDomain', domain) + + +def _get_config_value(key: str) -> str: + """Return the value of a property from the configuration file.""" + process = subprocess.run(['plget', key], input=CONFIG_FILE.read_bytes(), + stdout=subprocess.PIPE, check=True) + return process.stdout.decode().strip() + + +def _set_config_value(key: str, value: str): + """Set the value of a property in the configuration file.""" + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(f'{{\n{key} = "{value}";\n}}'.encode('utf-8')) + temp_file.close() + subprocess.run(['plmerge', CONFIG_FILE, temp_file.name], check=True) + pathlib.Path(temp_file.name).unlink() + + +@privileged +def uninstall() -> None: + """Uninstall SOGo: drop database and configuration files.""" + postgres.drop_database(DB_NAME, DB_USER) + CONFIG_FILE.unlink(missing_ok=True) diff --git a/plinth/modules/sogo/static/icons/sogo.png b/plinth/modules/sogo/static/icons/sogo.png new file mode 100644 index 000000000..cbe36365f Binary files /dev/null and b/plinth/modules/sogo/static/icons/sogo.png differ diff --git a/plinth/modules/sogo/static/icons/sogo.svg b/plinth/modules/sogo/static/icons/sogo.svg new file mode 100644 index 000000000..3e896db3c --- /dev/null +++ b/plinth/modules/sogo/static/icons/sogo.svg @@ -0,0 +1,46 @@ + + + + + + + + diff --git a/plinth/modules/sogo/tests/__init__.py b/plinth/modules/sogo/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/sogo/tests/test_functional.py b/plinth/modules/sogo/tests/test_functional.py new file mode 100644 index 000000000..03fdd3436 --- /dev/null +++ b/plinth/modules/sogo/tests/test_functional.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Functional, browser based tests for SOGo app.""" + +import time + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.sogo] + + +class TestSOGoApp(functional.BaseAppTests): + """Basic tests for the SOGo app.""" + app_name = 'sogo' + has_service = True + disable_after_tests = False + + def test_login(self, session_browser): + """Test that login to SOGo interface works.""" + _login(session_browser) + assert functional.eventually(_is_logged_in, [session_browser]) + + +def _login(browser) -> None: + """Login to SOGo web interface.""" + functional.visit(browser, '/SOGo/') + username = functional.config['DEFAULT']['username'] + password = functional.config['DEFAULT']['password'] + functional.eventually(browser.find_by_id, ['input_1']) + time.sleep(1) # For some reason, waiting does not work + browser.find_by_id('input_1').fill(username) + browser.find_by_id('passwordField').fill(password) + submit = browser.find_by_css( + 'form[name=loginForm] button[type=submit]').first + functional.submit(browser, element=submit) + + +def _is_logged_in(browser) -> bool: + """Return whether SOGo login was successful.""" + logout = browser.find_by_css('a[href="../logoff"]') + return bool(logout) diff --git a/plinth/modules/sogo/urls.py b/plinth/modules/sogo/urls.py new file mode 100644 index 000000000..a5152fe7f --- /dev/null +++ b/plinth/modules/sogo/urls.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the SOGo module.""" + +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path(r'^apps/sogo/$', views.SOGoAppView.as_view(), name='index') +] diff --git a/plinth/modules/sogo/views.py b/plinth/modules/sogo/views.py new file mode 100644 index 000000000..34feb45fa --- /dev/null +++ b/plinth/modules/sogo/views.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Views for the SOGo app.""" + +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ + +from plinth.privileged import service as service_privileged +from plinth.views import AppView + +from . import forms, privileged + + +class SOGoAppView(AppView): + """Server configuration page.""" + app_id = 'sogo' + form_class = forms.DomainForm + + def get_initial(self): + """Return the initial values to populate in the form.""" + initial = super().get_initial() + initial['domain'] = privileged.get_domain() + return initial + + def form_valid(self, form): + """Update the settings for changed domain values.""" + old_data = form.initial + new_data = form.cleaned_data + if old_data['domain'] != new_data['domain']: + privileged.set_domain(new_data['domain']) + service_privileged.try_restart('sogo') + service_privileged.try_restart('memcached') + messages.success(self.request, _('Configuration updated')) + + return super().form_valid(form) diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index 578d7593a..dc7cec711 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -53,6 +53,7 @@ _site_url = { 'syncthing': '/syncthing/', 'rssbridge': '/rss-bridge/', 'ttrss': '/tt-rss/', + 'sogo': '/SOGo/', } _sys_modules = [