FreedomBox/actions/ejabberd
James Valleroy 9b446d5dd1
coturn: Validate TURN URIs if provided in form
Signed-off-by: James Valleroy <jvalleroy@mailbox.org>

- Re-use the same validator in Matrix Synapse.
- Avoid importing plinth classes in actions files.
Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2021-04-16 17:52:57 -04:00

407 lines
14 KiB
Python
Executable File

#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for the ejabberd service
"""
import argparse
import json
import os
import re
import shutil
import socket
import subprocess
import sys
from distutils.version import LooseVersion as LV
from pathlib import Path
from plinth import action_utils
from ruamel.yaml import YAML, scalarstring
EJABBERD_CONFIG = '/etc/ejabberd/ejabberd.yml'
EJABBERD_BACKUP = '/var/log/ejabberd/ejabberd.dump'
EJABBERD_BACKUP_NEW = '/var/log/ejabberd/ejabberd_new.dump'
EJABBERD_ORIG_CERT = '/etc/ejabberd/ejabberd.pem'
EJABBERD_MANAGED_COTURN = '/etc/ejabberd/freedombox_managed_coturn'
IQDISC_DEPRECATED_VERSION = LV('18.03')
MOD_IRC_DEPRECATED_VERSION = LV('18.06')
yaml = YAML()
yaml.allow_duplicate_keys = True
yaml.preserve_quotes = True
TURN_URI_REGEX = r'(stun|turn):(.*):([0-9]{4})\?transport=(tcp|udp)'
def parse_arguments():
"""Return parsed command line arguments as dictionary"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
# Get configuration
subparsers.add_parser('get-configuration',
help='Return the current configuration')
# Preseed debconf values before packages are installed.
pre_install = subparsers.add_parser(
'pre-install',
help='Preseed debconf values before packages are installed.')
pre_install.add_argument(
'--domainname',
help='The domain name that will be used by the XMPP service.')
# Setup ejabberd configuration
setup = subparsers.add_parser('setup', help='Setup ejabberd configuration')
setup.add_argument(
'--domainname',
help='The domain name that will be used by the XMPP service.')
# Prepare ejabberd for hostname change
pre_hostname_change = subparsers.add_parser(
'pre-change-hostname', help='Prepare ejabberd for nodename change')
pre_hostname_change.add_argument('--old-hostname',
help='Previous hostname')
pre_hostname_change.add_argument('--new-hostname', help='New hostname')
# Update ejabberd nodename
hostname_change = subparsers.add_parser('change-hostname',
help='Update ejabberd nodename')
hostname_change.add_argument('--old-hostname', help='Previous hostname')
hostname_change.add_argument('--new-hostname', help='New hostname')
# Add a domain name to ejabberd
add_domain = subparsers.add_parser('add-domain',
help='Add a domain name to ejabberd')
add_domain.add_argument('--domainname', help='New domain name')
# Configure STUN/TURN server for use with ejabberd
turn = subparsers.add_parser(
'configure-turn', help='Configure a TURN server for use with ejabberd')
turn.add_argument(
'--managed', required=False, default=False, action='store_true',
help='Whether configuration is provided by user or auto-managed by '
'FreedomBox')
subparsers.add_parser(
'get-turn-config',
help='Get the latest STUN/TURN configuration in JSON format')
# Switch/check Message Archive Management (MAM) in ejabberd config
help_MAM = 'Switch or check Message Archive Management (MAM).'
mam = subparsers.add_parser('mam', help=help_MAM)
mam.add_argument('command', choices=('enable', 'disable', 'status'),
help=help_MAM)
subparsers.required = True
return parser.parse_args()
def subcommand_get_configuration(_):
"""Return the current configuration, specifically domains configured."""
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
print(json.dumps({'domains': conf['hosts']}))
def subcommand_pre_install(arguments):
"""Preseed debconf values before packages are installed."""
domainname = arguments.domainname
if not domainname:
# If new domainname is blank, use hostname instead.
domainname = socket.gethostname()
action_utils.debconf_set_selections(
['ejabberd ejabberd/hostname string ' + domainname])
def subcommand_setup(arguments):
"""Enabled LDAP authentication"""
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
for listen_port in conf['listen']:
if 'tls' in listen_port:
listen_port['tls'] = False
if 'use_turn' in listen_port:
conf['listen'].remove(listen_port) # Use coturn instead
conf['auth_method'] = 'ldap'
conf['ldap_servers'] = [scalarstring.DoubleQuotedScalarString('localhost')]
conf['ldap_base'] = scalarstring.DoubleQuotedScalarString(
'ou=users,dc=thisbox')
with open(EJABBERD_CONFIG, 'w') as file_handle:
yaml.dump(conf, file_handle)
upgrade_config(arguments.domainname)
try:
subprocess.check_output(['ejabberdctl', 'restart'])
except subprocess.CalledProcessError as err:
print('Failed to restart ejabberd with new configuration: %s', err)
def upgrade_config(domain):
"""Fix the config file by removing deprecated settings"""
current_version = _get_version()
if not current_version:
print('Warning: Unable to get ejabberd version.')
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
# Check if `iqdisc` is present and remove it
if 'mod_mam' in conf['modules'] and \
(not current_version or current_version > IQDISC_DEPRECATED_VERSION):
conf['modules']['mod_mam'].pop('iqdisc', None)
# check if mod_irc is present in modules and remove it
if 'mod_irc' in conf['modules'] and \
(not current_version or current_version > MOD_IRC_DEPRECATED_VERSION):
conf['modules'].pop('mod_irc')
# Debian has a patch to configuration to change port 5443 to 5280 in
# ejabberd package version 18.12. However, 5443 is the correct port to host
# BOSH. So, change it back. In 19.x, this behavior has changed to use both
# ports 5443 (for BOSH) and 5280 (for web administration).
bosh_port = any((True for listen_port in conf['listen']
if listen_port['port'] == 5443))
if not bosh_port:
for listen_port in conf['listen']:
if listen_port['port'] == 5280:
listen_port['port'] = 5443
cert_dir = Path('/etc/ejabberd/letsencrypt') / domain
cert_file = str(cert_dir / 'ejabberd.pem')
cert_file = scalarstring.DoubleQuotedScalarString(cert_file)
conf['s2s_certfile'] = cert_file
for listen_port in conf['listen']:
if 'certfile' in listen_port:
listen_port['certfile'] = cert_file
# Write changes back to the file
with open(EJABBERD_CONFIG, 'w') as file_handle:
yaml.dump(conf, file_handle)
def subcommand_pre_change_hostname(arguments):
"""Prepare ejabberd for hostname change"""
if not shutil.which('ejabberdctl'):
print('ejabberdctl not found. Is ejabberd installed?')
return
old_hostname = arguments.old_hostname
new_hostname = arguments.new_hostname
subprocess.call(['ejabberdctl', 'backup', EJABBERD_BACKUP])
try:
subprocess.check_output([
'ejabberdctl', 'mnesia-change-nodename',
'ejabberd@' + old_hostname, 'ejabberd@' + new_hostname,
EJABBERD_BACKUP, EJABBERD_BACKUP_NEW
])
os.remove(EJABBERD_BACKUP)
except subprocess.CalledProcessError as err:
print('Failed to change hostname in ejabberd backup database: %s', err)
def subcommand_change_hostname(arguments):
"""Update ejabberd with new hostname"""
if not shutil.which('ejabberdctl'):
print('ejabberdctl not found. Is ejabberd installed?')
return
action_utils.service_stop('ejabberd')
subprocess.call(['pkill', '-u', 'ejabberd'])
# Make sure there aren't files in the Mnesia spool dir
os.makedirs('/var/lib/ejabberd/oldfiles', exist_ok=True)
subprocess.call('mv /var/lib/ejabberd/*.* /var/lib/ejabberd/oldfiles/',
shell=True)
action_utils.service_start('ejabberd')
# restore backup database
if os.path.exists(EJABBERD_BACKUP_NEW):
try:
subprocess.check_output(
['ejabberdctl', 'restore', EJABBERD_BACKUP_NEW])
os.remove(EJABBERD_BACKUP_NEW)
except subprocess.CalledProcessError as err:
print('Failed to restore ejabberd backup database: %s', err)
else:
print('Could not load ejabberd backup database: %s not found' %
EJABBERD_BACKUP_NEW)
def subcommand_add_domain(arguments):
"""Update ejabberd with new domainname"""
if not shutil.which('ejabberdctl'):
print('ejabberdctl not found. Is ejabberd installed?')
return
domainname = arguments.domainname
# Add updated domainname to ejabberd hosts list.
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
conf['hosts'].append(scalarstring.DoubleQuotedScalarString(domainname))
conf['hosts'] = list(set(conf['hosts']))
with open(EJABBERD_CONFIG, 'w') as file_handle:
yaml.dump(conf, file_handle)
def subcommand_mam(argument):
"""Enable, disable, or get status of Message Archive Management (MAM)."""
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
if 'modules' not in conf:
print('Found no "modules" entry in ejabberd configuration file.')
return
if argument.command == 'status':
if 'mod_mam' in conf['modules']:
print('enabled')
return
else:
print('disabled')
return
if argument.command == 'enable':
# Explicitly set the recommended / default settings for mod_mam,
# see https://docs.ejabberd.im/admin/configuration/#mod-mam.
settings_mod_mam = {
'mod_mam': {
'db_type':
'mnesia', # default is 'mnesia' (w/o set default_db)
'default': 'never', # policy, default 'never'
'request_activates_archiving': False, # default False
'assume_mam_usage': False, # for non-ack'd msgs, default False
'cache_size': 1000, # default is 1000 items
'cache_life_time': 3600 # default is 3600 seconds = 1h
}
}
conf['modules'].update(settings_mod_mam)
elif argument.command == 'disable':
# disable modules by erasing from config file
if 'mod_mam' in conf['modules']:
conf['modules'].pop('mod_mam')
else:
print("Unknown command: %s" % argument.command)
return
with open(EJABBERD_CONFIG, 'w') as file_handle:
yaml.dump(conf, file_handle)
if action_utils.service_is_running('ejabberd'):
subprocess.call(['ejabberdctl', 'reload_config'])
def _generate_service(uri: str) -> dict:
"""Generate ejabberd mod_stun_disco service config from Coturn URI."""
pattern = re.compile(TURN_URI_REGEX)
typ, domain, port, transport = pattern.match(uri).groups()
return {
"host": domain,
"port": int(port),
"type": typ,
"transport": transport,
"restricted": False
}
def _generate_uris(services: list[dict]) -> list[str]:
"""Generate STUN/TURN URIs from ejabberd mod_stun_disco service config."""
return [
f"{s['type']}:{s['host']}:{s['port']}?transport={s['transport']}"
for s in services
]
def subcommand_get_turn_config(_):
"""Get the latest STUN/TURN configuration in JSON format."""
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
mod_stun_disco_config = conf['modules']['mod_stun_disco']
managed = os.path.exists(EJABBERD_MANAGED_COTURN)
if bool(mod_stun_disco_config):
print(
json.dumps([{
'domain': '',
'uris': _generate_uris(mod_stun_disco_config['services']),
'shared_secret': mod_stun_disco_config['secret'],
}, managed]))
else:
print(
json.dumps([{
'domain': None,
'uris': [],
'shared_secret': None
}, managed]))
def subcommand_configure_turn(arguments):
"""Set parameters for the STUN/TURN server to use with ejabberd."""
turn_server_config = json.loads(''.join(sys.stdin))
uris = turn_server_config['uris']
mod_stun_disco_config = {}
if turn_server_config['uris'] and turn_server_config['shared_secret']:
mod_stun_disco_config = {
'credentials_lifetime': '1000d',
'secret': turn_server_config['shared_secret'],
'services': [_generate_service(uri) for uri in uris]
}
with open(EJABBERD_CONFIG, 'r') as file_handle:
conf = yaml.load(file_handle)
conf['modules']['mod_stun_disco'] = mod_stun_disco_config
with open(EJABBERD_CONFIG, 'w') as file_handle:
yaml.dump(conf, file_handle)
if arguments.managed:
Path(EJABBERD_MANAGED_COTURN).touch()
else:
Path(EJABBERD_MANAGED_COTURN).unlink(missing_ok=True)
if action_utils.service_is_running('ejabberd'):
subprocess.call(['ejabberdctl', 'reload_config'])
def _get_version():
""" Get the current ejabberd version """
try:
output = subprocess.check_output(['ejabberdctl',
'status']).decode('utf-8')
except subprocess.CalledProcessError:
return None
version_info = output.strip().split('\n')[-1].split()
if version_info:
version = str(version_info[1])
return LV(version)
return None
def main():
"""Parse arguments and perform all duties"""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()