[^";]+))'), dkim_content, re.M|re.S + '(?=.*(;[\s]*|")p=(?P
[^";]+))'), dkim_content, re.M | re.S ) if dkim: result += '\n{host}. {ttl} IN TXT "v={v}; k={k}; p={p}"'.format( @@ -286,6 +263,18 @@ def domain_dns_conf(domain, ttl=None): return result +def domain_cert_status(auth, domain_list, full=False): + return yunohost.certificate.certificate_status(auth, domain_list, full) + + +def domain_cert_install(auth, domain_list, force=False, no_checks=False, self_signed=False, staging=False): + return yunohost.certificate.certificate_install(auth, domain_list, force, no_checks, self_signed, staging) + + +def domain_cert_renew(auth, domain_list, force=False, no_checks=False, email=False, staging=False): + return yunohost.certificate.certificate_renew(auth, domain_list, force, no_checks, email, staging) + + def get_public_ip(protocol=4): """Retrieve the public IP address from ip.yunohost.org""" if protocol == 4: @@ -301,3 +290,14 @@ def get_public_ip(protocol=4): logger.debug('cannot retrieve public IPv%d' % protocol, exc_info=1) raise MoulinetteError(errno.ENETUNREACH, m18n.n('no_internet_connection')) + + +def _get_maindomain(): + with open('/etc/yunohost/current_host', 'r') as f: + maindomain = f.readline().rstrip() + return maindomain + + +def _set_maindomain(domain): + with open('/etc/yunohost/current_host', 'w') as f: + f.write(domain) diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 878bc577e..7553e417c 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -94,8 +94,8 @@ def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None logger.info(m18n.n('dyndns_key_generating')) - os.system('cd /etc/yunohost/dyndns && ' \ - 'dnssec-keygen -a hmac-md5 -b 128 -n USER %s' % domain) + os.system('cd /etc/yunohost/dyndns && ' + 'dnssec-keygen -a hmac-md5 -b 128 -r /dev/urandom -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] @@ -104,12 +104,14 @@ def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None # Send subscription try: - r = requests.post('https://%s/key/%s' % (subscribe_host, base64.b64encode(key)), data={ 'subdomain': domain }) + r = requests.post('https://%s/key/%s' % (subscribe_host, base64.b64encode(key)), data={'subdomain': domain}) except requests.ConnectionError: raise MoulinetteError(errno.ENETUNREACH, m18n.n('no_internet_connection')) if r.status_code != 201: - try: error = json.loads(r.text)['error'] - except: error = "Server error" + try: + error = json.loads(r.text)['error'] + except: + error = "Server error" raise MoulinetteError(errno.EPERM, m18n.n('dyndns_registration_failed', error=error)) @@ -204,33 +206,33 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, lines = [ 'server %s' % dyn_host, 'zone %s' % host, - 'update delete %s. A' % domain, - 'update delete %s. AAAA' % domain, - 'update delete %s. MX' % domain, - 'update delete %s. TXT' % domain, + 'update delete %s. A' % domain, + 'update delete %s. AAAA' % domain, + 'update delete %s. MX' % domain, + 'update delete %s. TXT' % domain, 'update delete pubsub.%s. A' % domain, 'update delete pubsub.%s. AAAA' % domain, - 'update delete muc.%s. A' % domain, + 'update delete muc.%s. A' % domain, 'update delete muc.%s. AAAA' % domain, - 'update delete vjud.%s. A' % domain, + 'update delete vjud.%s. A' % domain, 'update delete vjud.%s. AAAA' % domain, 'update delete _xmpp-client._tcp.%s. SRV' % domain, 'update delete _xmpp-server._tcp.%s. SRV' % domain, - 'update add %s. 1800 A %s' % (domain, ipv4), + 'update add %s. 1800 A %s' % (domain, ipv4), '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, ipv4), - 'update add muc.%s. 1800 A %s' % (domain, ipv4), - 'update add vjud.%s. 1800 A %s' % (domain, ipv4), + 'update add pubsub.%s. 1800 A %s' % (domain, ipv4), + 'update add muc.%s. 1800 A %s' % (domain, ipv4), + 'update add vjud.%s. 1800 A %s' % (domain, ipv4), '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) ] if ipv6 is not None: lines += [ - 'update add %s. 1800 AAAA %s' % (domain, ipv6), + 'update add %s. 1800 AAAA %s' % (domain, ipv6), 'update add pubsub.%s. 1800 AAAA %s' % (domain, ipv6), - 'update add muc.%s. 1800 AAAA %s' % (domain, ipv6), - 'update add vjud.%s. 1800 AAAA %s' % (domain, ipv6), + 'update add muc.%s. 1800 AAAA %s' % (domain, ipv6), + 'update add vjud.%s. 1800 AAAA %s' % (domain, ipv6), ] lines += [ 'show', diff --git a/src/yunohost/firewall.py b/src/yunohost/firewall.py index 1291cf86a..91f484f48 100644 --- a/src/yunohost/firewall.py +++ b/src/yunohost/firewall.py @@ -67,14 +67,14 @@ def firewall_allow(protocol, port, ipv4_only=False, ipv6_only=False, # Validate protocols protocols = ['TCP', 'UDP'] if protocol != 'Both' and protocol in protocols: - protocols = [protocol,] + protocols = [protocol, ] # Validate IP versions ipvs = ['ipv4', 'ipv6'] if ipv4_only and not ipv6_only: - ipvs = ['ipv4',] + ipvs = ['ipv4', ] elif ipv6_only and not ipv4_only: - ipvs = ['ipv6',] + ipvs = ['ipv6', ] for p in protocols: # Iterate over IP versions to add port @@ -117,18 +117,18 @@ def firewall_disallow(protocol, port, ipv4_only=False, ipv6_only=False, # Validate protocols protocols = ['TCP', 'UDP'] if protocol != 'Both' and protocol in protocols: - protocols = [protocol,] + protocols = [protocol, ] # Validate IP versions and UPnP ipvs = ['ipv4', 'ipv6'] upnp = True if ipv4_only and ipv6_only: - upnp = True # automatically disallow UPnP + upnp = True # automatically disallow UPnP elif ipv4_only: - ipvs = ['ipv4',] + ipvs = ['ipv4', ] upnp = upnp_only elif ipv6_only: - ipvs = ['ipv6',] + ipvs = ['ipv6', ] upnp = upnp_only elif upnp_only: ipvs = [] @@ -178,7 +178,7 @@ def firewall_list(raw=False, by_ip_version=False, list_forwarded=False): ports = sorted(set(ports['ipv4']) | set(ports['ipv6'])) # Format returned dict - ret = { "opened_ports": ports } + ret = {"opened_ports": ports} if list_forwarded: # Combine TCP and UDP forwarded ports ret['forwarded_ports'] = sorted( @@ -224,8 +224,8 @@ def firewall_reload(skip_upnp=False): # Iterate over ports and add rule for protocol in ['TCP', 'UDP']: for port in firewall['ipv4'][protocol]: - rules.append("iptables -w -A INPUT -p %s --dport %s -j ACCEPT" \ - % (protocol, process.quote(str(port)))) + rules.append("iptables -w -A INPUT -p %s --dport %s -j ACCEPT" + % (protocol, process.quote(str(port)))) rules += [ "iptables -w -A INPUT -i lo -j ACCEPT", "iptables -w -A INPUT -p icmp -j ACCEPT", @@ -253,8 +253,8 @@ def firewall_reload(skip_upnp=False): # Iterate over ports and add rule for protocol in ['TCP', 'UDP']: for port in firewall['ipv6'][protocol]: - rules.append("ip6tables -w -A INPUT -p %s --dport %s -j ACCEPT" \ - % (protocol, process.quote(str(port)))) + rules.append("ip6tables -w -A INPUT -p %s --dport %s -j ACCEPT" + % (protocol, process.quote(str(port)))) rules += [ "ip6tables -w -A INPUT -i lo -j ACCEPT", "ip6tables -w -A INPUT -p icmpv6 -j ACCEPT", @@ -308,13 +308,14 @@ def firewall_upnp(action='status', no_refresh=False): try: # Remove old cron job os.remove('/etc/cron.d/yunohost-firewall') - except: pass + except: + pass action = 'status' no_refresh = False if action == 'status' and no_refresh: # Only return current state - return { 'enabled': enabled } + return {'enabled': enabled} elif action == 'enable' or (enabled and action == 'status'): # Add cron job with open(upnp_cron_job, 'w+') as f: @@ -330,7 +331,8 @@ def firewall_upnp(action='status', no_refresh=False): try: # Remove cron job os.remove(upnp_cron_job) - except: pass + except: + pass enabled = False if action == 'status': no_refresh = True @@ -364,7 +366,8 @@ def firewall_upnp(action='status', no_refresh=False): if upnpc.getspecificportmapping(port, protocol): try: upnpc.deleteportmapping(port, protocol) - except: pass + except: + pass if not enabled: continue try: @@ -403,7 +406,7 @@ def firewall_upnp(action='status', no_refresh=False): if action == 'enable' and not enabled: raise MoulinetteError(errno.ENXIO, m18n.n('upnp_port_open_failed')) - return { 'enabled': enabled } + return {'enabled': enabled} def firewall_stop(): @@ -444,12 +447,14 @@ def _get_ssh_port(default=22): pass return default + def _update_firewall_file(rules): """Make a backup and write new rules to firewall file""" os.system("cp {0} {0}.old".format(firewall_file)) with open(firewall_file, 'w') as f: yaml.safe_dump(rules, f, default_flow_style=False) + def _on_rule_command_error(returncode, cmd, output): """Callback for rules commands error""" # Log error and continue commands execution diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 500db919f..db7cd9504 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -24,11 +24,8 @@ Manage hooks """ import os -import sys import re -import json import errno -import subprocess from glob import iglob from moulinette.core import MoulinetteError @@ -315,7 +312,7 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, if path[0] != '/': path = os.path.realpath(path) if not os.path.isfile(path): - raise MoulinetteError(errno.EIO, m18n.g('file_not_exist')) + raise MoulinetteError(errno.EIO, m18n.g('file_not_exist', path=path)) # Construct command variables cmd_args = '' diff --git a/src/yunohost/monitor.py b/src/yunohost/monitor.py index d0fe224e9..18089e328 100644 --- a/src/yunohost/monitor.py +++ b/src/yunohost/monitor.py @@ -35,7 +35,7 @@ import errno import os import dns.resolver import cPickle as pickle -from datetime import datetime, timedelta +from datetime import datetime from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger @@ -44,8 +44,8 @@ from yunohost.domain import get_public_ip logger = getActionLogger('yunohost.monitor') -glances_uri = 'http://127.0.0.1:61209' -stats_path = '/var/lib/yunohost/stats' +glances_uri = 'http://127.0.0.1:61209' +stats_path = '/var/lib/yunohost/stats' crontab_path = '/etc/cron.d/yunohost-monitor' @@ -87,13 +87,13 @@ def monitor_disk(units=None, mountpoint=None, human_readable=False): # Retrieve monitoring for unit(s) for u in units: if u == 'io': - ## Define setter + # Define setter if len(units) > 1: def _set(dn, dvalue): try: result[dn][u] = dvalue except KeyError: - result[dn] = { u: dvalue } + result[dn] = {u: dvalue} else: def _set(dn, dvalue): result[dn] = dvalue @@ -111,13 +111,13 @@ def monitor_disk(units=None, mountpoint=None, human_readable=False): for dname in devices_names: _set(dname, 'not-available') elif u == 'filesystem': - ## Define setter + # Define setter if len(units) > 1: def _set(dn, dvalue): try: result[dn][u] = dvalue except KeyError: - result[dn] = { u: dvalue } + result[dn] = {u: dvalue} else: def _set(dn, dvalue): result[dn] = dvalue @@ -183,11 +183,11 @@ def monitor_network(units=None, human_readable=False): smtp_check = m18n.n('network_check_smtp_ko') try: - answers = dns.resolver.query(domain,'MX') + answers = dns.resolver.query(domain, 'MX') mx_check = {} i = 0 for server in answers: - mx_id = 'mx%s' %i + mx_id = 'mx%s' % i mx_check[mx_id] = server i = i + 1 except: @@ -307,7 +307,7 @@ def monitor_update_stats(period): stats = _retrieve_stats(period) if not stats: - stats = { 'disk': {}, 'network': {}, 'system': {}, 'timestamp': [] } + stats = {'disk': {}, 'network': {}, 'system': {}, 'timestamp': []} monitor = None # Get monitoring stats @@ -357,7 +357,7 @@ def monitor_update_stats(period): 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'] } + stats['network'] = {'usage': net_usage, 'infos': monitor['network']['infos']} # Append system stats for unit, values in monitor['system'].items(): @@ -421,7 +421,7 @@ def monitor_enable(with_stats=False): rules = ('*/5 * * * * root {cmd} day >> /dev/null\n' '3 * * * * root {cmd} week >> /dev/null\n' '6 */4 * * * root {cmd} month >> /dev/null').format( - cmd='/usr/bin/yunohost --quiet monitor update-stats') + cmd='/usr/bin/yunohost --quiet monitor update-stats') with open(crontab_path, 'w') as f: f.write(rules) @@ -530,7 +530,7 @@ def binary_to_human(n, customary=False): symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') prefix = {} for i, s in enumerate(symbols): - prefix[s] = 1 << (i+1)*10 + prefix[s] = 1 << (i + 1) * 10 for s in reversed(symbols): if n >= prefix[s]: value = float(n) / prefix[s] @@ -590,7 +590,7 @@ def _save_stats(stats, period, date=None): # Limit stats if date is None: t = stats['timestamp'] - limit = { 'day': 86400, 'week': 604800, 'month': 2419200 } + 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) @@ -612,7 +612,7 @@ def _monitor_all(period=None, since=None): since -- Timestamp of the stats beginning """ - result = { 'disk': {}, 'network': {}, 'system': {} } + result = {'disk': {}, 'network': {}, 'system': {}} # Real-time stats if period == 'day' and since is None: @@ -697,7 +697,7 @@ def _calculate_stats_mean(stats): s[k] = _mean(v, t, ts) elif isinstance(v, list): try: - nums = [ float(x * t[i]) for i, x in enumerate(v) ] + nums = [float(x * t[i]) for i, x in enumerate(v)] except: pass else: diff --git a/src/yunohost/service.py b/src/yunohost/service.py index ab26dd2bc..01b85dfbe 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -36,7 +36,7 @@ from difflib import unified_diff from moulinette.core import MoulinetteError from moulinette.utils import log, filesystem -from yunohost.hook import hook_list, hook_callback +from yunohost.hook import hook_callback base_conf_path = '/home/yunohost.conf' @@ -60,9 +60,9 @@ def service_add(name, status=None, log=None, runlevel=None): services = _get_services() if not status: - services[name] = { 'status': 'service' } + services[name] = {'status': 'service'} else: - services[name] = { 'status': status } + services[name] = {'status': status} if log is not None: services[name]['log'] = log @@ -202,7 +202,7 @@ def service_status(names=[]): status = None if 'status' not in services[name] or \ - services[name]['status'] == 'service': + services[name]['status'] == 'service': status = 'service %s status' % name else: status = str(services[name]['status']) @@ -211,7 +211,7 @@ def service_status(names=[]): if 'runlevel' in services[name].keys(): runlevel = int(services[name]['runlevel']) - result[name] = { 'status': 'unknown', 'loaded': 'unknown' } + result[name] = {'status': 'unknown', 'loaded': 'unknown'} # Retrieve service status try: @@ -261,7 +261,7 @@ def service_log(name, number=50): 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' ]: + 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)) @@ -314,13 +314,14 @@ def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, common_args = [1 if force else 0, 1 if dry_run else 0] # Execute hooks for pre-regen - pre_args = ['pre',] + common_args + pre_args = ['pre', ] + common_args + def _pre_call(name, priority, path, args): # create the pending conf directory for the service service_pending_path = os.path.join(pending_conf_dir, name) filesystem.mkdir(service_pending_path, 0755, True, uid='admin') # return the arguments to pass to the script - return pre_args + [service_pending_path,] + return pre_args + [service_pending_path, ] pre_result = hook_callback('conf_regen', names, pre_callback=_pre_call) # Update the services name @@ -336,8 +337,8 @@ def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, # Iterate over services and process pending conf for service, conf_files in _get_pending_conf(names).items(): logger.info(m18n.n( - 'service_regenconf_pending_applying' if not dry_run else \ - 'service_regenconf_dry_pending_applying', + 'service_regenconf_pending_applying' if not dry_run else + 'service_regenconf_dry_pending_applying', service=service)) conf_hashes = _get_conf_hashes(service) @@ -444,8 +445,8 @@ def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, continue elif not failed_regen: logger.success(m18n.n( - 'service_conf_updated' if not dry_run else \ - 'service_conf_would_be_updated', + 'service_conf_updated' if not dry_run else + 'service_conf_would_be_updated', service=service)) if succeed_regen and not dry_run: _update_conf_hashes(service, conf_hashes) @@ -461,14 +462,15 @@ def service_regen_conf(names=[], with_diff=False, force=False, dry_run=False, return result # Execute hooks for post-regen - post_args = ['post',] + common_args + post_args = ['post', ] + common_args + def _pre_call(name, priority, path, args): # append coma-separated applied changes for the service if name in result and result[name]['applied']: regen_conf_files = ','.join(result[name]['applied'].keys()) else: regen_conf_files = '' - return post_args + [regen_conf_files,] + return post_args + [regen_conf_files, ] hook_callback('conf_regen', names, pre_callback=_pre_call) return result @@ -556,7 +558,8 @@ def _tail(file, n, offset=None): return lines[-to_read:offset and -offset or None] avg_line_length *= 1.3 - except IOError: return [] + except IOError: + return [] def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True): @@ -636,12 +639,17 @@ def _get_pending_conf(services=[]): def _get_conf_hashes(service): """Get the registered conf hashes for a service""" - try: - return _get_services()[service]['conffiles'] - except: - logger.debug("unable to retrieve conf hashes for %s", - service, exc_info=1) + + services = _get_services() + + if service not in services: + logger.debug("Service %s is not in services.yml yet.", service) return {} + elif 'conffiles' not in services[service]: + logger.debug("No configuration files for service %s.", service) + return {} + else: + return services[service]['conffiles'] def _update_conf_hashes(service, hashes): diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index f78e32363..15c8a98f8 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -24,14 +24,13 @@ Specific tools """ import os -import sys import yaml -import re -import getpass import requests import json import errno import logging +import subprocess +import pwd from collections import OrderedDict import apt @@ -40,11 +39,11 @@ import apt.progress from moulinette.core import MoulinetteError, init_authenticator from moulinette.utils.log import getActionLogger from yunohost.app import app_fetchlist, app_info, app_upgrade, app_ssowatconf, app_list -from yunohost.domain import domain_add, domain_list, get_public_ip +from yunohost.domain import domain_add, domain_list, get_public_ip, _get_maindomain, _set_maindomain from yunohost.dyndns import dyndns_subscribe -from yunohost.firewall import firewall_upnp, firewall_reload +from yunohost.firewall import firewall_upnp from yunohost.service import service_status, service_regen_conf, service_log -from yunohost.monitor import monitor_disk, monitor_network, monitor_system +from yunohost.monitor import monitor_disk, monitor_system from yunohost.utils.packages import ynh_packages_version apps_setting_path= '/etc/yunohost/apps/' @@ -52,22 +51,34 @@ apps_setting_path= '/etc/yunohost/apps/' logger = getActionLogger('yunohost.tools') -def tools_ldapinit(auth): +def tools_ldapinit(): """ YunoHost LDAP initialization """ + + # Instantiate LDAP Authenticator + auth = init_authenticator(('ldap', 'default'), + {'uri': "ldap://localhost:389", + 'base_dn': "dc=yunohost,dc=org", + 'user_rdn': "cn=admin" }) + auth.authenticate('yunohost') + with open('/usr/share/yunohost/yunohost-config/moulinette/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 + 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 + try: + auth.add(rdn, attr_dict) + except: + pass admin_dict = { 'cn': 'admin', @@ -83,8 +94,18 @@ def tools_ldapinit(auth): auth.update('cn=admin', admin_dict) - logger.success(m18n.n('ldap_initialized')) + # Force nscd to refresh cache to take admin creation into account + subprocess.call(['nscd', '-i', 'passwd']) + # Check admin actually exists now + try: + pwd.getpwnam("admin") + except KeyError: + logger.error(m18n.n('ldap_init_failed_to_create_admin')) + raise MoulinetteError(errno.EINVAL, m18n.n('installation_failed')) + + logger.success(m18n.n('ldap_initialized')) + return auth def tools_adminpw(auth, new_password): """ @@ -104,56 +125,49 @@ def tools_adminpw(auth, new_password): logger.success(m18n.n('admin_password_changed')) -def tools_maindomain(auth, old_domain=None, new_domain=None, dyndns=False): +def tools_maindomain(auth, new_domain=None): """ - Main domain change tool + Check the current main domain, or change it Keyword argument: - new_domain - old_domain + new_domain -- The new domain to be set as the main domain """ - 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 } + # If no new domain specified, we return the current main domain if not new_domain: - raise MoulinetteError(errno.EINVAL, m18n.n('new_domain_required')) + return {'current_main_domain': _get_maindomain()} + + # Check domain exists if new_domain not in domain_list(auth)['domains']: - domain_add(auth, new_domain) + raise MoulinetteError(errno.EINVAL, m18n.n('domain_unknown')) - os.system('rm /etc/ssl/private/yunohost_key.pem') - os.system('rm /etc/ssl/certs/yunohost_crt.pem') + # Apply changes to ssl certs + ssl_key = "/etc/ssl/private/yunohost_key.pem" + ssl_crt = "/etc/ssl/private/yunohost_crt.pem" + new_ssl_key = "/etc/yunohost/certs/%s/key.pem" % new_domain + new_ssl_crt = "/etc/yunohost/certs/%s/crt.pem" % new_domain - 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, - ] + try: + if os.path.exists(ssl_key) or os.path.lexists(ssl_key): + os.remove(ssl_key) + if os.path.exists(ssl_crt) or os.path.lexists(ssl_crt): + os.remove(ssl_crt) - for command in command_list: - if os.system(command) != 0: - raise MoulinetteError(errno.EPERM, - m18n.n('maindomain_change_failed')) + os.symlink(new_ssl_key, ssl_key) + os.symlink(new_ssl_crt, ssl_crt) - if dyndns and len(new_domain.split('.')) >= 3: - try: - r = requests.get('https://dyndns.yunohost.org/domains') - except requests.ConnectionError: - pass - else: - dyndomains = json.loads(r.text) - dyndomain = '.'.join(new_domain.split('.')[1:]) - if dyndomain in dyndomains: - dyndns_subscribe(domain=new_domain) + _set_maindomain(new_domain) + except Exception as e: + logger.warning("%s" % e, exc_info=1) + raise MoulinetteError(errno.EPERM, m18n.n('maindomain_change_failed')) + # Regen configurations try: with open('/etc/yunohost/installed', 'r') as f: service_regen_conf() - except IOError: pass + except IOError: + pass logger.success(m18n.n('maindomain_changed')) @@ -164,7 +178,8 @@ def tools_postinstall(domain, password, ignore_dyndns=False): Keyword argument: domain -- YunoHost main domain - ignore_dyndns -- Do not subscribe domain to a DynDNS service + ignore_dyndns -- Do not subscribe domain to a DynDNS service (only + needed for nohost.me, noho.st domains) password -- YunoHost admin password """ @@ -182,25 +197,23 @@ def tools_postinstall(domain, password, ignore_dyndns=False): else: dyndomains = json.loads(r.text) dyndomain = '.'.join(domain.split('.')[1:]) + if dyndomain in dyndomains: if requests.get('https://dyndns.yunohost.org/test/%s' % domain).status_code == 200: dyndns = True else: raise MoulinetteError(errno.EEXIST, m18n.n('dyndns_unavailable')) + else: + dyndns = False + else: + dyndns = False logger.info(m18n.n('yunohost_installing')) - # Instantiate LDAP Authenticator - auth = init_authenticator(('ldap', 'default'), - {'uri': "ldap://localhost:389", - 'base_dn': "dc=yunohost,dc=org", - 'user_rdn': "cn=admin" }) - auth.authenticate('yunohost') - # Initialize LDAP for YunoHost # TODO: Improve this part by integrate ldapinit into conf_regen hook - tools_ldapinit(auth) + auth = tools_ldapinit() # Create required folders folders_to_create = [ @@ -212,8 +225,10 @@ def tools_postinstall(domain, password, ignore_dyndns=False): ] for folder in folders_to_create: - try: os.listdir(folder) - except OSError: os.makedirs(folder) + try: + os.listdir(folder) + except OSError: + os.makedirs(folder) # Change folders permissions os.system('chmod 755 /home/yunohost.app') @@ -226,6 +241,9 @@ def tools_postinstall(domain, password, ignore_dyndns=False): try: with open('/etc/ssowat/conf.json.persistent') as json_conf: ssowat_conf = json.loads(str(json_conf.read())) + except ValueError as e: + raise MoulinetteError(errno.EINVAL, + m18n.n('ssowat_persistent_conf_read_error', error=e.strerror)) except IOError: ssowat_conf = {} @@ -234,8 +252,13 @@ def tools_postinstall(domain, password, ignore_dyndns=False): 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) + try: + with open('/etc/ssowat/conf.json.persistent', 'w+') as f: + json.dump(ssowat_conf, f, sort_keys=True, indent=4) + except IOError as e: + raise MoulinetteError(errno.EPERM, + m18n.n('ssowat_persistent_conf_write_error', error=e.strerror)) + os.system('chmod 644 /etc/ssowat/conf.json.persistent') @@ -259,7 +282,8 @@ def tools_postinstall(domain, password, ignore_dyndns=False): m18n.n('yunohost_ca_creation_failed')) # New domain config - tools_maindomain(auth, old_domain='yunohost.org', new_domain=domain, dyndns=dyndns) + domain_add(auth, domain, dyndns) + tools_maindomain(auth, domain) # Generate SSOwat configuration file app_ssowatconf(auth) @@ -277,7 +301,6 @@ def tools_postinstall(domain, password, ignore_dyndns=False): os.system('service yunohost-firewall start') service_regen_conf(force=True) - logger.success(m18n.n('yunohost_configured')) @@ -298,6 +321,7 @@ def tools_update(ignore_apps=False, ignore_packages=False): logger.info(m18n.n('updating_apt_cache')) if not cache.update(): raise MoulinetteError(errno.EPERM, m18n.n('update_cache_failed')) + logger.info(m18n.n('done')) cache.open(None) @@ -345,7 +369,7 @@ def tools_update(ignore_apps=False, ignore_packages=False): if len(apps) == 0 and len(packages) == 0: logger.info(m18n.n('packages_no_upgrade')) - return { 'packages': packages, 'apps': apps } + return {'packages': packages, 'apps': apps} def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): @@ -378,6 +402,7 @@ def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): critical_upgrades.add(pkg.name) # Temporarily keep package ... pkg.mark_keep() + # ... and set a hourly cron up to upgrade critical packages if critical_upgrades: logger.info(m18n.n('packages_upgrade_critical_later', @@ -387,6 +412,7 @@ def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): if cache.get_changes(): logger.info(m18n.n('upgrading_packages')) + try: # Apply APT changes # TODO: Logs output for the API @@ -394,7 +420,7 @@ def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): apt.progress.base.InstallProgress()) except Exception as e: failure = True - logging.warning('unable to upgrade packages: %s' % str(e)) + logger.warning('unable to upgrade packages: %s' % str(e)) logger.error(m18n.n('packages_upgrade_failed')) else: logger.info(m18n.n('done')) @@ -406,7 +432,7 @@ def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): app_upgrade(auth) except Exception as e: failure = True - logging.warning('unable to upgrade apps: %s' % str(e)) + logger.warning('unable to upgrade apps: %s' % str(e)) logger.error(m18n.n('app_upgrade_failed')) if not failure: @@ -414,7 +440,7 @@ def tools_upgrade(auth, ignore_apps=False, ignore_packages=False): # Return API logs if it is an API call if is_api: - return { "log": service_log('yunohost-api', number="100").values()[0] } + return {"log": service_log('yunohost-api', number="100").values()[0]} def tools_diagnosis(auth, private=False): @@ -473,6 +499,7 @@ def tools_diagnosis(auth, private=False): # Services status services = service_status() diagnosis['services'] = {} + for service in services: diagnosis['services'][service] = "%s (%s)" % (services[service]['status'], services[service]['loaded']) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index ec7dd539c..9de9595f4 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -30,11 +30,11 @@ import string import json import errno import subprocess -import math import re from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger +from yunohost.service import service_status logger = getActionLogger('yunohost.user') @@ -50,12 +50,12 @@ def user_list(auth, fields=None, filter=None, limit=None, offset=None): fields -- fields to fetch """ - user_attrs = { 'uid': 'username', - 'cn': 'fullname', - 'mail': 'mail', - 'maildrop': 'mail-forward', - 'mailuserquota': 'mailbox-quota' } - attrs = [ 'uid' ] + user_attrs = {'uid': 'username', + 'cn': 'fullname', + 'mail': 'mail', + 'maildrop': 'mail-forward', + 'mailuserquota': 'mailbox-quota'} + attrs = ['uid'] users = {} # Set default arguments values @@ -74,12 +74,12 @@ def user_list(auth, fields=None, filter=None, limit=None, offset=None): raise MoulinetteError(errno.EINVAL, m18n.n('field_invalid', attr)) else: - attrs = [ 'uid', 'cn', 'mail', 'mailuserquota' ] + attrs = ['uid', 'cn', 'mail', 'mailuserquota'] 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]: + for user in result[offset:offset + limit]: entry = {} for attr, values in user.items(): try: @@ -88,11 +88,11 @@ def user_list(auth, fields=None, filter=None, limit=None, offset=None): pass uid = entry[user_attrs['uid']] users[uid] = entry - return { 'users' : users } + return {'users': users} def user_create(auth, username, firstname, lastname, mail, password, - mailbox_quota=0): + mailbox_quota="0"): """ Create user @@ -112,8 +112,8 @@ def user_create(auth, username, firstname, lastname, mail, password, # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ - 'uid' : username, - 'mail' : mail + 'uid': username, + 'mail': mail }) # Validate uniqueness of username in system users @@ -125,10 +125,10 @@ def user_create(auth, username, firstname, lastname, mail, password, raise MoulinetteError(errno.EEXIST, m18n.n('system_username_exists')) # Check that the mail domain exists - if mail[mail.find('@')+1:] not in domain_list(auth)['domains']: + if mail[mail.find('@') + 1:] not in domain_list(auth)['domains']: raise MoulinetteError(errno.EINVAL, m18n.n('mail_domain_unknown', - domain=mail[mail.find('@')+1:])) + domain=mail[mail.find('@') + 1:])) # Get random UID/GID uid_check = gid_check = 0 @@ -141,24 +141,24 @@ def user_create(auth, username, firstname, lastname, mail, password, 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 = ''.join(random.sample(char_set, 8)) salt = '$1$' + salt + '$' user_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, - 'mailuserquota' : mailbox_quota, - 'userPassword' : user_pwd, - 'gidNumber' : uid, - 'uidNumber' : uid, - 'homeDirectory' : '/home/' + username, - 'loginShell' : '/bin/false' + 'objectClass': ['mailAccount', 'inetOrgPerson', 'posixAccount'], + 'givenName': firstname, + 'sn': lastname, + 'displayName': fullname, + 'cn': fullname, + 'uid': username, + 'mail': mail, + 'maildrop': username, + 'mailuserquota': mailbox_quota, + 'userPassword': user_pwd, + 'gidNumber': uid, + 'uidNumber': uid, + 'homeDirectory': '/home/' + username, + 'loginShell': '/bin/false' } # If it is the first user, add some aliases @@ -166,26 +166,31 @@ def user_create(auth, username, firstname, lastname, mail, password, 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, + 'root@' + main_domain, + 'admin@' + main_domain, + 'webmaster@' + main_domain, + 'postmaster@' + main_domain, ] - attr_dict['mail'] = [ attr_dict['mail'] ] + aliases + 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())) + except ValueError as e: + raise MoulinetteError(errno.EINVAL, + m18n.n('ssowat_persistent_conf_read_error', error=e.strerror)) + except IOError: + ssowat_conf = {} - 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 'redirected_urls' in ssowat_conf and '/' in ssowat_conf['redirected_urls']: + del ssowat_conf['redirected_urls']['/'] + try: + with open('/etc/ssowat/conf.json.persistent', 'w+') as f: + json.dump(ssowat_conf, f, sort_keys=True, indent=4) + except IOError as e: + raise MoulinetteError(errno.EPERM, + m18n.n('ssowat_persistent_conf_write_error', error=e.strerror)) if auth.add(rdn, attr_dict): # Invalidate passwd to take user creation into account @@ -194,7 +199,7 @@ def user_create(auth, username, firstname, lastname, mail, password, # 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 }): + if auth.update('cn=sftpusers,ou=groups', {'memberUid': memberlist}): try: # Attempt to create user home folder subprocess.check_call( @@ -204,12 +209,12 @@ def user_create(auth, username, firstname, lastname, mail, password, logger.warning(m18n.n('user_home_creation_failed'), exc_info=1) app_ssowatconf(auth) - #TODO: Send a welcome mail to user + # TODO: Send a welcome mail to user logger.success(m18n.n('user_created')) hook_callback('post_user_create', args=[username, mail, password, firstname, lastname]) - return { 'fullname' : fullname, 'username' : username, 'mail' : mail } + return {'fullname': fullname, 'username': username, 'mail': mail} raise MoulinetteError(169, m18n.n('user_creation_failed')) @@ -232,9 +237,11 @@ def user_delete(auth, username, purge=False): # Update SFTP user group memberlist = auth.search(filter='cn=sftpusers', attrs=['memberUid'])[0]['memberUid'] - try: memberlist.remove(username) - except: pass - if auth.update('cn=sftpusers,ou=groups', { 'memberUid': memberlist }): + try: + memberlist.remove(username) + except: + pass + if auth.update('cn=sftpusers,ou=groups', {'memberUid': memberlist}): if purge: subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) else: @@ -280,11 +287,11 @@ def user_update(auth, username, firstname=None, lastname=None, mail=None, # Get modifications from arguments if firstname: - new_attr_dict['givenName'] = firstname # TODO: Validate + 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['sn'] = lastname # TODO: Validate new_attr_dict['cn'] = new_attr_dict['displayName'] = user['givenName'][0] + ' ' + lastname if lastname and firstname: @@ -292,34 +299,34 @@ def user_update(auth, username, firstname=None, lastname=None, mail=None, if change_password: char_set = string.ascii_uppercase + string.digits - salt = ''.join(random.sample(char_set,8)) + 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: + auth.validate_uniqueness({'mail': mail}) + if mail[mail.find('@') + 1:] not in domains: raise MoulinetteError(errno.EINVAL, m18n.n('mail_domain_unknown', - domain=mail[mail.find('@')+1:])) + domain=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 ] + add_mailalias = [add_mailalias] for mail in add_mailalias: - auth.validate_uniqueness({ 'mail': mail }) - if mail[mail.find('@')+1:] not in domains: + auth.validate_uniqueness({'mail': mail}) + if mail[mail.find('@') + 1:] not in domains: raise MoulinetteError(errno.EINVAL, m18n.n('mail_domain_unknown', - domain=mail[mail.find('@')+1:])) + domain=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 ] + remove_mailalias = [remove_mailalias] for mail in remove_mailalias: if len(user['mail']) > 1 and mail in user['mail'][1:]: user['mail'].remove(mail) @@ -330,7 +337,7 @@ def user_update(auth, username, firstname=None, lastname=None, mail=None, if add_mailforward: if not isinstance(add_mailforward, list): - add_mailforward = [ add_mailforward ] + add_mailforward = [add_mailforward] for mail in add_mailforward: if mail in user['maildrop'][1:]: continue @@ -339,7 +346,7 @@ def user_update(auth, username, firstname=None, lastname=None, mail=None, if remove_mailforward: if not isinstance(remove_mailforward, list): - remove_mailforward = [ remove_mailforward ] + remove_mailforward = [remove_mailforward] for mail in remove_mailforward: if len(user['maildrop']) > 1 and mail in user['maildrop'][1:]: user['maildrop'].remove(mail) @@ -352,11 +359,11 @@ def user_update(auth, username, firstname=None, lastname=None, mail=None, new_attr_dict['mailuserquota'] = mailbox_quota if auth.update('uid=%s,ou=users' % username, new_attr_dict): - logger.success(m18n.n('user_updated')) - app_ssowatconf(auth) - return user_info(auth, username) + logger.success(m18n.n('user_updated')) + app_ssowatconf(auth) + return user_info(auth, username) else: - raise MoulinetteError(169, m18n.n('user_update_failed')) + raise MoulinetteError(169, m18n.n('user_update_failed')) def user_info(auth, username): @@ -372,9 +379,9 @@ def user_info(auth, username): ] if len(username.split('@')) is 2: - filter = 'mail='+ username + filter = 'mail=' + username else: - filter = 'uid='+ username + filter = 'uid=' + username result = auth.search('ou=users,dc=yunohost,dc=org', filter, user_attrs) @@ -398,27 +405,50 @@ def user_info(auth, username): result_dict['mail-forward'] = user['maildrop'][1:] if 'mailuserquota' in user: - if user['mailuserquota'][0] != '0': - cmd = 'doveadm -f flow quota get -u %s' % user['uid'][0] - userquota = subprocess.check_output(cmd,stderr=subprocess.STDOUT, - shell=True) - quotavalue = re.findall(r'\d+', userquota) - result = '%s (%s%s)' % ( _convertSize(eval(quotavalue[0])), - quotavalue[2], '%') - result_dict['mailbox-quota'] = { - 'limit' : user['mailuserquota'][0], - 'use' : result - } + userquota = user['mailuserquota'][0] + + if isinstance(userquota, int): + userquota = str(userquota) + + # Test if userquota is '0' or '0M' ( quota pattern is ^(\d+[bkMGT])|0$ ) + is_limited = not re.match('0[bkMGT]?', userquota) + storage_use = '?' + + if service_status("dovecot")["status"] != "running": + logger.warning(m18n.n('mailbox_used_space_dovecot_down')) else: - result_dict['mailbox-quota'] = m18n.n('unlimit') - + cmd = 'doveadm -f flow quota get -u %s' % user['uid'][0] + cmd_result = subprocess.check_output(cmd, stderr=subprocess.STDOUT, + shell=True) + # Exemple of return value for cmd: + # """Quota name=User quota Type=STORAGE Value=0 Limit=- %=0 + # Quota name=User quota Type=MESSAGE Value=0 Limit=- %=0""" + has_value = re.search(r'Value=(\d+)', cmd_result) + + if has_value: + storage_use = int(has_value.group(1)) + storage_use = _convertSize(storage_use) + + if is_limited: + has_percent = re.search(r'%=(\d+)', cmd_result) + + if has_percent: + percentage = int(has_percent.group(1)) + storage_use += ' (%s%%)' % percentage + + result_dict['mailbox-quota'] = { + 'limit': userquota if is_limited else m18n.n('unlimit'), + 'use': storage_use + } + if result: return result_dict else: raise MoulinetteError(167, m18n.n('user_info_failed')) + def _convertSize(num, suffix=''): - for unit in ['K','M','G','T','P','E','Z']: + for unit in ['K', 'M', 'G', 'T', 'P', 'E', 'Z']: if abs(num) < 1024.0: return "%3.1f%s%s" % (num, unit, suffix) num /= 1024.0 diff --git a/src/yunohost/utils/packages.py b/src/yunohost/utils/packages.py index 5be2103e5..2372e7442 100644 --- a/src/yunohost/utils/packages.py +++ b/src/yunohost/utils/packages.py @@ -424,6 +424,7 @@ def get_installed_version(*pkgnames, **kwargs): return versions[pkgnames[0]] return versions + def meets_version_specifier(pkgname, specifier): """Check if a package installed version meets specifier""" spec = SpecifierSet(specifier) diff --git a/src/yunohost/vendor/__init__.py b/src/yunohost/vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/yunohost/vendor/acme_tiny/__init__.py b/src/yunohost/vendor/acme_tiny/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/yunohost/vendor/acme_tiny/acme_tiny.py b/src/yunohost/vendor/acme_tiny/acme_tiny.py new file mode 100644 index 000000000..d0ba33d1e --- /dev/null +++ b/src/yunohost/vendor/acme_tiny/acme_tiny.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +import argparse +import subprocess +import json +import os +import sys +import base64 +import binascii +import time +import hashlib +import re +import copy +import textwrap +import logging +try: + from urllib.request import urlopen # Python 3 +except ImportError: + from urllib2 import urlopen # Python 2 + +#DEFAULT_CA = "https://acme-staging.api.letsencrypt.org" +DEFAULT_CA = "https://acme-v01.api.letsencrypt.org" + +LOGGER = logging.getLogger(__name__) +LOGGER.addHandler(logging.StreamHandler()) +LOGGER.setLevel(logging.INFO) + + +def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): + # helper function base64 encode for jose spec + def _b64(b): + return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") + + # parse account key to get public key + log.info("Parsing account key...") + proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate() + if proc.returncode != 0: + raise IOError("OpenSSL Error: {0}".format(err)) + pub_hex, pub_exp = re.search( + r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", + out.decode('utf8'), re.MULTILINE | re.DOTALL).groups() + pub_exp = "{0:x}".format(int(pub_exp)) + pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp + header = { + "alg": "RS256", + "jwk": { + "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), + "kty": "RSA", + "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + }, + } + accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) + thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + + # helper function make signed requests + def _send_signed_request(url, payload): + payload64 = _b64(json.dumps(payload).encode('utf8')) + protected = copy.deepcopy(header) + protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] + protected64 = _b64(json.dumps(protected).encode('utf8')) + proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) + if proc.returncode != 0: + raise IOError("OpenSSL Error: {0}".format(err)) + data = json.dumps({ + "header": header, "protected": protected64, + "payload": payload64, "signature": _b64(out), + }) + try: + resp = urlopen(url, data.encode('utf8')) + return resp.getcode(), resp.read() + except IOError as e: + return getattr(e, "code", None), getattr(e, "read", e.__str__)() + + # find domains + log.info("Parsing CSR...") + proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate() + if proc.returncode != 0: + raise IOError("Error loading {0}: {1}".format(csr, err)) + domains = set([]) + common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) + if common_name is not None: + domains.add(common_name.group(1)) + subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE | re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.startswith("DNS:"): + domains.add(san[4:]) + + # get the certificate domains and expiration + log.info("Registering account...") + code, result = _send_signed_request(CA + "/acme/new-reg", { + "resource": "new-reg", + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf", + }) + if code == 201: + log.info("Registered!") + elif code == 409: + log.info("Already registered!") + else: + raise ValueError("Error registering: {0} {1}".format(code, result)) + + # verify each domain + for domain in domains: + log.info("Verifying {0}...".format(domain)) + + # get new challenge + code, result = _send_signed_request(CA + "/acme/new-authz", { + "resource": "new-authz", + "identifier": {"type": "dns", "value": domain}, + }) + if code != 201: + raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) + + # make the challenge file + challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + keyauthorization = "{0}.{1}".format(token, thumbprint) + wellknown_path = os.path.join(acme_dir, token) + with open(wellknown_path, "w") as wellknown_file: + wellknown_file.write(keyauthorization) + + # check that the file is in place + wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) + try: + resp = urlopen(wellknown_url) + resp_data = resp.read().decode('utf8').strip() + assert resp_data == keyauthorization + except (IOError, AssertionError): + os.remove(wellknown_path) + raise ValueError("Wrote file to {0}, but couldn't download {1}".format( + wellknown_path, wellknown_url)) + + # notify challenge are met + code, result = _send_signed_request(challenge['uri'], { + "resource": "challenge", + "keyAuthorization": keyauthorization, + }) + if code != 202: + raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) + + # wait for challenge to be verified + while True: + try: + resp = urlopen(challenge['uri']) + challenge_status = json.loads(resp.read().decode('utf8')) + except IOError as e: + raise ValueError("Error checking challenge: {0} {1}".format( + e.code, json.loads(e.read().decode('utf8')))) + if challenge_status['status'] == "pending": + time.sleep(2) + elif challenge_status['status'] == "valid": + log.info("{0} verified!".format(domain)) + os.remove(wellknown_path) + break + else: + raise ValueError("{0} challenge did not pass: {1}".format( + domain, challenge_status)) + + # get the new certificate + log.info("Signing certificate...") + proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + csr_der, err = proc.communicate() + code, result = _send_signed_request(CA + "/acme/new-cert", { + "resource": "new-cert", + "csr": _b64(csr_der), + }) + if code != 201: + raise ValueError("Error signing certificate: {0} {1}".format(code, result)) + + # return signed certificate! + log.info("Certificate signed!") + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) + + +def main(argv): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + This script automates the process of getting a signed TLS certificate from + Let's Encrypt using the ACME protocol. It will need to be run on your server + and have access to your private account key, so PLEASE READ THROUGH IT! It's + only ~200 lines, so it won't take long. + + ===Example Usage=== + python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt + =================== + + ===Example Crontab Renewal (once per month)=== + 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed.crt 2>> /var/log/acme_tiny.log + ============================================== + """) + ) + parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") + parser.add_argument("--csr", required=True, help="path to your certificate signing request") + parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") + parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") + parser.add_argument("--ca", default=DEFAULT_CA, help="certificate authority, default is Let's Encrypt") + + args = parser.parse_args(argv) + LOGGER.setLevel(args.quiet or LOGGER.level) + signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca) + sys.stdout.write(signed_crt) + +if __name__ == "__main__": # pragma: no cover + main(sys.argv[1:]) diff --git a/tests/test_actionmap.py b/tests/test_actionmap.py new file mode 100644 index 000000000..08b868839 --- /dev/null +++ b/tests/test_actionmap.py @@ -0,0 +1,4 @@ +import yaml + +def test_yaml_syntax(): + yaml.load(open("data/actionsmap/yunohost.yml"))