commit 8394b3d4285afa5686d0936902626947e6f13561 Author: Kload Date: Fri May 16 14:49:30 2014 +0200 Init diff --git a/__init__.py b/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/app.py b/app.py new file mode 100644 index 000000000..90f3a3ff8 --- /dev/null +++ b/app.py @@ -0,0 +1,1181 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_app.py + + Manage apps +""" +import os +import sys +import json +import shutil +import stat +import yaml +import time +import re +import socket +import urlparse +import errno + +from moulinette.core import MoulinetteError + +repo_path = '/var/cache/yunohost/repo' +apps_path = '/usr/share/yunohost/apps' +apps_setting_path= '/etc/yunohost/apps/' +install_tmp = '/var/cache/yunohost' +app_tmp_folder = install_tmp + '/from_file' + +def app_listlists(): + """ + List fetched lists + + + """ + list_list = [] + try: + for filename in os.listdir(repo_path): + if '.json' in filename: + list_list.append(filename[:len(filename)-5]) + except OSError: + raise MoulinetteError(1, m18n.n('no_list_found')) + + return { 'lists' : list_list } + + +def app_fetchlist(url=None, name=None): + """ + Fetch application list from app server + + Keyword argument: + name -- Name of the list (default yunohost) + url -- URL of remote JSON list (default http://app.yunohost.org/list.json) + + """ + # Create app path if not exists + try: os.listdir(repo_path) + except OSError: os.makedirs(repo_path) + + if url is None: + url = 'http://app.yunohost.org/list.json' + name = 'yunohost' + else: + if name is None: + raise MoulinetteError(errno.EINVAL, + m18n.n('custom_list_name_required')) + + list_file = '%s/%s.json' % (repo_path, name) + if os.system('wget "%s" -O "%s.tmp"' % (url, list_file)) != 0: + os.remove('%s.tmp' % list_file) + raise MoulinetteError(errno.EBADR, m18n.n('list_retrieve_error')) + + # Rename fetched temp list + os.rename('%s.tmp' % list_file, list_file) + + os.system("touch /etc/cron.d/yunohost-applist-%s" % name) + os.system("echo '00 00 * * * root yunohost app fetchlist -u %s -n %s --no-ldap > /dev/null 2>&1' >/etc/cron.d/yunohost-applist-%s" % (url, name, name)) + + msignals.display(m18n.n('list_fetched'), 'success') + + +def app_removelist(name): + """ + Remove list from the repositories + + Keyword argument: + name -- Name of the list to remove + + """ + try: + os.remove('%s/%s.json' % (repo_path, name)) + os.remove("/etc/cron.d/yunohost-applist-%s" % name) + except OSError: + raise MoulinetteError(errno.ENOENT, m18n.n('unknown_list')) + + msignals.display(m18n.n('list_removed'), 'success') + + +def app_list(offset=None, limit=None, filter=None, raw=False): + """ + List apps + + Keyword argument: + filter -- Name filter of app_id or app_name + offset -- Starting number for app fetching + limit -- Maximum number of app fetched + raw -- Return the full app_dict + + """ + if offset: offset = int(offset) + else: offset = 0 + if limit: limit = int(limit) + else: limit = 1000 + + applists = os.listdir(repo_path) + app_dict = {} + if raw: + list_dict = {} + else: + list_dict=[] + + if not applists: + app_fetchlist() + applists = os.listdir(repo_path) + + for applist in applists: + if '.json' in applist: + with open(repo_path +'/'+ applist) as json_list: + app_dict.update(json.loads(str(json_list.read()))) + + for app in os.listdir(apps_setting_path): + if app not in app_dict: + # Look for forks + if '__' in app: + original_app = app[:app.index('__')] + if original_app in app_dict: + app_dict[app] = app_dict[original_app] + continue + with open( apps_setting_path + app +'/manifest.json') as json_manifest: + app_dict[app] = {"manifest":json.loads(str(json_manifest.read()))} + app_dict[app]['manifest']['orphan']=True + + if len(app_dict) > (0 + offset) and limit > 0: + sorted_app_dict = {} + for sorted_keys in sorted(app_dict.keys())[offset:]: + sorted_app_dict[sorted_keys] = app_dict[sorted_keys] + + i = 0 + for app_id, app_info in sorted_app_dict.items(): + if i < limit: + if (filter and ((filter in app_id) or (filter in app_info['manifest']['name']))) or not filter: + installed = _is_installed(app_id) + + if raw: + app_info['installed'] = installed + list_dict[app_id] = app_info + else: + list_dict.append({ + 'id': app_id, + 'name': app_info['manifest']['name'], + 'description': app_info['manifest']['description'], + 'installed': installed + }) + i += 1 + else: + break + if not raw: + list_dict = { 'apps': list_dict } + return list_dict + + +def app_info(app, raw=False): + """ + Get app info + + Keyword argument: + app -- Specific app ID + raw -- Return the full app_dict + + """ + try: + app_info = app_list(filter=app, raw=True)[app] + except: + app_info = {} + + if _is_installed(app): + with open(apps_setting_path + app +'/settings.yml') as f: + app_info['settings'] = yaml.load(f) + + if raw: + return app_info + else: + return { + 'name': app_info['manifest']['name'], + 'description': app_info['manifest']['description']['en'], + #TODO: Add more infos + } + + +def app_map(app=None, raw=False, user=None): + """ + List apps by domain + + Keyword argument: + user -- Allowed app map for a user + raw -- Return complete dict + app -- Specific app to map + + """ + + result = {} + + for app_id in os.listdir(apps_setting_path): + if app and (app != app_id): + continue + + if user is not None: + app_dict = app_info(app=app_id, raw=True) + if ('mode' not in app_dict['settings']) or ('mode' in app_dict['settings'] and app_dict['settings']['mode'] == 'private'): + if 'allowed_users' in app_dict['settings'] and user not in app_dict['settings']['allowed_users'].split(','): + continue + + with open(apps_setting_path + app_id +'/settings.yml') as f: + app_settings = yaml.load(f) + + if 'domain' not in app_settings: + continue + + if raw: + if app_settings['domain'] not in result: + result[app_settings['domain']] = {} + result[app_settings['domain']][app_settings['path']] = { + 'label': app_settings['label'], + 'id': app_settings['id'] + } + else: + result[app_settings['domain']+app_settings['path']] = app_settings['label'] + + return result + + +def app_upgrade(auth, app, url=None, file=None): + """ + Upgrade app + + Keyword argument: + file -- Folder or tarball for upgrade + app -- App(s) to upgrade (default all) + url -- Git url to fetch for upgrade + + """ + from yunohost.hook import hook_add, hook_exec + + try: + app_list() + except MoulinetteError: + raise MoulinetteError(errno.ENODATA, m18n.n('app_no_upgrade')) + + upgraded_apps = [] + + # If no app is specified, upgrade all apps + if not app: + app = os.listdir(apps_setting_path) + elif not isinstance(app, list): + app = [ app ] + + for app_id in app: + installed = _is_installed(app_id) + if not installed: + raise MoulinetteError(errno.ENOPKG, + m18n.n('app_not_installed') % app_id) + + if app_id in upgraded_apps: + continue + + if '__' in app_id: + original_app_id = app_id[:app_id.index('__')] + else: + original_app_id = app_id + + current_app_dict = app_info(app_id, raw=True) + new_app_dict = app_info(original_app_id, raw=True) + + if file: + manifest = _extract_app_from_file(file) + elif url: + manifest = _fetch_app_from_git(url) + elif 'lastUpdate' not in new_app_dict or 'git' not in new_app_dict: + raise MoulinetteError(errno.EDESTADDRREQ, + m18n.n('custom_app_url_required') % app_id) + elif (new_app_dict['lastUpdate'] > current_app_dict['lastUpdate']) \ + or ('update_time' not in current_app_dict['settings'] \ + and (new_app_dict['lastUpdate'] > current_app_dict['settings']['install_time'])) \ + or ('update_time' in current_app_dict['settings'] \ + and (new_app_dict['lastUpdate'] > current_app_dict['settings']['update_time'])): + manifest = _fetch_app_from_git(app_id) + else: + continue + + # Check min version + if 'min_version' in manifest and __version__ < manifest['min_version']: + raise MoulinetteError(errno.EPERM, + m18n.n('app_recent_version_required') % app_id) + + app_setting_path = apps_setting_path +'/'+ app_id + + if original_app_id != app_id: + # Replace original_app_id with the forked one in scripts + for file in os.listdir(app_tmp_folder +'/scripts'): + #TODO: do it with sed ? + if file[:1] != '.': + with open(app_tmp_folder +'/scripts/'+ file, "r") as sources: + lines = sources.readlines() + with open(app_tmp_folder +'/scripts/'+ file, "w") as sources: + for line in lines: + sources.write(re.sub(r''+ original_app_id +'', app_id, line)) + + if 'hooks' in os.listdir(app_tmp_folder): + for file in os.listdir(app_tmp_folder +'/hooks'): + #TODO: do it with sed ? + if file[:1] != '.': + with open(app_tmp_folder +'/hooks/'+ file, "r") as sources: + lines = sources.readlines() + with open(app_tmp_folder +'/hooks/'+ file, "w") as sources: + for line in lines: + sources.write(re.sub(r''+ original_app_id +'', app_id, line)) + + # Add hooks + if 'hooks' in os.listdir(app_tmp_folder): + for file in os.listdir(app_tmp_folder +'/hooks'): + hook_add(app_id, app_tmp_folder +'/hooks/'+ file) + + # Execute App upgrade script + os.system('chown -hR admin: %s' % install_tmp) + if hook_exec(app_tmp_folder +'/scripts/upgrade') != 0: + #TODO: display fail messages from script + pass + else: + app_setting(app_id, 'update_time', int(time.time())) + + # Replace scripts and manifest + os.system('rm -rf "%s/scripts" "%s/manifest.json"' % (app_setting_path, app_setting_path)) + os.system('mv "%s/manifest.json" "%s/scripts" %s' % (app_tmp_folder, app_tmp_folder, app_setting_path)) + + # So much win + upgraded_apps.append(app_id) + msignals.display(m18n.n('app_upgraded') % app_id, 'success') + + if not upgraded_apps: + raise MoulinetteError(errno.ENODATA, m18n.n('no_app_upgrade')) + + msignals.display(m18n.n('upgrade_complete'), 'success') + + +def app_install(auth, app, label=None, args=None): + """ + Install apps + + Keyword argument: + label + app -- App to install + args -- Serialize arguments of installation + + """ + from yunohost.hook import hook_add, hook_remove, hook_exec + + # Fetch or extract sources + try: os.listdir(install_tmp) + except OSError: os.makedirs(install_tmp) + + if app in app_list(raw=True) or ('@' in app) or ('http://' in app) or ('https://' in app): + manifest = _fetch_app_from_git(app) + else: + manifest = _extract_app_from_file(app) + + # Check ID + if 'id' not in manifest or '__' in manifest['id']: + raise MoulinetteError(errno.EINVAL, m18n.n('app_id_invalid')) + + app_id = manifest['id'] + + # Check min version + if 'min_version' in manifest and __version__ < manifest['min_version']: + raise MoulinetteError(errno.EPERM, + m18n.n('app_recent_version_required') % app_id) + + # Check if app can be forked + instance_number = _installed_instance_number(app_id, last=True) + 1 + if instance_number > 1 : + if 'multi_instance' not in manifest or not is_true(manifest['multi_instance']): + raise MoulinetteError(errno.EEXIST, + m18n.n('app_already_installed') % app_id) + + app_id_forked = app_id + '__' + str(instance_number) + + # Replace app_id with the new one in scripts + for file in os.listdir(app_tmp_folder +'/scripts'): + #TODO: do it with sed ? + if file[:1] != '.': + with open(app_tmp_folder +'/scripts/'+ file, "r") as sources: + lines = sources.readlines() + with open(app_tmp_folder +'/scripts/'+ file, "w") as sources: + for line in lines: + sources.write(re.sub(r''+ app_id +'', app_id_forked, line)) + + if 'hooks' in os.listdir(app_tmp_folder): + for file in os.listdir(app_tmp_folder +'/hooks'): + #TODO: do it with sed ? + if file[:1] != '.': + with open(app_tmp_folder +'/hooks/'+ file, "r") as sources: + lines = sources.readlines() + with open(app_tmp_folder +'/hooks/'+ file, "w") as sources: + for line in lines: + sources.write(re.sub(r''+ app_id +'', app_id_forked, line)) + + # Change app_id for the rest of the process + app_id = app_id_forked + + # Prepare App settings + app_setting_path = apps_setting_path +'/'+ app_id + + #TMP: Remove old settings + if os.path.exists(app_setting_path): shutil.rmtree(app_setting_path) + os.makedirs(app_setting_path) + os.system('touch %s/settings.yml' % app_setting_path) + + # Add hooks + if 'hooks' in os.listdir(app_tmp_folder): + for file in os.listdir(app_tmp_folder +'/hooks'): + hook_add(app_id, app_tmp_folder +'/hooks/'+ file) + + app_setting(app_id, 'id', app_id) + app_setting(app_id, 'install_time', int(time.time())) + + if label: + app_setting(app_id, 'label', label) + else: + app_setting(app_id, 'label', manifest['name']) + + os.system('chown -R admin: '+ app_tmp_folder) + + try: + if args is None: + args = '' + args_dict = dict(urlparse.parse_qsl(args)) + except: + args_dict = {} + + # Execute App install script + os.system('chown -hR admin: %s' % install_tmp) + # Move scripts and manifest to the right place + os.system('cp %s/manifest.json %s' % (app_tmp_folder, app_setting_path)) + os.system('cp -R %s/scripts %s' % (app_tmp_folder, app_setting_path)) + try: + if hook_exec(app_tmp_folder + '/scripts/install', args_dict) == 0: + shutil.rmtree(app_tmp_folder) + os.system('chmod -R 400 %s' % app_setting_path) + os.system('chown -R root: %s' % app_setting_path) + os.system('chown -R admin: %s/scripts' % app_setting_path) + app_ssowatconf(auth) + msignals.display(m18n.n('installation_complete'), 'success') + else: + #TODO: display script fail messages + hook_remove(app_id) + shutil.rmtree(app_setting_path) + shutil.rmtree(app_tmp_folder) + raise MoulinetteError(errno.EIO, m18n.n('installation_failed')) + except KeyboardInterrupt, EOFError: + hook_remove(app_id) + shutil.rmtree(app_setting_path) + shutil.rmtree(app_tmp_folder) + raise MoulinetteError(errno.EINTR, m18n.g('operation_interrupted')) + + +def app_remove(app): + """ + Remove app + + Keyword argument: + app -- App(s) to delete + + """ + from yunohost.hook import hook_exec, hook_remove + + if not _is_installed(app): + raise MoulinetteError(errno.EINVAL, m18n.n('app_not_installed') % app) + + app_setting_path = apps_setting_path + app + + #TODO: display fail messages from script + try: + shutil.rmtree('/tmp/yunohost_remove') + except: pass + + os.system('cp -a %s /tmp/yunohost_remove && chown -hR admin: /tmp/yunohost_remove' % app_setting_path) + os.system('chown -R admin: /tmp/yunohost_remove') + os.system('chmod -R u+rX /tmp/yunohost_remove') + + if hook_exec('/tmp/yunohost_remove/scripts/remove') != 0: + pass + + if os.path.exists(app_setting_path): shutil.rmtree(app_setting_path) + shutil.rmtree('/tmp/yunohost_remove') + hook_remove(app) + app_ssowatconf() + msignals.display(m18n.n('app_removed') % app, 'success') + + +def app_addaccess(auth, apps, users): + """ + Grant access right to users (everyone by default) + + Keyword argument: + users + apps + + """ + from yunohost.user import user_list, user_info + + if not users: + users = [] + for user in user_list(auth)['users']: + users.append(user['username']) + + if not isinstance(users, list): users = [users] + if not isinstance(apps, list): apps = [apps] + + for app in apps: + if not _is_installed(app): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_not_installed') % app) + + with open(apps_setting_path + app +'/settings.yml') as f: + app_settings = yaml.load(f) + + if 'mode' not in app_settings: + app_setting(app, 'mode', 'private') + app_settings['mode'] = 'private' + + if app_settings['mode'] == 'private': + if 'allowed_users' in app_settings: + new_users = app_settings['allowed_users'] + else: + new_users = '' + + for allowed_user in users: + if allowed_user not in new_users.split(','): + try: + user_info(auth, allowed_user) + except MoulinetteError: + continue + if new_users == '': + new_users = allowed_user + else: + new_users = new_users +','+ allowed_user + + app_setting(app, 'allowed_users', new_users.strip()) + + app_ssowatconf(auth) + + return { 'allowed_users': new_users.split(',') } + + +def app_removeaccess(auth, apps, users): + """ + Revoke access right to users (everyone by default) + + Keyword argument: + users + apps + + """ + from yunohost.user import user_list + + remove_all = False + if not users: + remove_all = True + if not isinstance(users, list): users = [users] + if not isinstance(apps, list): apps = [apps] + for app in apps: + new_users = '' + + if not _is_installed(app): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_not_installed') % app) + + with open(apps_setting_path + app +'/settings.yml') as f: + app_settings = yaml.load(f) + + if 'skipped_uris' not in app_settings or app_settings['skipped_uris'] != '/': + if remove_all: + new_users = '' + elif 'allowed_users' in app_settings: + for allowed_user in app_settings['allowed_users'].split(','): + if allowed_user not in users: + if new_users == '': + new_users = allowed_user + else: + new_users = new_users +','+ allowed_user + else: + new_users='' + for user in user_list(auth)['users']: + if user['username'] not in users: + if new_users == '': + new_users = user['username'] + new_users=new_users+','+user['username'] + + app_setting(app, 'allowed_users', new_users.strip()) + + app_ssowatconf(auth) + + return { 'allowed_users': new_users.split(',') } + + +def app_clearaccess(auth, apps): + """ + Reset access rights for the app + + Keyword argument: + apps + + """ + if not isinstance(apps, list): apps = [apps] + + for app in apps: + if not _is_installed(app): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_not_installed') % app) + + with open(apps_setting_path + app +'/settings.yml') as f: + app_settings = yaml.load(f) + + if 'mode' in app_settings: + app_setting(app, 'mode', delete=True) + + if 'allowed_users' in app_settings: + app_setting(app, 'allowed_users', delete=True) + + app_ssowatconf(auth) + + +def app_makedefault(auth, app, domain=None): + """ + Redirect domain root to an app + + Keyword argument: + app + domain + + """ + from yunohost.domain import domain_list + + if not _is_installed(app): + raise MoulinetteError(errno.EINVAL, m18n.n('app_not_installed') % app) + + with open(apps_setting_path + app +'/settings.yml') as f: + app_settings = yaml.load(f) + + app_domain = app_settings['domain'] + app_path = app_settings['path'] + + if domain is None: + domain = app_domain + elif domain not in domain_list(auth)['domains']: + raise MoulinetteError(errno.EINVAL, m18n.n('domain_unknown')) + + if '/' in app_map(raw=True)[domain]: + raise MoulinetteError(errno.EEXIST, + m18n.n('app_location_already_used')) + + try: + with open('/etc/ssowat/conf.json.persistent') as json_conf: + ssowat_conf = json.loads(str(json_conf.read())) + except IOError: + ssowat_conf = {} + + if 'redirected_urls' not in ssowat_conf: + ssowat_conf['redirected_urls'] = {} + + ssowat_conf['redirected_urls'][domain +'/'] = app_domain + app_path + + with open('/etc/ssowat/conf.json.persistent', 'w+') as f: + json.dump(ssowat_conf, f, sort_keys=True, indent=4) + + os.system('chmod 644 /etc/ssowat/conf.json.persistent') + + msignals.display(m18n.n('ssowat_conf_updated'), 'success') + + +def app_setting(app, key, value=None, delete=False): + """ + Set or get an app setting value + + Keyword argument: + value -- Value to set + app -- App ID + key -- Key to get/set + delete -- Delete the key + + """ + settings_file = apps_setting_path + app +'/settings.yml' + + try: + with open(settings_file) as f: + app_settings = yaml.load(f) + except IOError: + # Do not fail if setting file is not there + app_settings = {} + + if value is None and not delete: + # Get the value + if app_settings is not None and key in app_settings: + print(app_settings[key]) + else: + # Set the value + if app_settings is None: + app_settings = {} + if delete and key in app_settings: + del app_settings[key] + else: + app_settings[key] = value + + with open(settings_file, 'w') as f: + yaml.safe_dump(app_settings, f, default_flow_style=False) + + +def app_service(service, status=None, log=None, runlevel=None, remove=False): + """ + Add or remove a YunoHost monitored service + + Keyword argument: + service -- Service to add/remove + status -- Custom status command + log -- Absolute path to log file to display + runlevel -- Runlevel priority of the service + remove -- Remove service + + """ + service_file = '/etc/yunohost/services.yml' + + try: + with open(service_file) as f: + services = yaml.load(f) + except IOError: + # Do not fail if service file is not there + services = {} + + if remove and service in services: + del services[service] + else: + if status is None: + services[service] = { 'status': 'service' } + else: + services[service] = { 'status': status } + + if log is not None: + services[service]['log'] = log + + if runlevel is not None: + services[service]['runlevel'] = runlevel + + with open(service_file, 'w') as f: + yaml.safe_dump(services, f, default_flow_style=False) + + +def app_checkport(port): + """ + Check availability of a local port + + Keyword argument: + port -- Port to check + + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1) + s.connect(("localhost", int(port))) + s.close() + except socket.error: + msignals.display(m18n.n('port_available') % int(port), 'success') + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('port_unavailable') % int(port)) + + +def app_checkurl(auth, url, app=None): + """ + Check availability of a web path + + Keyword argument: + url -- Url to check + app -- Write domain & path to app settings for further checks + + """ + from yunohost.domain import domain_list + + if "https://" == url[:8]: + url = url[8:] + elif "http://" == url[:7]: + url = url[7:] + + if url[-1:] != '/': + url = url + '/' + + domain = url[:url.index('/')] + path = url[url.index('/'):] + + if path[-1:] != '/': + path = path + '/' + + apps_map = app_map(raw=True) + + if domain not in domain_list(auth)['domains']: + raise MoulinetteError(errno.EINVAL, m18n.n('domain_unknown')) + + if domain in apps_map: + if path in apps_map[domain]: + raise MoulinetteError(errno.EINVAL, m18n.n('app_location_already_used')) + for app_path, v in apps_map[domain].items(): + if app_path in path and app_path.count('/') < path.count('/'): + raise MoulinetteError(errno.EPERM, + m18n.n('app_location_install_failed')) + + if app is not None: + app_setting(app, 'domain', value=domain) + app_setting(app, 'path', value=path) + + +def app_initdb(user, password=None, db=None, sql=None): + """ + Create database and initialize it with optionnal attached script + + Keyword argument: + db -- DB name (user unless set) + user -- Name of the DB user + password -- Password of the DB (generated unless set) + sql -- Initial SQL file + + """ + if db is None: + db = user + + return_pwd = False + if password is None: + password = random_password(12) + return_pwd = True + print(password) + + mysql_root_pwd = open('/etc/yunohost/mysql').read().rstrip() + mysql_command = 'mysql -u root -p%s -e "CREATE DATABASE %s ; GRANT ALL PRIVILEGES ON %s.* TO \'%s\'@localhost IDENTIFIED BY \'%s\';"' % (mysql_root_pwd, db, db, user, password) + if os.system(mysql_command) != 0: + raise MoulinetteError(errno.EIO, m18n.n('mysql_db_creation_failed')) + if sql is not None: + if os.system('mysql -u %s -p%s %s < %s' % (user, password, db, sql)) != 0: + raise MoulinetteError(errno.EIO, m18n.n('mysql_db_init_failed')) + + if not return_pwd: + msignals.display(m18n.n('mysql_db_initialized'), 'success') + + +def app_ssowatconf(auth): + """ + Regenerate SSOwat configuration file + + + """ + from yunohost.domain import domain_list + from yunohost.user import user_list + + with open('/etc/yunohost/current_host', 'r') as f: + main_domain = f.readline().rstrip() + + domains = domain_list(auth)['domains'] + + users = {} + for user in user_list(auth)['users']: + users[user['username']] = app_map(user=user['username']) + + skipped_urls = [] + skipped_regex = [] + unprotected_urls = [] + unprotected_regex = [] + protected_urls = [] + protected_regex = [] + redirected_regex = { main_domain +'/yunohost[\/]?$': 'https://'+ main_domain +'/yunohost/sso/' } + + apps = {} + for app in app_list()['apps']: + if _is_installed(app['id']): + with open(apps_setting_path + app['id'] +'/settings.yml') as f: + app_settings = yaml.load(f) + if 'skipped_uris' in app_settings: + for item in app_settings['skipped_uris'].split(','): + if item[-1:] == '/': + item = item[:-1] + skipped_urls.append(app_settings['domain'] + app_settings['path'][:-1] + item) + if 'skipped_regex' in app_settings: + for item in app_settings['skipped_regex'].split(','): + skipped_regex.append(item) + if 'unprotected_uris' in app_settings: + for item in app_settings['unprotected_uris'].split(','): + if item[-1:] == '/': + item = item[:-1] + unprotected_urls.append(app_settings['domain'] + app_settings['path'][:-1] + item) + if 'unprotected_regex' in app_settings: + for item in app_settings['unprotected_regex'].split(','): + unprotected_regex.append(item) + if 'protected_uris' in app_settings: + for item in app_settings['protected_uris'].split(','): + if item[-1:] == '/': + item = item[:-1] + protected_urls.append(app_settings['domain'] + app_settings['path'][:-1] + item) + if 'protected_regex' in app_settings: + for item in app_settings['protected_regex'].split(','): + protected_regex.append(item) + + for domain in domains: + skipped_urls.extend([domain +'/yunohost/admin', domain +'/yunohost/api']) + + with open('/etc/ssowat/conf.json') as f: + conf_dict = json.load(f) + + if not 'portal_domain' in conf_dict: + conf_dict['portal_domain'] = main_domain + if not 'portal_path' in conf_dict: + conf_dict['portal_path'] = '/yunohost/sso/' + if not 'portal_port' in conf_dict: + conf_dict['portal_port'] = '443' + if not 'portal_scheme' in conf_dict: + conf_dict['portal_scheme'] = 'https' + if not 'additional_headers' in conf_dict: + conf_dict['additional_headers'] = { + 'Auth-User': 'uid', + 'Remote-User': 'uid', + 'Name': 'cn', + 'Email': 'mail' + } + conf_dict['domains'] = domains + conf_dict['skipped_urls'] = skipped_urls + conf_dict['unprotected_urls'] = unprotected_urls + conf_dict['protected_urls'] = protected_urls + conf_dict['skipped_regex'] = skipped_regex + conf_dict['unprotected_regex'] = unprotected_regex + conf_dict['protected_regex'] = protected_regex + conf_dict['redirected_regex'] = redirected_regex + conf_dict['users'] = users + + with open('/etc/ssowat/conf.json', 'w+') as f: + json.dump(conf_dict, f, sort_keys=True, indent=4) + + msignals.display(m18n.n('ssowat_conf_generated'), 'success') + + +def _extract_app_from_file(path, remove=False): + """ + Unzip or untar application tarball in app_tmp_folder, or copy it from a directory + + Keyword arguments: + path -- Path of the tarball or directory + remove -- Remove the tarball after extraction + + Returns: + Dict manifest + + """ + global app_tmp_folder + + msignals.display(m18n.n('extracting')) + + if os.path.exists(app_tmp_folder): shutil.rmtree(app_tmp_folder) + os.makedirs(app_tmp_folder) + + if ".zip" in path: + extract_result = os.system('cd %s && unzip %s -d %s > /dev/null 2>&1' % (os.getcwd(), path, app_tmp_folder)) + if remove: os.remove(path) + elif ".tar" in path: + extract_result = os.system('cd %s && tar -xf %s -C %s > /dev/null 2>&1' % (os.getcwd(), path, app_tmp_folder)) + if remove: os.remove(path) + elif (path[:1] == '/' and os.path.exists(path)) or (os.system('cd %s/%s' % (os.getcwd(), path)) == 0): + shutil.rmtree(app_tmp_folder) + if path[len(path)-1:] != '/': + path = path + '/' + extract_result = os.system('cd %s && cp -a "%s" %s' % (os.getcwd(), path, app_tmp_folder)) + else: + extract_result = 1 + + if extract_result != 0: + raise MoulinetteError(errno.EINVAL, m18n.n('app_extraction_failed')) + + try: + if len(os.listdir(app_tmp_folder)) == 1: + for folder in os.listdir(app_tmp_folder): + app_tmp_folder = app_tmp_folder +'/'+ folder + with open(app_tmp_folder + '/manifest.json') as json_manifest: + manifest = json.loads(str(json_manifest.read())) + manifest['lastUpdate'] = int(time.time()) + except IOError: + raise MoulinetteError(errno.EIO, m18n.n('app_install_files_invalid')) + + msignals.display(m18n.n('done')) + + return manifest + + +def _fetch_app_from_git(app): + """ + Unzip or untar application tarball in app_tmp_folder + + Keyword arguments: + app -- App_id or git repo URL + + Returns: + Dict manifest + + """ + global app_tmp_folder + + msignals.display(m18n.n('downloading')) + + if ('@' in app) or ('http://' in app) or ('https://' in app): + if "github.com" in app: + url = app.replace("git@github.com:", "https://github.com/") + if ".git" in url[-4:]: url = url[:-4] + if "/" in url [-1:]: url = url[:-1] + url = url + "/archive/master.zip" + if os.system('wget "%s" -O "%s.zip" > /dev/null 2>&1' % (url, app_tmp_folder)) == 0: + return _extract_app_from_file(app_tmp_folder +'.zip', remove=True) + + git_result = os.system('git clone %s %s' % (app, app_tmp_folder)) + git_result_2 = 0 + try: + with open(app_tmp_folder + '/manifest.json') as json_manifest: + manifest = json.loads(str(json_manifest.read())) + manifest['lastUpdate'] = int(time.time()) + except IOError: + raise MoulinetteError(errno.EIO, m18n.n('app_manifest_invalid')) + + else: + app_dict = app_list(raw=True) + + if app in app_dict: + app_info = app_dict[app] + app_info['manifest']['lastUpdate'] = app_info['lastUpdate'] + manifest = app_info['manifest'] + else: + raise MoulinetteError(errno.EINVAL, m18n.n('app_unknown')) + + if "github.com" in app_info['git']['url']: + url = app_info['git']['url'].replace("git@github.com:", "https://github.com/") + if ".git" in url[-4:]: url = url[:-4] + if "/" in url [-1:]: url = url[:-1] + url = url + "/archive/"+ str(app_info['git']['revision']) + ".zip" + if os.system('wget "%s" -O "%s.zip" > /dev/null 2>&1' % (url, app_tmp_folder)) == 0: + return _extract_app_from_file(app_tmp_folder +'.zip', remove=True) + + app_tmp_folder = install_tmp +'/'+ app + if os.path.exists(app_tmp_folder): shutil.rmtree(app_tmp_folder) + + git_result = os.system('git clone %s -b %s %s' % (app_info['git']['url'], app_info['git']['branch'], app_tmp_folder)) + git_result_2 = os.system('cd %s && git reset --hard %s' % (app_tmp_folder, str(app_info['git']['revision']))) + + if not git_result == git_result_2 == 0: + raise MoulinetteError(errno.EIO, m18n.n('app_sources_fetch_failed')) + + msignals.display(m18n.n('done')) + + return manifest + + +def _installed_instance_number(app, last=False): + """ + Check if application is installed and return instance number + + Keyword arguments: + app -- id of App to check + last -- Return only last instance number + + Returns: + Number of last installed instance | List or instances + + """ + if last: + number = 0 + try: + installed_apps = os.listdir(apps_setting_path) + except OSError: + os.makedirs(apps_setting_path) + return 0 + + for installed_app in installed_apps: + if number == 0 and app == installed_app: + number = 1 + elif '__' in installed_app: + if app == installed_app[:installed_app.index('__')]: + if int(installed_app[installed_app.index('__') + 2:]) > number: + number = int(installed_app[installed_app.index('__') + 2:]) + + return number + + else: + instance_number_list = [] + instances_dict = app_map(app=app, raw=True) + for key, domain in instances_dict.items(): + for key, path in domain.items(): + instance_number_list.append(path['instance']) + + return sorted(instance_number_list) + + +def _is_installed(app): + """ + Check if application is installed + + Keyword arguments: + app -- id of App to check + + Returns: + Boolean + + """ + try: + installed_apps = os.listdir(apps_setting_path) + except OSError: + os.makedirs(apps_setting_path) + return False + + for installed_app in installed_apps: + if app == installed_app: + return True + else: + continue + + return False + + +def is_true(arg): + """ + Convert a string into a boolean + + Keyword arguments: + arg -- The string to convert + + Returns: + Boolean + + """ + true_list = ['yes', 'Yes', 'true', 'True' ] + for string in true_list: + if arg == string: + return True + return False + + +def random_password(length=8): + """ + Generate a random string + + Keyword arguments: + length -- The string length to generate + + """ + import string, random + + char_set = string.ascii_uppercase + string.digits + string.ascii_lowercase + return ''.join(random.sample(char_set, length)) diff --git a/backup.py b/backup.py new file mode 100644 index 000000000..12b629d4e --- /dev/null +++ b/backup.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_backup.py + + Manage backups +""" +import os +import sys +import json +import yaml +import glob + +from moulinette.core import MoulinetteError + +def backup_init(helper=False): + """ + Init Tahoe-LAFS configuration + + Keyword argument: + helper -- Init as a helper node rather than a "helped" one + + """ + tahoe_cfg_dir = '/usr/share/yunohost/yunohost-config/backup' + if helper: + configure_cmd = '/configure_tahoe.sh helper' + else: + configure_cmd = '/configure_tahoe.sh' + + os.system('tahoe create-client /home/yunohost.backup/tahoe') + os.system('/bin/bash %s%s' % (tahoe_cfg_dir, configure_cmd)) + os.system('cp %s/tahoe.cfg /home/yunohost.backup/tahoe/' % tahoe_cfg_dir) + #os.system('update-rc.d tahoe-lafs defaults') + #os.system('service tahoe-lafs restart') diff --git a/data/checkupdate b/data/checkupdate new file mode 100644 index 000000000..82626e5c9 --- /dev/null +++ b/data/checkupdate @@ -0,0 +1,67 @@ +#!/bin/bash + +if [ ! -d /tmp/yunohost ]; +then + mkdir /tmp/yunohost +fi + +if [ -f /tmp/yunohost/changelog ]; +then + rm /tmp/yunohost/changelog +fi + +apt-get update -y > /dev/null 2>&1 +if [[ $? != 0 ]]; +then + exit 2 +else + echo OK > /tmp/yunohost/update_status +fi + +# Set $DIRCACHE +eval `/usr/bin/apt-config shell DIRCACHE Dir::Cache` + + +# get the list of packages which are pending an upgrade +PKGNAMES=`/usr/bin/apt-get -q -y --ignore-hold --allow-unauthenticated -s dist-upgrade | \ + /bin/grep ^Inst | /usr/bin/cut -d\ -f2 | /usr/bin/sort` + +if [[ $PKGNAMES = "" ]]; +then + exit 1 +fi + +if [ -n "$PKGNAMES" ] ; then + + # do the upgrade downloads + /usr/bin/apt-get --ignore-hold -qq -d --allow-unauthenticated --force-yes dist-upgrade > /dev/null +fi + + +PKGPATH="/${DIRCACHE}archives/" +for PKG in $PKGNAMES ; do + VER=`LC_ALL=C /usr/bin/apt-cache policy $PKG |\ + /bin/grep Candidate: | /usr/bin/cut -f 4 -d \ ` + OLDVER=`LC_ALL=C /usr/bin/apt-cache policy $PKG |\ + /bin/grep Installed: | /usr/bin/cut -f 4 -d \ ` + VERFILE=`echo "$VER" | /bin/sed -e "s/:/%3a/g"` + if ls ${PKGPATH}${PKG}_${VERFILE}_*.deb >& /dev/null ; then + DEBS="$DEBS ${PKGPATH}${PKG}_${VERFILE}_*.deb" + fi + echo -e "$PKG $OLDVER -> $VER" +done + +MISSING_DEBS=`apt-get -y --ignore-hold --allow-unauthenticated --print-uris dist-upgrade \ + | grep "file:" \ + | sed "s/'file:\(.*\)' .*/\1/g"` + +DEBS=`echo $MISSING_DEBS $DEBS | /usr/bin/sort` + +if [[ $DEBS = "" ]]; +then + exit 3 +else + if [ -x /usr/bin/apt-listchanges ] ; then + /usr/bin/apt-listchanges --which=both -f text $DEBS > /tmp/yunohost/changelog 2>/dev/null + fi +fi diff --git a/data/firewall.yml b/data/firewall.yml new file mode 100644 index 000000000..b548a1fc6 --- /dev/null +++ b/data/firewall.yml @@ -0,0 +1,10 @@ +uPnP: + enabled: false + TCP: [22, 25, 53, 80, 443, 465, 993, 5222, 5269, 5290] + UDP: [53] +ipv4: + TCP: [22, 25, 53, 80, 443, 465, 993, 5222, 5269, 5290] + UDP: [53] +ipv6: + TCP: [22, 25, 53, 80, 443, 465, 993, 5222, 5269, 5290] + UDP: [53] diff --git a/data/ldap_scheme.yml b/data/ldap_scheme.yml new file mode 100644 index 000000000..75bdea6e2 --- /dev/null +++ b/data/ldap_scheme.yml @@ -0,0 +1,56 @@ +parents: + ou=users: + ou: users + objectClass: + - organizationalUnit + - top + + ou=domains: + ou: domains + objectClass: + - organizationalUnit + - top + + ou=apps: + ou: apps + objectClass: + - organizationalUnit + - top + + ou=groups: + ou: groups + objectClass: + - organizationalUnit + - top + ou=sudo: + ou: sudo + objectClass: + - organizationalUnit + - top + +children: + cn=admins,ou=groups: + cn: admins + gidNumber: "4001" + memberUid: admin + objectClass: + - posixGroup + - top + + cn=sftpusers,ou=groups: + cn: sftpusers + gidNumber: "4002" + memberUid: admin + objectClass: + - posixGroup + - top + + cn=admin,ou=sudo: + cn: admin + sudoUser: admin + sudoHost: ALL + sudoCommand: ALL + sudoOption: "!authenticate" + objectClass: + - sudoRole + - top diff --git a/data/services.yml b/data/services.yml new file mode 100644 index 000000000..ec5c37d42 --- /dev/null +++ b/data/services.yml @@ -0,0 +1,38 @@ +nginx: + status: service + log: /var/log/nginx +bind9: + status: service + log: /var/log/daemon.log +dovecot: + status: service + log: [/var/log/mail.log,/var/log/mail.err] +postfix: + status: service + log: [/var/log/mail.log,/var/log/mail.err] +mysql: + status: service + log: [/var/log/mysql.log,/var/log/mysql.err] +glances: + status: service +tahoe-lafs: + status: ps aux | grep tahoe |grep -v grep + log: /home/yunohost.backup/tahoe/logs/twistd.log +ssh: + status: service + log: /var/log/auth.log +metronome: + status: metronomectl status + log: [/var/log/metronome/metronome.log,/var/log/metronome/metronome.err] +slapd: + status: service + log: /var/log/syslog +php5-fpm: + status: service + log: /var/log/php5-fpm.log +yunohost-api: + status: cat /usr/share/pyshared/yunohost-cli/twistd.pid + log: /var/log/yunohost.log +postgrey: + status: service + log: /var/log/mail.log diff --git a/data/upgrade b/data/upgrade new file mode 100644 index 000000000..b8a81cced --- /dev/null +++ b/data/upgrade @@ -0,0 +1,5 @@ +/bin/bash +rm /tmp/yunohost/update_status +sudo apt-get upgrade -y > /tmp/yunohost/update_log 2>&1 +if [ $(echo $?) = 0 ]; then echo "OK" > /tmp/yunohost/upgrade_status; else echo "NOK" > /tmp/yunohost/upgrade_status; fi +rm /tmp/yunohost/upgrade.run diff --git a/domain.py b/domain.py new file mode 100644 index 000000000..5faa37acb --- /dev/null +++ b/domain.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_domain.py + + Manage domains +""" +import os +import sys +import datetime +import re +import shutil +import json +import yaml +import errno +from urllib import urlopen + +from moulinette.core import MoulinetteError + + +def domain_list(auth, filter=None, limit=None, offset=None): + """ + List domains + + Keyword argument: + filter -- LDAP filter used to search + offset -- Starting number for domain fetching + limit -- Maximum number of domain fetched + + """ + result_list = [] + + # Set default arguments values + if offset is None: + offset = 0 + if limit is None: + limit = 1000 + if filter is None: + filter = 'virtualdomain=*' + + result = auth.search('ou=domains,dc=yunohost,dc=org', filter, ['virtualdomain']) + + if len(result) > offset and limit > 0: + for domain in result[offset:offset+limit]: + result_list.append(domain['virtualdomain'][0]) + return { 'domains': result_list } + + +def domain_add(auth, domains, main=False, dyndns=False): + """ + Create a custom domain + + Keyword argument: + domains -- Domain name to add + main -- Is the main domain + dyndns -- Subscribe to DynDNS + + """ + attr_dict = { 'objectClass' : ['mailDomain', 'top'] } + ip = str(urlopen('http://ip.yunohost.org').read()) + now = datetime.datetime.now() + timestamp = str(now.year) + str(now.month) + str(now.day) + result = [] + + if not isinstance(domains, list): + domains = [ domains ] + + for domain in domains: + if domain in domain_list(auth)['domains']: + continue + + # DynDNS domain + if dyndns: + if len(domain.split('.')) < 3: + raise MoulinetteError(errno.EINVAL, m18n.n('domain_dyndns_invalid')) + import requests + from yunohost.dyndns import dyndns_subscribe + + r = requests.get('http://dyndns.yunohost.org/domains') + dyndomains = json.loads(r.text) + dyndomain = '.'.join(domain.split('.')[1:]) + if dyndomain in dyndomains: + if os.path.exists('/etc/cron.d/yunohost-dyndns'): + raise MoulinetteError(errno.EPERM, + m18n.n('domain_dyndns_already_subscribed')) + dyndns_subscribe(domain=domain) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('domain_dyndns_root_unknown')) + + # Commands + ssl_dir = '/usr/share/yunohost/yunohost-config/ssl/yunoCA' + ssl_domain_path = '/etc/yunohost/certs/%s' % domain + with open('%s/serial' % ssl_dir, 'r') as f: + serial = f.readline().rstrip() + try: os.listdir(ssl_domain_path) + except OSError: os.makedirs(ssl_domain_path) + + command_list = [ + 'cp %s/openssl.cnf %s' % (ssl_dir, ssl_domain_path), + 'sed -i "s/yunohost.org/%s/g" %s/openssl.cnf' % (domain, ssl_domain_path), + 'openssl req -new -config %s/openssl.cnf -days 3650 -out %s/certs/yunohost_csr.pem -keyout %s/certs/yunohost_key.pem -nodes -batch' + % (ssl_domain_path, ssl_dir, ssl_dir), + 'openssl ca -config %s/openssl.cnf -days 3650 -in %s/certs/yunohost_csr.pem -out %s/certs/yunohost_crt.pem -batch' + % (ssl_domain_path, ssl_dir, ssl_dir), + 'ln -s /etc/ssl/certs/ca-yunohost_crt.pem %s/ca.pem' % ssl_domain_path, + 'cp %s/certs/yunohost_key.pem %s/key.pem' % (ssl_dir, ssl_domain_path), + 'cp %s/newcerts/%s.pem %s/crt.pem' % (ssl_dir, serial, ssl_domain_path), + 'chmod 755 %s' % ssl_domain_path, + 'chmod 640 %s/key.pem' % ssl_domain_path, + 'chmod 640 %s/crt.pem' % ssl_domain_path, + 'chmod 600 %s/openssl.cnf' % ssl_domain_path, + 'chown root:metronome %s/key.pem' % ssl_domain_path, + 'chown root:metronome %s/crt.pem' % ssl_domain_path + ] + + for command in command_list: + if os.system(command) != 0: + raise MoulinetteError(errno.EIO, + m18n.n('domain_cert_gen_failed')) + + try: + auth.validate_uniqueness({ 'virtualdomain': domain }) + except MoulinetteError: + raise MoulinetteError(errno.EEXIST, m18n.n('domain_exists')) + + + attr_dict['virtualdomain'] = domain + + try: + with open('/var/lib/bind/%s.zone' % domain) as f: pass + except IOError as e: + zone_lines = [ + '$TTL 38400', + '%s. IN SOA ns.%s. root.%s. %s 10800 3600 604800 38400' % (domain, domain, domain, timestamp), + '%s. IN NS ns.%s.' % (domain, domain), + '%s. IN A %s' % (domain, ip), + '%s. IN MX 5 %s.' % (domain, domain), + '%s. IN TXT "v=spf1 mx a -all"' % domain, + 'ns.%s. IN A %s' % (domain, ip), + '_xmpp-client._tcp.%s. IN SRV 0 5 5222 %s.' % (domain, domain), + '_xmpp-server._tcp.%s. IN SRV 0 5 5269 %s.' % (domain, domain), + '_jabber._tcp.%s. IN SRV 0 5 5269 %s.' % (domain, domain), + ] + if main: + zone_lines.extend([ + 'pubsub.%s. IN A %s' % (domain, ip), + 'muc.%s. IN A %s' % (domain, ip), + 'vjud.%s. IN A %s' % (domain, ip) + ]) + with open('/var/lib/bind/%s.zone' % domain, 'w') as zone: + for line in zone_lines: + zone.write(line + '\n') + + os.system('chown bind /var/lib/bind/%s.zone' % domain) + + else: + raise MoulinetteError(errno.EEXIST, + m18n.n('domain_zone_exists')) + + conf_lines = [ + 'zone "%s" {' % domain, + ' type master;', + ' file "/var/lib/bind/%s.zone";' % domain, + ' allow-transfer {', + ' 127.0.0.1;', + ' localnets;', + ' };', + '};' + ] + with open('/etc/bind/named.conf.local', 'a') as conf: + for line in conf_lines: + conf.write(line + '\n') + + os.system('service bind9 reload') + + # XMPP + try: + with open('/etc/metronome/conf.d/%s.cfg.lua' % domain) as f: pass + except IOError as e: + conf_lines = [ + 'VirtualHost "%s"' % domain, + ' ssl = {', + ' key = "%s/key.pem";' % ssl_domain_path, + ' certificate = "%s/crt.pem";' % ssl_domain_path, + ' }', + ' authentication = "ldap2"', + ' ldap = {', + ' hostname = "localhost",', + ' user = {', + ' basedn = "ou=users,dc=yunohost,dc=org",', + ' filter = "(&(objectClass=posixAccount)(mail=*@%s))",' % domain, + ' usernamefield = "mail",', + ' namefield = "cn",', + ' },', + ' }', + ] + with open('/etc/metronome/conf.d/%s.cfg.lua' % domain, 'w') as conf: + for line in conf_lines: + conf.write(line + '\n') + + os.system('mkdir -p /var/lib/metronome/%s/pep' % domain.replace('.', '%2e')) + os.system('chown -R metronome: /var/lib/metronome/') + os.system('chown -R metronome: /etc/metronome/conf.d/') + os.system('service metronome restart') + + + # Nginx + os.system('cp /usr/share/yunohost/yunohost-config/nginx/template.conf /etc/nginx/conf.d/%s.conf' % domain) + os.system('mkdir /etc/nginx/conf.d/%s.d/' % domain) + os.system('sed -i s/yunohost.org/%s/g /etc/nginx/conf.d/%s.conf' % (domain, domain)) + os.system('service nginx reload') + + if auth.add('virtualdomain=%s,ou=domains' % domain, attr_dict): + result.append(domain) + continue + else: + raise MoulinetteError(errno.EIO, m18n.n('domain_creation_failed')) + + + os.system('yunohost app ssowatconf > /dev/null 2>&1') + + msignals.display(m18n.n('domain_created'), 'success') + return { 'domains': result } + + +def domain_remove(auth, domains): + """ + Delete domains + + Keyword argument: + domains -- Domain(s) to delete + + """ + result = [] + domains_list = domain_list(auth)['domains'] + + if not isinstance(domains, list): + domains = [ domains ] + + for domain in domains: + if domain not in domains_list: + raise MoulinetteError(errno.EINVAL, m18n.n('domain_unknown')) + + # Check if apps are installed on the domain + for app in os.listdir('/etc/yunohost/apps/'): + with open('/etc/yunohost/apps/' + app +'/settings.yml') as f: + try: + app_domain = yaml.load(f)['domain'] + except: + continue + else: + if app_domain == domain: + raise MoulinetteError(errno.EPERM, + m18n.n('domain_uninstall_app_first')) + + if auth.remove('virtualdomain=' + domain + ',ou=domains'): + try: + shutil.rmtree('/etc/yunohost/certs/%s' % domain) + os.remove('/var/lib/bind/%s.zone' % domain) + shutil.rmtree('/var/lib/metronome/%s' % domain.replace('.', '%2e')) + os.remove('/etc/metronome/conf.d/%s.cfg.lua' % domain) + shutil.rmtree('/etc/nginx/conf.d/%s.d' % domain) + os.remove('/etc/nginx/conf.d/%s.conf' % domain) + except: + pass + with open('/etc/bind/named.conf.local', 'r') as conf: + conf_lines = conf.readlines() + with open('/etc/bind/named.conf.local', 'w') as conf: + in_block = False + for line in conf_lines: + if re.search(r'^zone "%s' % domain, line): + in_block = True + if in_block: + if re.search(r'^};$', line): + in_block = False + else: + conf.write(line) + result.append(domain) + continue + else: + raise MoulinetteError(errno.EIO, m18n.n('domain_deletion_failed')) + + os.system('yunohost app ssowatconf > /dev/null 2>&1') + os.system('service nginx reload') + os.system('service bind9 reload') + os.system('service metronome restart') + + msignals.display(m18n.n('domain_deleted'), 'success') + return { 'domains': result } diff --git a/dyndns.py b/dyndns.py new file mode 100644 index 000000000..08377753c --- /dev/null +++ b/dyndns.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_dyndns.py + + Subscribe and Update DynDNS Hosts +""" +import os +import sys +import requests +import json +import glob +import base64 +import errno + +from moulinette.core import MoulinetteError + + +def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None): + """ + Subscribe to a DynDNS service + + Keyword argument: + domain -- Full domain to subscribe with + key -- Public DNS key + subscribe_host -- Dynette HTTP API to subscribe to + + """ + if domain is None: + with open('/etc/yunohost/current_host', 'r') as f: + domain = f.readline().rstrip() + + # Verify if domain is available + if requests.get('http://%s/test/%s' % (subscribe_host, domain)).status_code != 200: + raise MoulinetteError(errno.EEXIST, m18n.n('dyndns_unavailable')) + + if key is None: + if len(glob.glob('/etc/yunohost/dyndns/*.key')) == 0: + os.makedirs('/etc/yunohost/dyndns') + print(_("DNS key is being generated, it may take a while...")) + os.system('cd /etc/yunohost/dyndns && dnssec-keygen -a hmac-md5 -b 128 -n USER %s' % domain) + os.system('chmod 600 /etc/yunohost/dyndns/*.key /etc/yunohost/dyndns/*.private') + + key_file = glob.glob('/etc/yunohost/dyndns/*.key')[0] + with open(key_file) as f: + key = f.readline().strip().split(' ')[-1] + + # Send subscription + r = requests.post('http://%s/key/%s' % (subscribe_host, base64.b64encode(key)), data={ 'subdomain': domain }) + if r.status_code != 201: + try: error = json.loads(r.text)['error'] + except: error = "Server error" + raise MoulinetteError(errno.EPERM, + m18n.n('dyndns_registration_failed') % error) + + msignals.display(m18n.n('dyndns_registered'), 'success') + + dyndns_installcron() + + +def dyndns_update(dyn_host="dynhost.yunohost.org", domain=None, key=None, ip=None): + """ + Update IP on DynDNS platform + + Keyword argument: + domain -- Full domain to subscribe with + dyn_host -- Dynette DNS server to inform + key -- Public DNS key + ip -- IP address to send + + """ + if domain is None: + with open('/etc/yunohost/current_host', 'r') as f: + domain = f.readline().rstrip() + + if ip is None: + new_ip = requests.get('http://ip.yunohost.org').text + else: + new_ip = ip + + try: + with open('/etc/yunohost/dyndns/old_ip', 'r') as f: + old_ip = f.readline().rstrip() + except IOError: + old_ip = '0.0.0.0' + + if old_ip != new_ip: + host = domain.split('.')[1:] + host = '.'.join(host) + lines = [ + 'server %s' % dyn_host, + 'zone %s' % host, + 'update delete %s. A' % domain, + 'update delete %s. MX' % domain, + 'update delete %s. TXT' % domain, + 'update delete pubsub.%s. A' % domain, + 'update delete muc.%s. A' % domain, + 'update delete vjud.%s. A' % domain, + 'update delete _xmpp-client._tcp.%s. SRV' % domain, + 'update delete _xmpp-server._tcp.%s. SRV' % domain, + 'update add %s. 1800 A %s' % (domain, new_ip), + 'update add %s. 14400 MX 5 %s.' % (domain, domain), + 'update add %s. 14400 TXT "v=spf1 a mx -all"' % domain, + 'update add pubsub.%s. 1800 A %s' % (domain, new_ip), + 'update add muc.%s. 1800 A %s' % (domain, new_ip), + 'update add vjud.%s. 1800 A %s' % (domain, new_ip), + 'update add _xmpp-client._tcp.%s. 14400 SRV 0 5 5222 %s.' % (domain, domain), + 'update add _xmpp-server._tcp.%s. 14400 SRV 0 5 5269 %s.' % (domain, domain), + 'show', + 'send' + ] + with open('/etc/yunohost/dyndns/zone', 'w') as zone: + for line in lines: + zone.write(line + '\n') + + if key is None: + private_key_file = glob.glob('/etc/yunohost/dyndns/*.private')[0] + else: + private_key_file = key + if os.system('/usr/bin/nsupdate -k %s /etc/yunohost/dyndns/zone' % private_key_file) == 0: + msignals.display(m18n.n('dyndns_ip_updated'), 'success') + with open('/etc/yunohost/dyndns/old_ip', 'w') as f: + f.write(new_ip) + else: + os.system('rm /etc/yunohost/dyndns/old_ip > /dev/null 2>&1') + raise MoulinetteError(errno.EPERM, + m18n.n('dyndns_ip_update_failed')) + + +def dyndns_installcron(): + """ + Install IP update cron + + + """ + with open('/etc/cron.d/yunohost-dyndns', 'w+') as f: + f.write('*/2 * * * * root yunohost dyndns update >> /dev/null') + + msignals.display(m18n.n('dyndns_cron_installed'), 'success') + + +def dyndns_removecron(): + """ + Remove IP update cron + + + """ + try: + os.remove("/etc/cron.d/yunohost-dyndns") + except: + raise MoulinetteError(errno.EIO, m18n.n('dyndns_cron_remove_failed')) + + msignals.display(m18n.n('dyndns_cron_removed'), 'success') diff --git a/firewall.py b/firewall.py new file mode 100644 index 000000000..b5c2a478d --- /dev/null +++ b/firewall.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_firewall.py + + Manage firewall rules +""" +import os +import sys +import yaml +import errno +try: + import miniupnpc +except ImportError: + sys.stderr.write('Error: Yunohost CLI Require miniupnpc lib\n') + sys.exit(1) + +from moulinette.core import MoulinetteError + + +def firewall_allow(port=None, protocol='TCP', ipv6=False, no_upnp=False): + """ + Allow connection port/protocol + + Keyword argument: + port -- Port to open + protocol -- Protocol associated with port + ipv6 -- ipv6 + no_upnp -- Do not request for uPnP + + """ + port = int(port) + ipv = "ipv4" + protocols = [protocol] + + firewall = firewall_list(raw=True) + + upnp = not no_upnp and firewall['uPnP']['enabled'] + + if ipv6: + ipv = "ipv6" + + if protocol == "Both": + protocols = ['UDP', 'TCP'] + + for protocol in protocols: + if upnp and port not in firewall['uPnP'][protocol]: + firewall['uPnP'][protocol].append(port) + if port not in firewall[ipv][protocol]: + firewall[ipv][protocol].append(port) + else: + msignals.display(m18n.n('port_already_opened') % port, 'warning') + + with open('/etc/yunohost/firewall.yml', 'w') as f: + yaml.safe_dump(firewall, f, default_flow_style=False) + + return firewall_reload() + + +def firewall_disallow(port=None, protocol='TCP', ipv6=False): + """ + Allow connection port/protocol + + Keyword argument: + port -- Port to open + protocol -- Protocol associated with port + ipv6 -- ipv6 + + """ + port = int(port) + ipv = "ipv4" + protocols = [protocol] + + firewall = firewall_list(raw=True) + + if ipv6: + ipv = "ipv6" + + if protocol == "Both": + protocols = ['UDP', 'TCP'] + + for protocol in protocols: + if port in firewall['uPnP'][protocol]: + firewall['uPnP'][protocol].remove(port) + if port in firewall[ipv][protocol]: + firewall[ipv][protocol].remove(port) + else: + msignals.display(m18n.n('port_already_closed') % port, 'warning') + + with open('/etc/yunohost/firewall.yml', 'w') as f: + yaml.safe_dump(firewall, f, default_flow_style=False) + + return firewall_reload() + + +def firewall_list(raw=False): + """ + List all firewall rules + + Keyword argument: + raw -- Return the complete YAML dict + + """ + with open('/etc/yunohost/firewall.yml') as f: + firewall = yaml.load(f) + + if raw: + return firewall + else: + return { "openned_ports": firewall['ipv4']['TCP'] } + + +def firewall_reload(): + """ + Reload all firewall rules + + + """ + from yunohost.hook import hook_callback + + firewall = firewall_list(raw=True) + upnp = firewall['uPnP']['enabled'] + + # IPv4 + if os.system("iptables -P INPUT ACCEPT") != 0: + raise MoulinetteError(errno.ESRCH, m18n.n('iptables_unavailable')) + if upnp: + try: + upnpc = miniupnpc.UPnP() + upnpc.discoverdelay = 200 + if upnpc.discover() == 1: + upnpc.selectigd() + for protocol in ['TCP', 'UDP']: + for port in firewall['uPnP'][protocol]: + if upnpc.getspecificportmapping(port, protocol): + try: upnpc.deleteportmapping(port, protocol) + except: pass + upnpc.addportmapping(port, protocol, upnpc.lanaddr, port, 'yunohost firewall : port %d' % port, '') + else: + raise MoulinetteError(errno.ENXIO, m18n.n('upnp_dev_not_found')) + except: + msignals.display(m18n.n('upnp_port_open_failed'), 'warning') + + os.system("iptables -F") + os.system("iptables -X") + os.system("iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT") + + if 22 not in firewall['ipv4']['TCP']: + firewall_allow(22) + + # Loop + for protocol in ['TCP', 'UDP']: + for port in firewall['ipv4'][protocol]: + os.system("iptables -A INPUT -p %s --dport %d -j ACCEPT" % (protocol, port)) + + hook_callback('post_iptable_rules', [upnp, os.path.exists("/proc/net/if_inet6")]) + + os.system("iptables -A INPUT -i lo -j ACCEPT") + os.system("iptables -A INPUT -p icmp -j ACCEPT") + os.system("iptables -P INPUT DROP") + + # IPv6 + if os.path.exists("/proc/net/if_inet6"): + os.system("ip6tables -P INPUT ACCEPT") + os.system("ip6tables -F") + os.system("ip6tables -X") + os.system("ip6tables -A INPUT -m state --state ESTABLISHED -j ACCEPT") + + if 22 not in firewall['ipv6']['TCP']: + firewall_allow(22, ipv6=True) + + # Loop v6 + for protocol in ['TCP', 'UDP']: + for port in firewall['ipv6'][protocol]: + os.system("ip6tables -A INPUT -p %s --dport %d -j ACCEPT" % (protocol, port)) + + os.system("ip6tables -A INPUT -i lo -j ACCEPT") + os.system("ip6tables -A INPUT -p icmpv6 -j ACCEPT") + os.system("ip6tables -P INPUT DROP") + + os.system("service fail2ban restart") + msignals.display(m18n.n('firewall_reloaded'), 'success') + + return firewall_list() + + +def firewall_upnp(action=None): + """ + Add uPnP cron and enable uPnP in firewall.yml, or the opposite. + + Keyword argument: + action -- enable/disable + + """ + firewall = firewall_list(raw=True) + + if action: + action = action[0] + + if action == 'enable': + firewall['uPnP']['enabled'] = True + + with open('/etc/cron.d/yunohost-firewall', 'w+') as f: + f.write('*/50 * * * * root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin yunohost firewall reload >>/dev/null') + + msignals.display(m18n.n('upnp_enabled'), 'success') + + if action == 'disable': + firewall['uPnP']['enabled'] = False + + try: + upnpc = miniupnpc.UPnP() + upnpc.discoverdelay = 200 + if upnpc.discover() == 1: + upnpc.selectigd() + for protocol in ['TCP', 'UDP']: + for port in firewall['uPnP'][protocol]: + if upnpc.getspecificportmapping(port, protocol): + try: upnpc.deleteportmapping(port, protocol) + except: pass + except: pass + + + try: os.remove('/etc/cron.d/yunohost-firewall') + except: pass + + msignals.display(m18n.n('upnp_disabled'), 'success') + + if action: + os.system("cp /etc/yunohost/firewall.yml /etc/yunohost/firewall.yml.old") + with open('/etc/yunohost/firewall.yml', 'w') as f: + yaml.safe_dump(firewall, f, default_flow_style=False) + + return { "enabled": firewall['uPnP']['enabled'] } + + +def firewall_stop(): + """ + Stop iptables and ip6tables + + + """ + + if os.system("iptables -P INPUT ACCEPT") != 0: + raise MoulinetteError(errno.ESRCH, m18n.n('iptables_unavailable')) + + os.system("iptables -F") + os.system("iptables -X") + + if os.path.exists("/proc/net/if_inet6"): + os.system("ip6tables -P INPUT ACCEPT") + os.system("ip6tables -F") + os.system("ip6tables -X") + + if os.path.exists("/etc/cron.d/yunohost-firewall"): + firewall_upnp('disable') diff --git a/hook.py b/hook.py new file mode 100644 index 000000000..b57183f26 --- /dev/null +++ b/hook.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_hook.py + + Manage hooks +""" +import os +import sys +import re +import json +import errno + +from moulinette.core import MoulinetteError + +hook_folder = '/usr/share/yunohost/hooks/' + +def hook_add(app, file): + """ + Store hook script to filsystem + + Keyword argument: + app -- App to link with + file -- Script to add (/path/priority-file) + + """ + path, filename = os.path.split(file) + if '-' in filename: + priority, action = filename.split('-') + else: + priority = '50' + action = filename + + try: os.listdir(hook_folder + action) + except OSError: os.makedirs(hook_folder + action) + + finalpath = hook_folder + action +'/'+ priority +'-'+ app + print app + os.system('cp %s %s' % (file, finalpath)) + os.system('chown -hR admin: %s' % hook_folder) + + return { 'hook': finalpath } + + +def hook_remove(app): + """ + Remove hooks linked to a specific app + + Keyword argument: + app -- Scripts related to app will be removed + + """ + try: + for action in os.listdir(hook_folder): + for script in os.listdir(hook_folder + action): + if script.endswith(app): + os.remove(hook_folder + action +'/'+ script) + except OSError: pass + + +def hook_callback(action, args=None): + """ + Execute all scripts binded to an action + + Keyword argument: + action -- Action name + args -- Ordered list of arguments to pass to the script + + """ + try: os.listdir(hook_folder + action) + except OSError: pass + else: + if args is None: + args = [] + elif not isinstance(args, list): + args = [args] + + for hook in os.listdir(hook_folder + action): + try: + hook_exec(file=hook_folder + action +'/'+ hook, args=args) + except: pass + + +def hook_check(file): + """ + Parse the script file and get arguments + + Keyword argument: + file -- File to check + + """ + try: + with open(file[:file.index('scripts/')] + 'manifest.json') as f: + manifest = json.loads(str(f.read())) + except: + raise MoulinetteError(errno.EIO, m18n.n('app_manifest_invalid')) + + action = file[file.index('scripts/') + 8:] + if 'arguments' in manifest and action in manifest['arguments']: + return manifest['arguments'][action] + else: + return {} + + +def hook_exec(file, args=None): + """ + Execute hook from a file with arguments + + Keyword argument: + file -- Script to execute + args -- Arguments to pass to the script + + """ + if isinstance(args, list): + arg_list = args + else: + required_args = hook_check(file) + if args is None: + args = {} + + arg_list = [] + for arg in required_args: + if arg['name'] in args: + if 'choices' in arg and args[arg['name']] not in arg['choices']: + raise MoulinetteError(errno.EINVAL, + m18n.n('hook_choice_invalid') + % args[arg['name']]) + arg_list.append(args[arg['name']]) + else: + if os.isatty(1) and 'ask' in arg: + # Retrieve proper ask string + ask_string = None + for lang in [m18n.locale, m18n.default_locale]: + if lang in arg['ask']: + ask_string = arg['ask'][lang] + break + if not ask_string: + # Fallback to en + ask_string = arg['ask']['en'] + + # Append extra strings + if 'choices' in arg: + ask_string += ' (%s)' % '|'.join(arg['choices']) + if 'default' in arg: + ask_string += ' (default: %s)' % arg['default'] + + input_string = msignals.prompt(ask_string) + + if input_string == '' and 'default' in arg: + input_string = arg['default'] + + arg_list.append(input_string) + elif 'default' in arg: + arg_list.append(arg['default']) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('hook_argument_missing') + % arg['name']) + + file_path = "./" + if "/" in file and file[0:2] != file_path: + file_path = os.path.dirname(file) + file = file.replace(file_path +"/", "") + return os.system('su - admin -c "cd \\"%s\\" && bash \\"%s\\" %s"' % (file_path, file, ' '.join(arg_list))) + #TODO: Allow python script diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 000000000..2c25aacff --- /dev/null +++ b/locales/en.json @@ -0,0 +1,128 @@ +{ + "yunohost" : "YunoHost", + "yunohost_not_installed" : "YunoHost is not or not correctly installed. Please execute 'yunohost tools postinstall'.", + + "upgrade_complete" : "Upgrade complete", + "installation_complete" : "Installation complete", + "installation_failed" : "Installation failed", + + "no_list_found" : "No list found", + "custom_list_name_required" : "You must provide a name for your custom list", + "list_retrieve_error" : "Unable to retrieve the remote list", + "list_feteched" : "List successfully fetched", + "list_unknown" : "Unknown list", + "list_removed" : "List successfully removed", + "app_unknown" : "Unknown app", + "app_no_upgrade" : "No app to upgrade", + "app_not_installed" : "%s is not installed", + "custom_app_url_required" : "You must provide an URL to upgrade your custom app %s", + "app_recent_version_required" : "%s requires a more recent version of the moulinette", + "app_upgraded" : "%s successfully upgraded", + "app_id_invalid" : "Invalid app id", + "app_already_installed" : "%s is already installed", + "app_removed" : "%s successfully removed", + "app_location_already_used" : "An app is already installed on this location", + "app_location_install_failed" : "Unable to install the app on this location", + "app_extraction_failed" : "Unable to extract installation files", + "app_install_files_invalid" : "Invalid installation files", + "app_manifest_invalid" : "Invalid app manifest", + "app_sources_fetch_failed" : "Unable to fetch sources files", + "ssowat_conf_updated" : "SSOwat persistent configuration successfully updated", + "ssowat_conf_generated" : "SSOwat configuration successfully generated", + "mysql_db_creation_failed" : "MySQL database creation failed", + "mysql_db_init_failed" : "MySQL database init failed", + "mysql_db_initialized" : "MySQL database successfully initialized", + "extracting" : "Extracting...", + "downloading" : "Downloading...", + "done" : "Done.", + + "domain_unknown" : "Unknown domain", + "domain_dyndns_invalid" : "Invalid domain to use with DynDNS", + "domain_dyndns_already_subscribed" : "You already have subscribed to a DynDNS domain", + "domain_dyndns_root_unknown" : "Unknown DynDNS root domain", + "domain_cert_gen_failed" : "Unable to generate certificate", + "domain_exists" : "Domain already exists", + "domain_zone_exists" : "Zone file already exists", + "domain_creation_failed" : "Unable to create domain", + "domain_created" : "Domain successfully created", + "domain_uninstall_app_first" : "One or more apps are installed on this domain. Please uninstall them before proceed to domain removal.", + "domain_deletion_failed" : "Unable to delete domain", + "domain_deleted" : "Domain successfully deleted", + + "dyndns_unavailable" : "Unavailable DynDNS subdomain", + "dyndns_registration_failed" : "Unable to register DynDNS domain: %s", + "dyndns_registered" : "DynDNS domain successfully registered", + "dyndns_ip_update_failed" : "Unable to update IP address on DynDNS", + "dyndns_ip_updated" : "IP address successfully updated on DynDNS", + "dyndns_cron_installed" : "DynDNS cron job successfully installed", + "dyndns_cron_remove_failed" : "Unable to remove DynDNS cron job", + "dyndns_cron_removed" : "DynDNS cron job successfully removed", + + "port_available" : "Port %d is available", + "port_unavailable" : "Port %d is not available", + "port_already_opened" : "Port %d is already opened", + "port_already_closed" : "Port %d is already closed", + "iptables_unavailable" : "You cannot play with iptables here. You are either in a container or your kernel does not support it.", + "upnp_dev_not_found" : "No uPnP device found", + "upnp_port_open_failed" : "Unable to open uPnP ports", + "upnp_enabled" : "uPnP successfully enabled", + "upnp_disabled" : "uPnP successfully disabled", + "firewall_reloaded" : "Firewall successfully reloaded", + + "hook_choice_invalid" : "Invalid choice '%s'", + "hook_argument_missing" : "Missing argument '%s'", + + "mountpoint_unknown" : "Unknown mountpoint", + "unit_unknown" : "Unknown unit '%s'", + "monitor_period_invalid" : "Invalid time period", + "monitor_stats_no_update" : "No monitoring statistics to update", + "monitor_stats_file_not_found" : "Statistics file not found", + "monitor_stats_period_unavailable" : "No available statistics for the period", + "monitor_enabled" : "Server monitoring successfully enabled", + "monitor_disabled" : "Server monitoring successfully disabled", + "monitor_not_enabled" : "Server monitoring is not enabled", + "monitor_glances_con_failed" : "Unable to connect to Glances server", + + "service_unknown" : "Unknown service '%s'", + "service_start_failed" : "Unable to start service '%s'", + "service_already_started" : "Service '%s' is already started", + "service_started" : "Service '%s' successfully started", + "service_stop_failed" : "Unable to stop service '%s'", + "service_already_stopped" : "Service '%s' is already stopped", + "service_stopped" : "Service '%s' successfully stopped", + "service_enable_failed" : "Unable to enable service '%s'", + "service_enabled" : "Service '%s' successfully enabled", + "service_disable_failed" : "Unable to disable service '%s'", + "service_disabled" : "Service '%s' successfully disabled", + "service_status_failed" : "Unable to determine status of service '%s'", + "service_no_log" : "No log to display for service '%s'", + "service_cmd_exec_failed" : "Unable to execute command '%s'", + + "ldap_initialized" : "LDAP successfully initialized", + "password_too_short" : "Password is too short", + "admin_password_change_failed" : "Unable to change password", + "admin_password_changed" : "Administration password successfully changed", + "maindomain_change_failed" : "Unable to change main domain", + "maindomain_changed" : "Main domain successfully changed", + "yunohost_installing" : "Installing YunoHost...", + "yunohost_already_installed" : "YunoHost is already installed", + "yunohost_ca_creation_failed" : "Unable to create certificate authority", + "yunohost_configured" : "YunoHost successfully configured", + "update_cache_failed" : "Unable to update APT cache", + "system_no_upgrade" : "There is no packages to upgrade", + "system_upgraded" : "System successfully upgraded", + + "field_invalid" : "Invalid field '%s'", + "mail_domain_unknown" : "Unknown mail address domain '%s'", + "mail_alias_remove_failed" : "Unable to remove mail alias '%s'", + "mail_forward_remove_failed" : "Unable to remove mail forward '%s'", + "user_unknown" : "Unknown user", + "user_creation_failed" : "Unable to create user", + "user_created" : "User successfully created", + "user_deletion_failed" : "Unable to delete user", + "user_deleted" : "User successfully deleted", + "user_update_failed" : "Unable to update user", + "user_updated" : "User successfully updated", + "user_info_failed" : "Unable to retrieve user information" +} + diff --git a/monitor.py b/monitor.py new file mode 100644 index 000000000..a2e7f8a18 --- /dev/null +++ b/monitor.py @@ -0,0 +1,710 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_monitor.py + + Monitoring functions +""" +import re +import json +import time +import psutil +import calendar +import subprocess +import xmlrpclib +import os.path +import errno +import cPickle as pickle +from urllib import urlopen +from datetime import datetime, timedelta + +from moulinette.core import MoulinetteError + +glances_uri = 'http://127.0.0.1:61209' +stats_path = '/var/lib/yunohost/stats' +crontab_path = '/etc/cron.d/yunohost-monitor' + + +def monitor_disk(units=None, mountpoint=None, human_readable=False): + """ + Monitor disk space and usage + + Keyword argument: + units -- Unit(s) to monitor + mountpoint -- Device mountpoint + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result_dname = None + result = {} + + if units is None: + units = ['io', 'filesystem'] + + _format_dname = lambda d: (os.path.realpath(d)).replace('/dev/', '') + + # Get mounted devices + devices = {} + for p in psutil.disk_partitions(all=True): + if not p.device.startswith('/dev/') or not p.mountpoint: + continue + if mountpoint is None: + devices[_format_dname(p.device)] = p.mountpoint + elif mountpoint == p.mountpoint: + dn = _format_dname(p.device) + devices[dn] = p.mountpoint + result_dname = dn + if len(devices) == 0: + if mountpoint is not None: + raise MoulinetteError(errno.ENODEV, m18n.n('mountpoint_unknown')) + return result + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'io': + ## Define setter + if len(units) > 1: + def _set(dn, dvalue): + try: + result[dn][u] = dvalue + except KeyError: + result[dn] = { u: dvalue } + else: + def _set(dn, dvalue): + result[dn] = dvalue + + # Iterate over values + devices_names = devices.keys() + for d in json.loads(glances.getDiskIO()): + dname = d.pop('disk_name') + try: + devices_names.remove(dname) + except: + continue + else: + _set(dname, d) + for dname in devices_names: + _set(dname, 'not-available') + elif u == 'filesystem': + ## Define setter + if len(units) > 1: + def _set(dn, dvalue): + try: + result[dn][u] = dvalue + except KeyError: + result[dn] = { u: dvalue } + else: + def _set(dn, dvalue): + result[dn] = dvalue + + # Iterate over values + devices_names = devices.keys() + for d in json.loads(glances.getFs()): + dname = _format_dname(d.pop('device_name')) + try: + devices_names.remove(dname) + except: + continue + else: + if human_readable: + for i in ['used', 'avail', 'size']: + d[i] = _binary_to_human(d[i]) + 'B' + _set(dname, d) + for dname in devices_names: + _set(dname, 'not-available') + else: + raise MoulinetteError(errno.EINVAL, m18n.n('unit_unknown') % u) + + if result_dname is not None: + return result[result_dname] + return result + + +def monitor_network(units=None, human_readable=False): + """ + Monitor network interfaces + + Keyword argument: + units -- Unit(s) to monitor + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result = {} + + if units is None: + units = ['usage', 'infos'] + + # Get network devices and their addresses + devices = {} + output = subprocess.check_output('ip addr show'.split()) + for d in re.split('^(?:[0-9]+: )', output, flags=re.MULTILINE): + d = re.sub('\n[ ]+', ' % ', d) # Replace new lines by % + m = re.match('([a-z]+[0-9]?): (.*)', d) # Extract device name (1) and its addresses (2) + if m: + devices[m.group(1)] = m.group(2) + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'usage': + result[u] = {} + for i in json.loads(glances.getNetwork()): + iname = i['interface_name'] + if iname in devices.keys(): + del i['interface_name'] + if human_readable: + for k in i.keys(): + if k != 'time_since_update': + i[k] = _binary_to_human(i[k]) + 'B' + result[u][iname] = i + elif u == 'infos': + try: + p_ip = str(urlopen('http://ip.yunohost.org').read()) + except: + p_ip = 'unknown' + + l_ip = 'unknown' + for name, addrs in devices.items(): + if name == 'lo': + continue + if not isinstance(l_ip, dict): + l_ip = {} + l_ip[name] = _extract_inet(addrs) + + gateway = 'unknown' + output = subprocess.check_output('ip route show'.split()) + m = re.search('default via (.*) dev ([a-z]+[0-9]?)', output) + if m: + addr = _extract_inet(m.group(1), True) + if len(addr) == 1: + proto, gateway = addr.popitem() + + result[u] = { + 'public_ip': p_ip, + 'local_ip': l_ip, + 'gateway': gateway + } + else: + raise MoulinetteError(errno.EINVAL, m18n.n('unit_unknown') % u) + + if len(units) == 1: + return result[units[0]] + return result + + +def monitor_system(units=None, human_readable=False): + """ + Monitor system informations and usage + + Keyword argument: + units -- Unit(s) to monitor + human_readable -- Print sizes in human readable format + + """ + glances = _get_glances_api() + result = {} + + if units is None: + units = ['memory', 'cpu', 'process', 'uptime', 'infos'] + + # Retrieve monitoring for unit(s) + for u in units: + if u == 'memory': + ram = json.loads(glances.getMem()) + swap = json.loads(glances.getMemSwap()) + if human_readable: + for i in ram.keys(): + if i != 'percent': + ram[i] = _binary_to_human(ram[i]) + 'B' + for i in swap.keys(): + if i != 'percent': + swap[i] = _binary_to_human(swap[i]) + 'B' + result[u] = { + 'ram': ram, + 'swap': swap + } + elif u == 'cpu': + result[u] = { + 'load': json.loads(glances.getLoad()), + 'usage': json.loads(glances.getCpu()) + } + elif u == 'process': + result[u] = json.loads(glances.getProcessCount()) + elif u == 'uptime': + result[u] = (str(datetime.now() - datetime.fromtimestamp(psutil.BOOT_TIME)).split('.')[0]) + elif u == 'infos': + result[u] = json.loads(glances.getSystem()) + else: + raise MoulinetteError(errno.EINVAL, m18n.n('unit_unknown') % u) + + if len(units) == 1 and type(result[units[0]]) is not str: + return result[units[0]] + return result + + +def monitor_update_stats(period): + """ + Update monitoring statistics + + Keyword argument: + period -- Time period to update (day, week, month) + + """ + if period not in ['day', 'week', 'month']: + raise MoulinetteError(errno.EINVAL, m18n.n('monitor_period_invalid')) + + stats = _retrieve_stats(period) + if not stats: + stats = { 'disk': {}, 'network': {}, 'system': {}, 'timestamp': [] } + + monitor = None + # Get monitoring stats + if period == 'day': + monitor = _monitor_all('day') + else: + t = stats['timestamp'] + p = 'day' if period == 'week' else 'week' + if len(t) > 0: + monitor = _monitor_all(p, t[len(t) - 1]) + else: + monitor = _monitor_all(p, 0) + if not monitor: + raise MoulinetteError(errno.ENODATA, m18n.n('monitor_stats_no_update')) + + stats['timestamp'].append(time.time()) + + # Append disk stats + for dname, units in monitor['disk'].items(): + disk = {} + # Retrieve current stats for disk name + if dname in stats['disk'].keys(): + disk = stats['disk'][dname] + + for unit, values in units.items(): + # Continue if unit doesn't contain stats + if not isinstance(values, dict): + continue + + # Retrieve current stats for unit and append new ones + curr = disk[unit] if unit in disk.keys() else {} + if unit == 'io': + disk[unit] = _append_to_stats(curr, values, 'time_since_update') + elif unit == 'filesystem': + disk[unit] = _append_to_stats(curr, values, ['fs_type', 'mnt_point']) + stats['disk'][dname] = disk + + # Append network stats + net_usage = {} + for iname, values in monitor['network']['usage'].items(): + # Continue if units doesn't contain stats + if not isinstance(values, dict): + continue + + # Retrieve current stats and append new ones + curr = {} + if 'usage' in stats['network'] and iname in stats['network']['usage']: + curr = stats['network']['usage'][iname] + net_usage[iname] = _append_to_stats(curr, values, 'time_since_update') + stats['network'] = { 'usage': net_usage, 'infos': monitor['network']['infos'] } + + # Append system stats + for unit, values in monitor['system'].items(): + # Continue if units doesn't contain stats + if not isinstance(values, dict): + continue + + # Set static infos unit + if unit == 'infos': + stats['system'][unit] = values + continue + + # Retrieve current stats and append new ones + curr = stats['system'][unit] if unit in stats['system'].keys() else {} + stats['system'][unit] = _append_to_stats(curr, values) + + _save_stats(stats, period) + + +def monitor_show_stats(period, date=None): + """ + Show monitoring statistics + + Keyword argument: + period -- Time period to show (day, week, month) + + """ + if period not in ['day', 'week', 'month']: + raise MoulinetteError(errno.EINVAL, m18n.n('monitor_period_invalid')) + + result = _retrieve_stats(period, date) + if result is False: + raise MoulinetteError(errno.ENOENT, + m18n.n('monitor_stats_file_not_found')) + elif result is None: + raise MoulinetteError(errno.EINVAL, + m18n.n('monitor_stats_period_unavailable')) + return result + + +def monitor_enable(no_stats=False): + """ + Enable server monitoring + + Keyword argument: + no_stats -- Disable monitoring statistics + + """ + from yunohost.service import (service_status, service_enable, + service_start) + + glances = service_status('glances') + if glances['status'] != 'running': + service_start('glances') + if glances['loaded'] != 'enabled': + service_enable('glances') + + # Install crontab + if not no_stats: + cmd = 'yunohost monitor update-stats' + # day: every 5 min # week: every 1 h # month: every 4 h # + rules = ('*/5 * * * * root %(cmd)s day --no-ldap >> /dev/null\n' + \ + '3 * * * * root %(cmd)s week --no-ldap >> /dev/null\n' + \ + '6 */4 * * * root %(cmd)s month --no-ldap >> /dev/null') % {'cmd': cmd} + os.system("touch %s" % crontab_path) + os.system("echo '%s' >%s" % (rules, crontab_path)) + + msignals.display(m18n.n('monitor_enabled'), 'success') + + +def monitor_disable(): + """ + Disable server monitoring + + """ + from yunohost.service import (service_status, service_disable, + service_stop) + + glances = service_status('glances') + if glances['status'] != 'inactive': + service_stop('glances') + if glances['loaded'] != 'disabled': + try: + service_disable('glances') + except MoulinetteError as e: + msignals.display(e.strerror, 'warning') + + # Remove crontab + try: + os.remove(crontab_path) + except: + pass + + msignals.display(m18n.n('monitor_disabled'), 'success') + + +def _get_glances_api(): + """ + Retrieve Glances API running on the local server + + """ + try: + p = xmlrpclib.ServerProxy(glances_uri) + p.system.methodHelp('getAll') + except (xmlrpclib.ProtocolError, IOError): + pass + else: + return p + + from yunohost.service import service_status + + if service_status('glances')['status'] != 'running': + raise MoulinetteError(errno.EPERM, m18n.n('monitor_not_enabled')) + raise MoulinetteError(errno.EIO, m18n.n('monitor_glances_con_failed')) + + +def _extract_inet(string, skip_netmask=False, skip_loopback=True): + """ + Extract IP addresses (v4 and/or v6) from a string limited to one + address by protocol + + Keyword argument: + string -- String to search in + skip_netmask -- True to skip subnet mask extraction + skip_loopback -- False to include addresses reserved for the + loopback interface + + Returns: + A dict of {protocol: address} with protocol one of 'ipv4' or 'ipv6' + + """ + ip4_pattern = '((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}' + ip6_pattern = '(((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)' + ip4_pattern += '/[0-9]{1,2})' if not skip_netmask else ')' + ip6_pattern += '/[0-9]{1,3})' if not skip_netmask else ')' + result = {} + + for m in re.finditer(ip4_pattern, string): + addr = m.group(1) + if skip_loopback and addr.startswith('127.'): + continue + + # Limit to only one result + result['ipv4'] = addr + break + + for m in re.finditer(ip6_pattern, string): + addr = m.group(1) + if skip_loopback and addr == '::1': + continue + + # Limit to only one result + result['ipv6'] = addr + break + + return result + + +def _binary_to_human(n, customary=False): + """ + Convert bytes or bits into human readable format with binary prefix + + Keyword argument: + n -- Number to convert + customary -- Use customary symbol instead of IEC standard + + """ + symbols = ('Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi') + if customary: + symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') + prefix = {} + for i, s in enumerate(symbols): + prefix[s] = 1 << (i+1)*10 + for s in reversed(symbols): + if n >= prefix[s]: + value = float(n) / prefix[s] + return '%.1f%s' % (value, s) + return "%s" % n + + +def _retrieve_stats(period, date=None): + """ + Retrieve statistics from pickle file + + Keyword argument: + period -- Time period to retrieve (day, week, month) + date -- Date of stats to retrieve + + """ + pkl_file = None + + # Retrieve pickle file + if date is not None: + timestamp = calendar.timegm(date) + pkl_file = '%s/%d_%s.pkl' % (stats_path, timestamp, period) + else: + pkl_file = '%s/%s.pkl' % (stats_path, period) + if not os.path.isfile(pkl_file): + return False + + # Read file and process its content + with open(pkl_file, 'r') as f: + result = pickle.load(f) + if not isinstance(result, dict): + return None + return result + + +def _save_stats(stats, period, date=None): + """ + Save statistics to pickle file + + Keyword argument: + stats -- Stats dict to save + period -- Time period of stats (day, week, month) + date -- Date of stats + + """ + pkl_file = None + + # Set pickle file name + if date is not None: + timestamp = calendar.timegm(date) + pkl_file = '%s/%d_%s.pkl' % (stats_path, timestamp, period) + else: + pkl_file = '%s/%s.pkl' % (stats_path, period) + if not os.path.isdir(stats_path): + os.makedirs(stats_path) + + # Limit stats + if date is None: + t = stats['timestamp'] + limit = { 'day': 86400, 'week': 604800, 'month': 2419200 } + if (t[len(t) - 1] - t[0]) > limit[period]: + begin = t[len(t) - 1] - limit[period] + stats = _filter_stats(stats, begin) + + # Write file content + with open(pkl_file, 'w') as f: + pickle.dump(stats, f) + return True + + +def _monitor_all(period=None, since=None): + """ + Monitor all units (disk, network and system) for the given period + If since is None, real-time monitoring is returned. Otherwise, the + mean of stats since this timestamp is calculated and returned. + + Keyword argument: + period -- Time period to monitor (day, week, month) + since -- Timestamp of the stats beginning + + """ + result = { 'disk': {}, 'network': {}, 'system': {} } + + # Real-time stats + if period == 'day' and since is None: + result['disk'] = monitor_disk() + result['network'] = monitor_network() + result['system'] = monitor_system() + return result + + # Retrieve stats and calculate mean + stats = _retrieve_stats(period) + if not stats: + return None + stats = _filter_stats(stats, since) + if not stats: + return None + result = _calculate_stats_mean(stats) + + return result + + +def _filter_stats(stats, t_begin=None, t_end=None): + """ + Filter statistics by beginning and/or ending timestamp + + Keyword argument: + stats -- Dict stats to filter + t_begin -- Beginning timestamp + t_end -- Ending timestamp + + """ + if t_begin is None and t_end is None: + return stats + + i_begin = i_end = None + # Look for indexes of timestamp interval + for i, t in enumerate(stats['timestamp']): + if t_begin and i_begin is None and t >= t_begin: + i_begin = i + if t_end and i != 0 and i_end is None and t > t_end: + i_end = i + # Check indexes + if i_begin is None: + if t_begin and t_begin > stats['timestamp'][0]: + return None + i_begin = 0 + if i_end is None: + if t_end and t_end < stats['timestamp'][0]: + return None + i_end = len(stats['timestamp']) + if i_begin == 0 and i_end == len(stats['timestamp']): + return stats + + # Filter function + def _filter(s, i, j): + for k, v in s.items(): + if isinstance(v, dict): + s[k] = _filter(v, i, j) + elif isinstance(v, list): + s[k] = v[i:j] + return s + + stats = _filter(stats, i_begin, i_end) + return stats + + +def _calculate_stats_mean(stats): + """ + Calculate the weighted mean for each statistic + + Keyword argument: + stats -- Stats dict to process + + """ + timestamp = stats['timestamp'] + t_sum = sum(timestamp) + del stats['timestamp'] + + # Weighted mean function + def _mean(s, t, ts): + for k, v in s.items(): + if isinstance(v, dict): + s[k] = _mean(v, t, ts) + elif isinstance(v, list): + try: + nums = [ float(x * t[i]) for i, x in enumerate(v) ] + except: + pass + else: + s[k] = sum(nums) / float(ts) + return s + + stats = _mean(stats, timestamp, t_sum) + return stats + + +def _append_to_stats(stats, monitor, statics=[]): + """ + Append monitoring statistics to current statistics + + Keyword argument: + stats -- Current stats dict + monitor -- Monitoring statistics + statics -- List of stats static keys + + """ + if isinstance(statics, str): + statics = [statics] + + # Appending function + def _append(s, m, st): + for k, v in m.items(): + if k in st: + s[k] = v + elif isinstance(v, dict): + if k not in s: + s[k] = {} + s[k] = _append(s[k], v, st) + else: + if k not in s: + s[k] = [] + if isinstance(v, list): + s[k].extend(v) + else: + s[k].append(v) + return s + + stats = _append(stats, monitor, statics) + return stats diff --git a/service.py b/service.py new file mode 100644 index 000000000..c2582c4d2 --- /dev/null +++ b/service.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_service.py + + Manage services +""" +import yaml +import glob +import subprocess +import errno +import os.path + +from moulinette.core import MoulinetteError + + +def service_start(names): + """ + Start one or more services + + Keyword argument: + names -- Services name to start + + """ + if isinstance(names, str): + names = [names] + for name in names: + if _run_service_command('start', name): + msignals.display(m18n.n('service_started') % name, 'success') + else: + if service_status(name)['status'] != 'running': + raise MoulinetteError(errno.EPERM, + m18n.n('service_start_failed') % name) + msignals.display(m18n.n('service_already_started') % name) + + +def service_stop(names): + """ + Stop one or more services + + Keyword argument: + name -- Services name to stop + + """ + if isinstance(names, str): + names = [names] + for name in names: + if _run_service_command('stop', name): + msignals.display(m18n.n('service_stopped') % name, 'success') + else: + if service_status(name)['status'] != 'inactive': + raise MoulinetteError(errno.EPERM, + m18n.n('service_stop_failed') % name) + msignals.display(m18n.n('service_already_stopped') % name) + + +def service_enable(names): + """ + Enable one or more services + + Keyword argument: + names -- Services name to enable + + """ + if isinstance(names, str): + names = [names] + for name in names: + if _run_service_command('enable', name): + msignals.display(m18n.n('service_enabled') % name, 'success') + else: + raise MoulinetteError(errno.EPERM, + m18n.n('service_enable_failed') % name) + + +def service_disable(names): + """ + Disable one or more services + + Keyword argument: + names -- Services name to disable + + """ + if isinstance(names, str): + names = [names] + for name in names: + if _run_service_command('disable', name): + msignals.display(m18n.n('service_disabled') % name, 'success') + else: + raise MoulinetteError(errno.EPERM, + m18n.n('service_disable_failed') % name) + + +def service_status(names=[]): + """ + Show status information about one or more services (all by default) + + Keyword argument: + names -- Services name to show + + """ + services = _get_services() + check_names = True + result = {} + + if isinstance(names, str): + names = [names] + elif len(names) == 0: + names = services.keys() + check_names = False + + for name in names: + if check_names and name not in services.keys(): + raise MoulinetteError(errno.EINVAL, + m18n.n('service_unknown') % name) + + status = None + if services[name]['status'] == 'service': + status = 'service %s status' % name + else: + status = str(services[name]['status']) + + runlevel = 5 + if 'runlevel' in services[name].keys(): + runlevel = int(services[name]['runlevel']) + + result[name] = { 'status': 'unknown', 'loaded': 'unknown' } + + # Retrieve service status + try: + ret = subprocess.check_output(status.split(), stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + if 'usage:' not in e.output.lower(): + result[name]['status'] = 'inactive' + else: + # TODO: Log output? + msignals.display(m18n.n('service_status_failed') % name, + 'warning') + else: + result[name]['status'] = 'running' + + # Retrieve service loading + rc_path = glob.glob("/etc/rc%d.d/S[0-9][0-9]%s" % (runlevel, name)) + if len(rc_path) == 1 and os.path.islink(rc_path[0]): + result[name]['loaded'] = 'enabled' + elif os.path.isfile("/etc/init.d/%s" % name): + result[name]['loaded'] = 'disabled' + else: + result[name]['loaded'] = 'not-found' + + if len(names) == 1: + return result[names[0]] + return result + + +def service_log(name, number=50): + """ + Log every log files of a service + + Keyword argument: + name -- Service name to log + number -- Number of lines to display + + """ + services = _get_services() + + if name not in services.keys(): + raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown') % name) + + if 'log' in services[name]: + log_list = services[name]['log'] + result = {} + if not isinstance(log_list, list): + log_list = [log_list] + + for log_path in log_list: + if os.path.isdir(log_path): + for log in [ f for f in os.listdir(log_path) if os.path.isfile(os.path.join(log_path, f)) and f[-4:] == '.log' ]: + result[os.path.join(log_path, log)] = _tail(os.path.join(log_path, log), int(number)) + else: + result[log_path] = _tail(log_path, int(number)) + else: + raise MoulinetteError(errno.EPERM, m18n.n('service_no_log') % name) + + return result + + +def _run_service_command(action, service): + """ + Run services management command (start, stop, enable, disable) + + Keyword argument: + service -- Service name + action -- Action to perform + + """ + if service not in _get_services().keys(): + raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown') % name) + + cmd = None + if action in ['start', 'stop']: + cmd = 'service %s %s' % (service, action) + elif action in ['enable', 'disable']: + arg = 'defaults' if action == 'enable' else 'remove' + cmd = 'update-rc.d %s %s' % (service, arg) + else: + raise ValueError("Unknown action '%s'" % action) + + try: + ret = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + # TODO: Log output? + msignals.display(m18n.n('service_cmd_exec_failed') % ' '.join(e.cmd), + 'warning') + return False + return True + + +def _get_services(): + """ + Get a dict of managed services with their parameters + + """ + with open('/etc/yunohost/services.yml', 'r') as f: + services = yaml.load(f) + return services + + +def _tail(file, n, offset=None): + """ + Reads a n lines from f with an offset of offset lines. The return + value is a tuple in the form ``(lines, has_more)`` where `has_more` is + an indicator that is `True` if there are more lines in the file. + + """ + avg_line_length = 74 + to_read = n + (offset or 0) + + try: + with open(file, 'r') as f: + while 1: + try: + f.seek(-(avg_line_length * to_read), 2) + except IOError: + # woops. apparently file is smaller than what we want + # to step back, go to the beginning instead + f.seek(0) + pos = f.tell() + lines = f.read().splitlines() + if len(lines) >= to_read or pos == 0: + return lines[-to_read:offset and -offset or None] + avg_line_length *= 1.3 + + except IOError: return [] diff --git a/tools.py b/tools.py new file mode 100644 index 000000000..7fa5d348c --- /dev/null +++ b/tools.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_tools.py + + Specific tools +""" +import os +import sys +import yaml +import re +import getpass +import requests +import json +import errno +import apt +import apt.progress + +from moulinette.core import MoulinetteError + +apps_setting_path= '/etc/yunohost/apps/' + +def tools_ldapinit(auth): + """ + YunoHost LDAP initialization + + + """ + with open('ldap_scheme.yml') as f: + ldap_map = yaml.load(f) + + for rdn, attr_dict in ldap_map['parents'].items(): + try: auth.add(rdn, attr_dict) + except: pass + + for rdn, attr_dict in ldap_map['children'].items(): + try: auth.add(rdn, attr_dict) + except: pass + + admin_dict = { + 'cn': 'admin', + 'uid': 'admin', + 'description': 'LDAP Administrator', + 'gidNumber': '1007', + 'uidNumber': '1007', + 'homeDirectory': '/home/admin', + 'loginShell': '/bin/bash', + 'objectClass': ['organizationalRole', 'posixAccount', 'simpleSecurityObject'], + 'userPassword': 'yunohost' + } + + auth.update('cn=admin', admin_dict) + + msignals.display(m18n.n('ldap_ initialized'), 'success') + + +def tools_adminpw(old_password, new_password): + """ + Change admin password + + Keyword argument: + new_password + old_password + + """ + # Validate password length + if len(new_password) < 4: + raise MoulinetteError(errno.EINVAL, m18n.n('password_too_short')) + + old_password.replace('"', '\\"') + old_password.replace('&', '\\&') + new_password.replace('"', '\\"') + new_password.replace('&', '\\&') + result = os.system('ldappasswd -h localhost -D cn=admin,dc=yunohost,dc=org -w "%s" -a "%s" -s "%s"' % (old_password, old_password, new_password)) + + if result == 0: + msignals.display(m18n.n('admin_password_changed'), 'success') + else: + raise MoulinetteError(errno.EPERM, + m18n.n('admin_password_change_failed')) + + +def tools_maindomain(auth, old_domain=None, new_domain=None, dyndns=False): + """ + Main domain change tool + + Keyword argument: + new_domain + old_domain + + """ + from yunohost.domain import domain_add + from yunohost.dyndns import dyndns_subscribe + + if not old_domain: + with open('/etc/yunohost/current_host', 'r') as f: + old_domain = f.readline().rstrip() + + if not new_domain: + return { 'current_main_domain': old_domain } + + config_files = [ + '/etc/postfix/main.cf', + '/etc/metronome/metronome.cfg.lua', + '/etc/dovecot/dovecot.conf', + '/usr/share/yunohost/yunohost-config/others/startup', + '/home/yunohost.backup/tahoe/tahoe.cfg', + '/etc/amavis/conf.d/05-node_id', + '/etc/amavis/conf.d/50-user' + ] + + config_dir = [] + + for dir in config_dir: + for file in os.listdir(dir): + config_files.append(dir + '/' + file) + + for file in config_files: + with open(file, "r") as sources: + lines = sources.readlines() + with open(file, "w") as sources: + for line in lines: + sources.write(re.sub(r''+ old_domain +'', new_domain, line)) + + domain_add(auth, [new_domain], main=True) + + os.system('rm /etc/ssl/private/yunohost_key.pem') + os.system('rm /etc/ssl/certs/yunohost_crt.pem') + + command_list = [ + 'ln -s /etc/yunohost/certs/%s/key.pem /etc/ssl/private/yunohost_key.pem' % new_domain, + 'ln -s /etc/yunohost/certs/%s/crt.pem /etc/ssl/certs/yunohost_crt.pem' % new_domain, + 'echo %s > /etc/yunohost/current_host' % new_domain, + 'service nginx restart', + 'service metronome restart', + 'service postfix restart', + 'service dovecot restart', + 'service amavis restart' + ] + + try: + with open('/etc/yunohost/light') as f: pass + except IOError: + command_list.append('service amavis restart') + #command_list.append('service tahoe-lafs restart') + + for command in command_list: + if os.system(command) != 0: + raise MoulinetteError(errno.EPERM, + m18n.n('maindomain_change_failed')) + + if dyndns: dyndns_subscribe(domain=new_domain) + elif len(new_domain.split('.')) >= 3: + r = requests.get('http://dyndns.yunohost.org/domains') + dyndomains = json.loads(r.text) + dyndomain = '.'.join(new_domain.split('.')[1:]) + if dyndomain in dyndomains: + dyndns_subscribe(domain=new_domain) + + msignals.display(m18n.n('maindomain_changed'), 'success') + + +def tools_postinstall(domain, password, dyndns=False): + """ + YunoHost post-install + + Keyword argument: + domain -- YunoHost main domain + dyndns -- Subscribe domain to a DynDNS service + password -- YunoHost admin password + + """ + from yunohost.backup import backup_init + from yunohost.app import app_ssowatconf + + try: + with open('/etc/yunohost/installed') as f: pass + except IOError: + msignals.display(m18n.n('yunohost_installing')) + else: + raise MoulinetteError(errno.EPERM, m18n.n('yunohost_already_installed')) + + if len(domain.split('.')) >= 3: + r = requests.get('http://dyndns.yunohost.org/domains') + dyndomains = json.loads(r.text) + dyndomain = '.'.join(domain.split('.')[1:]) + if dyndomain in dyndomains: + if requests.get('http://dyndns.yunohost.org/test/%s' % domain).status_code == 200: + dyndns=True + else: + raise MoulinetteError(errno.EEXIST, + m18n.n('dyndns_unavailable')) + + # Create required folders + folders_to_create = [ + '/etc/yunohost/apps', + '/etc/yunohost/certs', + '/var/cache/yunohost/repo', + '/home/yunohost.backup', + '/home/yunohost.app' + ] + + for folder in folders_to_create: + try: os.listdir(folder) + except OSError: os.makedirs(folder) + + # Set hostname to avoid amavis bug + if os.system('hostname -d') != 0: + os.system('hostname yunohost.yunohost.org') + + # Add a temporary SSOwat rule to redirect SSO to admin page + try: + with open('/etc/ssowat/conf.json.persistent') as json_conf: + ssowat_conf = json.loads(str(json_conf.read())) + except IOError: + ssowat_conf = {} + + if 'redirected_urls' not in ssowat_conf: + ssowat_conf['redirected_urls'] = {} + + ssowat_conf['redirected_urls']['/'] = domain +'/yunohost/admin' + + with open('/etc/ssowat/conf.json.persistent', 'w+') as f: + json.dump(ssowat_conf, f, sort_keys=True, indent=4) + + os.system('chmod 644 /etc/ssowat/conf.json.persistent') + + # Create SSL CA + ssl_dir = '/usr/share/yunohost/yunohost-config/ssl/yunoCA' + command_list = [ + 'echo "01" > %s/serial' % ssl_dir, + 'rm %s/index.txt' % ssl_dir, + 'touch %s/index.txt' % ssl_dir, + 'cp %s/openssl.cnf %s/openssl.ca.cnf' % (ssl_dir, ssl_dir), + 'sed -i "s/yunohost.org/%s/g" %s/openssl.ca.cnf ' % (domain, ssl_dir), + 'openssl req -x509 -new -config %s/openssl.ca.cnf -days 3650 -out %s/ca/cacert.pem -keyout %s/ca/cakey.pem -nodes -batch' % (ssl_dir, ssl_dir, ssl_dir), + 'cp %s/ca/cacert.pem /etc/ssl/certs/ca-yunohost_crt.pem' % ssl_dir, + 'update-ca-certificates' + ] + + for command in command_list: + if os.system(command) != 0: + raise MoulinetteError(errno.EPERM, + m18n.n('yunohost_ca_creation_failed')) + + # Initialize YunoHost LDAP base + tools_ldapinit(auth) + + # Initialize backup system + backup_init() + + # New domain config + tools_maindomain(auth, old_domain='yunohost.org', new_domain=domain, dyndns=dyndns) + + # Generate SSOwat configuration file + app_ssowatconf(auth) + + # Change LDAP admin password + tools_adminpw(old_password='yunohost', new_password=password) + + os.system('touch /etc/yunohost/installed') + os.system('service yunohost-api restart &') + + msignals.display(m18n.n('yunohost_configured'), 'success') + + +def tools_update(ignore_apps=False, ignore_packages=False): + """ + Update apps & package cache, then display changelog + + Keyword arguments: + ignore_apps -- Ignore app list update and changelog + ignore_packages -- Ignore apt cache update and changelog + + """ + from yunohost.app import app_fetchlist, app_info + + packages = [] + if not ignore_packages: + cache = apt.Cache() + # Update APT cache + if not cache.update(): + raise MoulinetteError(errno.EPERM, m18n.n('update_cache_failed')) + + cache.open(None) + cache.upgrade(True) + + # Add changelogs to the result + for pkg in cache.get_changes(): + packages.append({ + 'name': pkg.name, + 'fullname': pkg.fullname, + 'changelog': pkg.get_changelog() + }) + + apps = [] + if not ignore_apps: + app_fetchlist() + app_list = os.listdir(apps_setting_path) + if len(app_list) > 0: + for app_id in app_list: + if '__' in app_id: + original_app_id = app_id[:app_id.index('__')] + else: + original_app_id = app_id + + current_app_dict = app_info(app_id, raw=True) + new_app_dict = app_info(original_app_id, raw=True) + + # Custom app + if 'lastUpdate' not in new_app_dict or 'git' not in new_app_dict: + continue + + if (new_app_dict['lastUpdate'] > current_app_dict['lastUpdate']) \ + or ('update_time' not in current_app_dict['settings'] \ + and (new_app_dict['lastUpdate'] > current_app_dict['settings']['install_time'])) \ + or ('update_time' in current_app_dict['settings'] \ + and (new_app_dict['lastUpdate'] > current_app_dict['settings']['update_time'])): + apps.append({ + 'id': app_id, + 'label': current_app_dict['settings']['label'] + }) + + if len(apps) == 0 and len(packages) == 0: + msignals.display(m18n.n('system_no_upgrade'), 'success') + + return { 'packages': packages, 'apps': apps } + + +def tools_upgrade(ignore_apps=False, ignore_packages=False): + """ + Update apps & package cache, then display changelog + + Keyword arguments: + ignore_apps -- Ignore apps upgrade + ignore_packages -- Ignore APT packages upgrade + + """ + from yunohost.app import app_upgrade + + if not ignore_packages: + cache = apt.Cache() + cache.open(None) + cache.upgrade(True) + + # If API call + if not os.isatty(1): + critical_packages = ["yunohost-cli", "yunohost-admin", "yunohost-config-nginx", "ssowat", "python"] + for pkg in cache.get_changes(): + if pkg.name in critical_packages: + # Temporarily keep package ... + pkg.mark_keep() + # ... and set a hourly cron up to upgrade critical packages + with open('/etc/cron.d/yunohost-upgrade', 'w+') as f: + f.write('00 * * * * root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin apt-get install '+ ' '.join(critical_packages) + ' -y && rm -f /etc/cron.d/yunohost-upgrade') + try: + # Apply APT changes + cache.commit(apt.progress.text.AcquireProgress(), apt.progress.base.InstallProgress()) + except: pass + + if not ignore_apps: + try: + app_upgrade() + except: pass + + msignals.display(m18n.n('system_upgraded'), 'success') + + # Return API logs if it is an API call + if not os.isatty(1): + return { "log": service_log('yunohost-api', number="100").values()[0] } diff --git a/user.py b/user.py new file mode 100644 index 000000000..71a76f9ea --- /dev/null +++ b/user.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2014 YUNOHOST.ORG + + 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 http://www.gnu.org/licenses + +""" + +""" yunohost_user.py + + Manage users +""" +import os +import sys +import crypt +import random +import string +import json +import errno + +from moulinette.core import MoulinetteError + + +def user_list(auth, fields=None, filter=None, limit=None, offset=None): + """ + List users + + Keyword argument: + filter -- LDAP filter used to search + offset -- Starting number for user fetching + limit -- Maximum number of user fetched + fields -- fields to fetch + + """ + user_attrs = { 'uid': 'username', + 'cn': 'fullname', + 'mail': 'mail', + 'maildrop': 'mail-forward' } + attrs = [] + result_list = [] + + # Set default arguments values + if offset is None: + offset = 0 + if limit is None: + limit = 1000 + if filter is None: + filter = '(&(objectclass=person)(!(uid=root))(!(uid=nobody)))' + if fields: + keys = user_attrs.keys() + for attr in fields: + if attr in keys: + attrs.append(attr) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('field_invalid') % attr) + else: + attrs = [ 'uid', 'cn', 'mail' ] + + result = auth.search('ou=users,dc=yunohost,dc=org', filter, attrs) + + if len(result) > offset and limit > 0: + for user in result[offset:offset+limit]: + entry = {} + for attr, values in user.items(): + try: + entry[user_attrs[attr]] = values[0] + except: + pass + result_list.append(entry) + return { 'users' : result_list } + + +def user_create(auth, username, firstname, lastname, mail, password): + """ + Create user + + Keyword argument: + firstname + lastname + username -- Must be unique + mail -- Main mail address must be unique + password + + """ + from yunohost.domain import domain_list + from yunohost.hook import hook_callback + + # Validate password length + if len(password) < 4: + raise MoulinetteError(errno.EINVAL, m18n.n('password_too_short')) + + auth.validate_uniqueness({ + 'uid' : username, + 'mail' : mail + }) + + if mail[mail.find('@')+1:] not in domain_list(auth)['domains']: + raise MoulinetteError(errno.EINVAL, + m18n.n('mail_domain_unknown') + % mail[mail.find('@')+1:]) + + # Get random UID/GID + uid_check = gid_check = 0 + while uid_check == 0 and gid_check == 0: + uid = str(random.randint(200, 99999)) + uid_check = os.system("getent passwd %s" % uid) + gid_check = os.system("getent group %s" % uid) + + # Adapt values for LDAP + fullname = '%s %s' % (firstname, lastname) + rdn = 'uid=%s,ou=users' % username + char_set = string.ascii_uppercase + string.digits + salt = ''.join(random.sample(char_set,8)) + salt = '$1$' + salt + '$' + pwd = '{CRYPT}' + crypt.crypt(str(password), salt) + attr_dict = { + 'objectClass' : ['mailAccount', 'inetOrgPerson', 'posixAccount'], + 'givenName' : firstname, + 'sn' : lastname, + 'displayName' : fullname, + 'cn' : fullname, + 'uid' : username, + 'mail' : mail, + 'maildrop' : username, + 'userPassword' : pwd, + 'gidNumber' : uid, + 'uidNumber' : uid, + 'homeDirectory' : '/home/' + username, + 'loginShell' : '/bin/false' + } + + # If it is the first user, add some aliases + if not auth.search(base='ou=users,dc=yunohost,dc=org', filter='uid=*'): + with open('/etc/yunohost/current_host') as f: + main_domain = f.readline().rstrip() + aliases = [ + 'root@'+ main_domain, + 'admin@'+ main_domain, + 'webmaster@'+ main_domain, + 'postmaster@'+ main_domain, + ] + attr_dict['mail'] = [ attr_dict['mail'] ] + aliases + + # If exists, remove the redirection from the SSO + try: + with open('/etc/ssowat/conf.json.persistent') as json_conf: + ssowat_conf = json.loads(str(json_conf.read())) + + if 'redirected_urls' in ssowat_conf and '/' in ssowat_conf['redirected_urls']: + del ssowat_conf['redirected_urls']['/'] + + with open('/etc/ssowat/conf.json.persistent', 'w+') as f: + json.dump(ssowat_conf, f, sort_keys=True, indent=4) + + except IOError: pass + + + if auth.add(rdn, attr_dict): + # Update SFTP user group + memberlist = auth.search(filter='cn=sftpusers', attrs=['memberUid'])[0]['memberUid'] + memberlist.append(username) + if auth.update('cn=sftpusers,ou=groups', { 'memberUid': memberlist }): + os.system("su - %s -c ''" % username) + os.system('yunohost app ssowatconf > /dev/null 2>&1') + #TODO: Send a welcome mail to user + msignals.display(m18n.n('user_created'), 'success') + hook_callback('post_user_create', [username, mail, password, firstname, lastname]) + + return { 'fullname' : fullname, 'username' : username, 'mail' : mail } + + raise MoulinetteError(169, m18n.n('user_creation_failed')) + + +def user_delete(auth, users, purge=False): + """ + Delete user + + Keyword argument: + users -- Username of users to delete + purge + + """ + if not isinstance(users, list): + users = [ users ] + deleted = [] + + for user in users: + if auth.remove('uid=%s,ou=users' % user): + # Update SFTP user group + memberlist = auth.search(filter='cn=sftpusers', attrs=['memberUid'])[0]['memberUid'] + try: memberlist.remove(user) + except: pass + if auth.update('cn=sftpusers,ou=groups', { 'memberUid': memberlist }): + if purge: + os.system('rm -rf /home/%s' % user) + deleted.append(user) + continue + else: + raise MoulinetteError(169, m18n.n('user_deletion_failed')) + + os.system('yunohost app ssowatconf > /dev/null 2>&1') + msignals.display(m18n.n('user_deleted'), 'success') + return { 'users': deleted } + + +def user_update(auth, username, firstname=None, lastname=None, mail=None, change_password=None, add_mailforward=None, remove_mailforward=None, add_mailalias=None, remove_mailalias=None): + """ + Update user informations + + Keyword argument: + lastname + mail + firstname + add_mailalias -- Mail aliases to add + remove_mailforward -- Mailforward addresses to remove + username -- Username of user to update + add_mailforward -- Mailforward addresses to add + change_password -- New password to set + remove_mailalias -- Mail aliases to remove + + """ + from yunohost.domain import domain_list + + attrs_to_fetch = ['givenName', 'sn', 'mail', 'maildrop'] + new_attr_dict = {} + domains = domain_list(auth)['domains'] + + # Populate user informations + result = auth.search(base='ou=users,dc=yunohost,dc=org', filter='uid=' + username, attrs=attrs_to_fetch) + if not result: + raise MoulinetteError(errno.EINVAL, m18n.n('user_unknown')) + user = result[0] + + # Get modifications from arguments + if firstname: + new_attr_dict['givenName'] = firstname # TODO: Validate + new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + user['sn'][0] + + if lastname: + new_attr_dict['sn'] = lastname # TODO: Validate + new_attr_dict['cn'] = new_attr_dict['displayName'] = user['givenName'][0] + ' ' + lastname + + if lastname and firstname: + new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + lastname + + if change_password: + char_set = string.ascii_uppercase + string.digits + salt = ''.join(random.sample(char_set,8)) + salt = '$1$' + salt + '$' + new_attr_dict['userPassword'] = '{CRYPT}' + crypt.crypt(str(change_password), salt) + + if mail: + auth.validate_uniqueness({ 'mail': mail }) + if mail[mail.find('@')+1:] not in domains: + raise MoulinetteError(errno.EINVAL, + m18n.n('mail_domain_unknown') + % mail[mail.find('@')+1:]) + del user['mail'][0] + new_attr_dict['mail'] = [mail] + user['mail'] + + if add_mailalias: + if not isinstance(add_mailalias, list): + add_mailalias = [ add_mailalias ] + for mail in add_mailalias: + auth.validate_uniqueness({ 'mail': mail }) + if mail[mail.find('@')+1:] not in domains: + raise MoulinetteError(errno.EINVAL, + m18n.n('mail_domain_unknown') + % mail[mail.find('@')+1:]) + user['mail'].append(mail) + new_attr_dict['mail'] = user['mail'] + + if remove_mailalias: + if not isinstance(remove_mailalias, list): + remove_mailalias = [ remove_mailalias ] + for mail in remove_mailalias: + if len(user['mail']) > 1 and mail in user['mail'][1:]: + user['mail'].remove(mail) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('mail_alias_remove_failed') % mail) + new_attr_dict['mail'] = user['mail'] + + if add_mailforward: + if not isinstance(add_mailforward, list): + add_mailforward = [ add_mailforward ] + for mail in add_mailforward: + if mail in user['maildrop'][1:]: + continue + user['maildrop'].append(mail) + new_attr_dict['maildrop'] = user['maildrop'] + + if remove_mailforward: + if not isinstance(remove_mailforward, list): + remove_mailforward = [ remove_mailforward ] + for mail in remove_mailforward: + if len(user['maildrop']) > 1 and mail in user['maildrop'][1:]: + user['maildrop'].remove(mail) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('mail_forward_remove_failed') % mail) + new_attr_dict['maildrop'] = user['maildrop'] + + if auth.update('uid=%s,ou=users' % username, new_attr_dict): + msignals.display(m18n.n('user_updated'), 'success') + return user_info(auth, username) + else: + raise MoulinetteError(169, m18n.n('user_update_failed')) + + +def user_info(auth, username): + """ + Get user informations + + Keyword argument: + username -- Username or mail to get informations + + """ + user_attrs = ['cn', 'mail', 'uid', 'maildrop', 'givenName', 'sn'] + + if len(username.split('@')) is 2: + filter = 'mail='+ username + else: + filter = 'uid='+ username + + result = auth.search('ou=users,dc=yunohost,dc=org', filter, user_attrs) + + if result: + user = result[0] + else: + raise MoulinetteError(errno.EINVAL, m18n.n('user_unknown')) + + result_dict = { + 'username': user['uid'][0], + 'fullname': user['cn'][0], + 'firstname': user['givenName'][0], + 'lastname': user['sn'][0], + 'mail': user['mail'][0] + } + + if len(user['mail']) > 1: + result_dict['mail-aliases'] = user['mail'][1:] + + if len(user['maildrop']) > 1: + result_dict['mail-forward'] = user['maildrop'][1:] + + if result: + return result_dict + else: + raise MoulinetteError(167, m18n.n('user_info_failed'))