#!/usr/bin/python3 # # This file is part of FreedomBox. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # """ Configuration helper for the ejabberd service """ import argparse import os import shutil import socket import stat import subprocess import sys import ruamel.yaml from distutils.version import LooseVersion as LV from plinth import action_utils from plinth.modules import config from plinth.modules.letsencrypt import LIVE_DIRECTORY as LE_LIVE_DIRECTORY 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' IQDISC_DEPRECATED_VERSION = LV('18.03') MOD_IRC_DEPRECATED_VERSION = LV('18.06') def parse_arguments(): """Return parsed command line arguments as dictionary""" parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') # 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 subparsers.add_parser('setup', help='Setup ejabberd configuration') subparsers.add_parser('enable', help='Enable XMPP service') subparsers.add_parser('disable', help='Disable 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') # Update ejabberd with new domainname domainname_change = subparsers.add_parser( 'change-domainname', help='Update ejabberd with new domainname') domainname_change.add_argument('--domainname', help='New domainname') # 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) help_LE = "Add/drop Let's Encrypt certificate if configured domain matches" letsencrypt = subparsers.add_parser('letsencrypt', help=help_LE) letsencrypt.add_argument('command', choices=('add', 'drop'), help=help_LE) letsencrypt.add_argument('--domain', help='Domain name to drop.') subparsers.required = True return parser.parse_args() 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() subprocess.check_output( ['debconf-set-selections'], input=b'ejabberd ejabberd/hostname string ' + domainname.encode()) def subcommand_setup(_): """Enabled LDAP authentication""" with open(EJABBERD_CONFIG, 'r') as file_handle: conf = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) for listen_port in conf['listen']: if 'tls' in listen_port: listen_port['tls'] = False conf['auth_method'] = 'ldap' conf['ldap_servers'] = [ ruamel.yaml.scalarstring.DoubleQuotedScalarString('localhost') ] conf['ldap_base'] = ruamel.yaml.scalarstring.DoubleQuotedScalarString( 'ou=users,dc=thisbox') with open(EJABBERD_CONFIG, 'w') as file_handle: ruamel.yaml.round_trip_dump(conf, file_handle) upgrade_config() try: subprocess.check_output(['ejabberdctl', 'restart']) except subprocess.CalledProcessError as err: print('Failed to restart ejabberd with new configuration: %s', err) with action_utils.WebserverChange() as webserver_change: webserver_change.enable('jwchat-plinth') def upgrade_config(): """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 = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) # 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') # BOSH port was changed from 5280 to 5443. for listen_port in conf['listen']: if listen_port['port'] == 5280: listen_port['port'] = 5443 # Write changes back to the file with open(EJABBERD_CONFIG, 'w') as file_handle: ruamel.yaml.round_trip_dump(conf, file_handle) def subcommand_enable(_): """Enable XMPP service""" action_utils.service_enable('ejabberd') action_utils.webserver_enable('jwchat-plinth') def subcommand_disable(_): """Disable XMPP service""" action_utils.webserver_disable('jwchat-plinth') action_utils.service_disable('ejabberd') 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_change_domainname(arguments): """Update ejabberd with new domainname""" if not shutil.which('ejabberdctl'): print('ejabberdctl not found. Is ejabberd installed?') return domainname = arguments.domainname if not domainname: # If new domainname is blank, use hostname instead. domainname = socket.gethostname() action_utils.service_stop('ejabberd') subprocess.call(['pkill', '-u', 'ejabberd']) # Add updated domainname to ejabberd hosts list. with open(EJABBERD_CONFIG, 'r') as file_handle: conf = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) conf['hosts'].append( ruamel.yaml.scalarstring.DoubleQuotedScalarString(domainname)) with open(EJABBERD_CONFIG, 'w') as file_handle: ruamel.yaml.round_trip_dump(conf, file_handle) action_utils.service_start('ejabberd') def subcommand_mam(argument): """Enable, disable, or get status of Message Archive Management (MAM).""" with open(EJABBERD_CONFIG, 'r') as file_handle: conf = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) 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: ruamel.yaml.round_trip_dump(conf, file_handle) if action_utils.service_is_running('ejabberd'): subprocess.call(['ejabberdctl', 'reload_config']) def subcommand_letsencrypt(arguments): """ Add/drop usage of Let's Encrypt cert. The command 'add' applies only to current domain, will be called by action 'letsencrypt run_renew_hooks', when certbot renews the cert (if ejabberd is selected for cert use). Drop of a cert must be possible for any domain to respond to domain change. """ current_domain = config.get_domainname() with open(EJABBERD_CONFIG, 'r') as file_handle: conf = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) if arguments.domain is not None and arguments.domain not in conf['hosts']: print('Aborted: Current domain "%s" not configured for ejabberd.' % arguments.domain) sys.exit(1) if arguments.command == 'add' and arguments.domain is not None \ and arguments.domain != current_domain: print('Aborted: Only certificate of current domain "%s" can be added.' % current_domain) sys.exit(2) if arguments.domain is None: arguments.domain = current_domain cert_folder = '/etc/ejabberd/letsencrypt/' + arguments.domain cert_file = cert_folder + '/ejabberd.pem' if arguments.command == 'add': le_folder = os.path.join(LE_LIVE_DIRECTORY, current_domain) le_privkey = os.path.join(le_folder, 'privkey.pem') le_fullchain = os.path.join(le_folder, 'fullchain.pem') if not os.path.exists(le_folder): print('Aborted: No certificate directory at %s.' % le_folder) sys.exit(3) if not os.path.exists(cert_folder): os.makedirs(cert_folder) shutil.chown(cert_folder, 'ejabberd', 'ejabberd') with open(cert_file, 'w') as outfile: with open(le_privkey, 'r') as infile: for line in infile: if line.strip(): outfile.write(line) with open(le_fullchain, 'r') as infile: for line in infile: if line.strip(): outfile.write(line) shutil.chown(cert_file, 'ejabberd', 'ejabberd') os.chmod(cert_file, stat.S_IRUSR | stat.S_IWUSR) cert_file = ruamel.yaml.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 else: # arguments.command == 'drop' (ensured by parser) orig_cert_file = ruamel.yaml.scalarstring.DoubleQuotedScalarString( EJABBERD_ORIG_CERT) for listen_port in conf['listen']: if 'certfile' in listen_port \ and listen_port['certfile'] == cert_file: listen_port['certfile'] = orig_cert_file if conf['s2s_certfile'] == cert_file: conf['s2s_certfile'] = orig_cert_file if os.path.exists(cert_folder): shutil.rmtree(cert_folder) with open(EJABBERD_CONFIG, 'w') as file_handle: ruamel.yaml.round_trip_dump(conf, file_handle) if action_utils.service_is_running('ejabberd'): action_utils.service_restart('ejabberd') 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()