#!/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 ruamel.yaml import YAML, scalarstring from plinth import action_utils 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()