diff --git a/locales/en.json b/locales/en.json index 8f83b709..f27d9f5f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -213,6 +213,14 @@ "mailbox_used_space_dovecot_down": "Dovecot mailbox service need to be up, if you want to get mailbox used space", "maindomain_change_failed": "Unable to change the main domain", "maindomain_changed": "The main domain has been changed", + "migrate_tsig_end": "Migration to hmac-sha512 finished", + "migrate_tsig_failed": "Migrating the dyndns domain {domain} to hmac-sha512 failed, rolling back. Error: {error_code} - {error}", + "migrate_tsig_start": "Not secure enough key algorithm detected for TSIG signature of domain '{domain}', initiating migration to the more secure one hmac-sha512", + "migrate_tsig_wait": "Let's wait 3min for the dyndns server to take the new key into account...", + "migrate_tsig_wait_2": "2min...", + "migrate_tsig_wait_3": "1min...", + "migrate_tsig_wait_4": "30 secondes...", + "migrate_tsig_not_needed": "You do not appear to use a dyndns domain, so no migration is needed !", "migrations_backward": "Migrating backward.", "migrations_bad_value_for_target": "Invalide number for target argument, available migrations numbers are 0 or {}", "migrations_cant_reach_migration_file": "Can't access migrations files at path %s", diff --git a/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py b/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py new file mode 100644 index 00000000..5d495b06 --- /dev/null +++ b/src/yunohost/data_migrations/0002_migrate_to_tsig_sha256.py @@ -0,0 +1,91 @@ +import glob +import os +import requests +import base64 +import time +import json +import errno + +from moulinette import m18n +from moulinette.core import MoulinetteError +from moulinette.utils.log import getActionLogger + +from yunohost.tools import Migration +from yunohost.dyndns import _guess_current_dyndns_domain + +logger = getActionLogger('yunohost.migration') + + +class MyMigration(Migration): + "Migrate Dyndns stuff from MD5 TSIG to SHA512 TSIG" + + def backward(self): + # Not possible because that's a non-reversible operation ? + pass + + def migrate(self, dyn_host="dyndns.yunohost.org", domain=None, private_key_path=None): + + if domain is None or private_key_path is None: + try: + (domain, private_key_path) = _guess_current_dyndns_domain(dyn_host) + assert "+157" in private_key_path + except (MoulinetteError, AssertionError): + logger.warning(m18n.n("migrate_tsig_not_needed")) + return + + logger.warning(m18n.n('migrate_tsig_start', domain=domain)) + public_key_path = private_key_path.rsplit(".private", 1)[0] + ".key" + public_key_md5 = open(public_key_path).read().strip().split(' ')[-1] + + os.system('cd /etc/yunohost/dyndns && ' + 'dnssec-keygen -a hmac-sha512 -b 512 -r /dev/urandom -n USER %s' % domain) + os.system('chmod 600 /etc/yunohost/dyndns/*.key /etc/yunohost/dyndns/*.private') + + # +165 means that this file store a hmac-sha512 key + new_key_path = glob.glob('/etc/yunohost/dyndns/*+165*.key')[0] + public_key_sha512 = open(new_key_path).read().strip().split(' ', 6)[-1] + + try: + r = requests.put('https://%s/migrate_key_to_sha512/' % (dyn_host), + data={ + 'public_key_md5': base64.b64encode(public_key_md5), + 'public_key_sha512': base64.b64encode(public_key_sha512), + }, timeout=30) + 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 Exception: + # failed to decode json + error = r.text + + import traceback + from StringIO import StringIO + stack = StringIO() + traceback.print_stack(file=stack) + logger.error(stack.getvalue()) + + # Migration didn't succeed, so we rollback and raise an exception + os.system("mv /etc/yunohost/dyndns/*+165* /tmp") + + raise MoulinetteError(m18n.n('migrate_tsig_failed', domain=domain, + error_code=str(r.status_code), error=error)) + + # remove old certificates + os.system("mv /etc/yunohost/dyndns/*+157* /tmp") + + # sleep to wait for dyndns cache invalidation + logger.warning(m18n.n('migrate_tsig_wait')) + time.sleep(60) + logger.warning(m18n.n('migrate_tsig_wait_2')) + time.sleep(60) + logger.warning(m18n.n('migrate_tsig_wait_3')) + time.sleep(30) + logger.warning(m18n.n('migrate_tsig_wait_4')) + time.sleep(30) + + logger.warning(m18n.n('migrate_tsig_end')) + return + diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 55a2be69..851d04f4 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -27,6 +27,7 @@ import os import re import json import glob +import time import base64 import errno import requests @@ -46,6 +47,14 @@ OLD_IPV4_FILE = '/etc/yunohost/dyndns/old_ip' OLD_IPV6_FILE = '/etc/yunohost/dyndns/old_ipv6' DYNDNS_ZONE = '/etc/yunohost/dyndns/zone' +RE_DYNDNS_PRIVATE_KEY_MD5 = re.compile( + r'.*/K(?P[^\s\+]+)\.\+157.+\.private$' +) + +RE_DYNDNS_PRIVATE_KEY_SHA512 = re.compile( + r'.*/K(?P[^\s\+]+)\.\+165.+\.private$' +) + def _dyndns_provides(provider, domain): """ @@ -129,21 +138,22 @@ def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None if key is None: if len(glob.glob('/etc/yunohost/dyndns/*.key')) == 0: - os.makedirs('/etc/yunohost/dyndns') + if not os.path.exists('/etc/yunohost/dyndns'): + os.makedirs('/etc/yunohost/dyndns') logger.info(m18n.n('dyndns_key_generating')) os.system('cd /etc/yunohost/dyndns && ' - 'dnssec-keygen -a hmac-md5 -b 128 -r /dev/urandom -n USER %s' % domain) + 'dnssec-keygen -a hmac-sha512 -b 512 -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] with open(key_file) as f: - key = f.readline().strip().split(' ')[-1] + key = f.readline().strip().split(' ', 6)[-1] # 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?key_algo=hmac-sha512' % (subscribe_host, base64.b64encode(key)), data={'subdomain': domain}, timeout=30) except requests.ConnectionError: raise MoulinetteError(errno.ENETUNREACH, m18n.n('no_internet_connection')) if r.status_code != 201: @@ -213,6 +223,19 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, key = keys[0] + # This mean that hmac-md5 is used + # (Re?)Trigger the migration to sha256 and return immediately. + # The actual update will be done in next run. + if "+157" in key: + from yunohost.tools import _get_migration_by_name + migration = _get_migration_by_name("migrate_to_tsig_sha256") + try: + migration["module"].MyMigration().migrate(dyn_host, domain, key) + except Exception as e: + logger.error(m18n.n('migrations_migration_has_failed', exception=e, **migration), exc_info=1) + + return + # Extract 'host', e.g. 'nohost.me' from 'foo.nohost.me' host = domain.split('.')[1:] host = '.'.join(host) @@ -313,15 +336,13 @@ def _guess_current_dyndns_domain(dyn_host): dynette...) """ - re_dyndns_private_key = re.compile( - r'.*/K(?P[^\s\+]+)\.\+157.+\.private$' - ) - # Retrieve the first registered domain for path in glob.iglob('/etc/yunohost/dyndns/K*.private'): - match = re_dyndns_private_key.match(path) + match = RE_DYNDNS_PRIVATE_KEY_MD5.match(path) if not match: - continue + match = RE_DYNDNS_PRIVATE_KEY_SHA512.match(path) + if not match: + continue _domain = match.group('domain') # Verify if domain is registered (i.e., if it's available, skip diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 959da2df..b997961b 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -774,30 +774,10 @@ def tools_migrations_migrate(target=None, skip=False): # loading all migrations for migration in tools_migrations_list()["migrations"]: - logger.debug(m18n.n('migrations_loading_migration', - number=migration["number"], - name=migration["name"], - )) - - try: - # this is python builtin method to import a module using a name, we - # use that to import the migration as a python object so we'll be - # able to run it in the next loop - module = import_module("yunohost.data_migrations.{file_name}".format(**migration)) - except Exception: - import traceback - traceback.print_exc() - - raise MoulinetteError(errno.EINVAL, m18n.n('migrations_error_failed_to_load_migration', - number=migration["number"], - name=migration["name"], - )) - break - migrations.append({ "number": migration["number"], "name": migration["name"], - "module": module, + "module": _get_migration_module(migration), }) migrations = sorted(migrations, key=lambda x: x["number"]) @@ -934,6 +914,47 @@ def _get_migrations_list(): return sorted(migrations) +def _get_migration_by_name(migration_name, with_module=True): + """ + Low-level / "private" function to find a migration by its name + """ + + migrations = tools_migrations_list()["migrations"] + + matches = [ m for m in migrations if m["name"] == migration_name ] + + assert len(matches) == 1, "Unable to find migration with name %s" % migration_name + + migration = matches[0] + + if with_module: + migration["module"] = _get_migration_module(migration) + + return migration + + +def _get_migration_module(migration): + + logger.debug(m18n.n('migrations_loading_migration', + number=migration["number"], + name=migration["name"], + )) + + try: + # this is python builtin method to import a module using a name, we + # use that to import the migration as a python object so we'll be + # able to run it in the next loop + return import_module("yunohost.data_migrations.{file_name}".format(**migration)) + except Exception: + import traceback + traceback.print_exc() + + raise MoulinetteError(errno.EINVAL, m18n.n('migrations_error_failed_to_load_migration', + number=migration["number"], + name=migration["name"], + )) + + class Migration(object): def migrate(self):