sogo: Add a new app for SOGo groupware

SOGo is an open source webmail client and groupware available in Debian.
Make a new FreedomBox app for it to be used with the local
Postfix/Dovecot email server.

SOGo requires a database to store events/tasks as well as user settings. Memcached
is also required for caching.

Users log in with their username (as opposed to username + domain on
Roundcube). The host header seen from the first login will be
associated with the user profile. So, if a user logs into SOGo from
freedombox.local and later configures the email server to use
example.com, they will manually have to edit their account(s) to show
the updated domain.

Authentication is done via openldap. It is possible to authenticate with
apache, however it is limited to http basic auth, so mod_auth_tkt cannot
be used. See: b40d777a86/SoObjects/SOGo/SOGoProxyAuthenticator.m (L137)
Configuring http basic auth in my opinion wouldn't add much to
the user experience. It would actually take away the usage of SOGO's built
in TOTP feature.

SOGo only accepts configurations from /etc/sogo/sogo.conf, other configs
from sogo.d don't get recognised.

Use the sogo icon from upstream source. Update sogo.png and sogo.svg to be the
same image (but resized) that is provided in the upstream source. The previous
image was download from Wikimedia Commons.

Update smtp settings so that messages can be sent

Test result for mail deliverability sent with SOGo: https://www.mail-tester.com/test-pdf2yzy6n

The result shows that the message is not DKIM signed. This seems to be
an issue not specific to SOGo. Mails sent from Thunderbird don't get
signed either.

Tests:
- Install app and log in with a FreedomBox user. Create a new event
  titled "Lunch with 🍕 and fries". Confirm the pizza character displays properly.
- Backup the app and uninstall it.
- Restore from the backup, log in and confirm the event gets restored.

To-do:
- test ActiveSync
- create a fail2ban jail
- include the icons in the copyright file
- test sending email in a production setup
- test sieve filters
- write tests

https://salsa.debian.org/freedombox-team/freedombox/-/issues/56

[Sunil]

- App:

  - Update icons to be uniform size as all other apps and copyright information.

  - Since SOGo is not configured to trust the authentication from Apache, it
  does not require FirewallLocalProtection. Remove it.

  - Expand app description. Talk about Email Server app.

  - Update to match recent tags related changes.

  - Make memcached a shared daemon as other processes might use it.

  - Added shared daemon for PostgreSQL.

  - Don't start services when rerunning setup if the app is currently disabled.

  - Don't restart memcached during a restore operation.

- Security:

  - Add system security restrictions to the daemon.

  - Don't use fail2ban jail. SOGo has a mechanism to lock users for a few
  minutes. Use that instead.

- Apache:

  - Make /.well-known URLs work by moving their definitions to global section.

  - Remove old (<2.4) Apache authorization keywords.

  - Simplify, indentation, new line at EOF.

- Manifest:

  - Add more tags.

  - Add SOGo connector, DAVx5, and GNOME Calendar to list of clients.

  - Add 'sogo' to list of service to bring down during backup/restore.

- Privileged:

  - Switch from MySQL to PostgreSQL as it is recommended by SOGo.

  - Use existing utility to generate database password.

  - Use plget and plmerge utilities from gnustep-common package to parse/edit
  the configuration instead of augeas which don't have a dedicated lens.

  - Don't reset the domain when rerunning setup.

  - Ensure that the configuration file has proper ownership and permissions even
  when it did not exist previously.

  - Add typing information for most methods.

  - Remove configuration file after uninstall.

- Configuration:

  - Define database URLs for all seven database tables.

  - Set calendar default roles as suggested in the installation guide.

  - Refresh view automatically every minute to check for new mail.

  - Use the mechanism to lock account after failed login attempts.

  - Add folder name for Junk folder too explicitly.

- Tests: Add basic functional tests.

Tests:

- Functional tests work.

- Rerunning setup does not change the domain back to the primary domain of the
email server.

- Login works. Sending mail and reading mail works. Creating calendar events and
contact works.

- Changing the domain sets the domain value properly in the configuration file.
Configured domain is shown properly on the form.

- Backup and restore work as expected.

- When configuration file is removed and setup is re-run, then the file is
created with proper ownership and permissions.

- 'systemd-analyze security sogo.service' shows a good score.

Signed-off-by: Benedek Nagy <contact@nbenedek.me>
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Benedek Nagy 2024-12-27 21:49:35 +01:00 committed by James Valleroy
parent 74e908ea82
commit 6887c960fe
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
15 changed files with 586 additions and 0 deletions

6
debian/copyright vendored
View File

@ -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 <jack.palevich@gmail.com>
2014 The Syncthing Authors

View File

@ -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 <a href="{email_url}">Postfix/Dovecot</a> '
'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()

View File

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

View File

@ -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
<Location /SOGo>
ProxyPass http://127.0.0.1:20000/SOGo retry=0 nocanon
ProxyPreserveHost On
SetEnv proxy-nokeepalive 1
<IfModule headers_module>
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
</IfModule>
AddDefaultCharset UTF-8
</Location>
<Directory /usr/lib/GNUstep/SOGo/>
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.
<IfModule expires_module>
ExpiresActive On
ExpiresDefault "access plus 1 year"
</IfModule>
</Directory>

View File

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

View File

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

View File

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

View File

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

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: 6.3 KiB

View File

View File

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

View File

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

View File

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

View File

@ -53,6 +53,7 @@ _site_url = {
'syncthing': '/syncthing/',
'rssbridge': '/rss-bridge/',
'ttrss': '/tt-rss/',
'sogo': '/SOGo/',
}
_sys_modules = [