From 5ae558edc915ec3b4284848db8585d3c9c4ca3a0 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sun, 8 Oct 2017 23:44:07 +0200 Subject: [PATCH] [fix] Clean madness related to DynDNS (#353) * Add a function to check if a dyndns provider provides a domain * Add a function to check a domain availability from a dyndns provider * Use new functions in dyndns_subscribe * Replace complete madness by _dyndns_available in dyndns_update * This regex is only used in dyndns_update, no need to define it at the whole file scope level * Try to clarify management of old ipv4/ipv6 ... * Add a nice helper to get both ipv4 and ipv6... * Clarify the dyndns_update madness to get current ipv4/v6 * Remove now useless IPRouteLine * Change default values of old ipv4/v6 to None, otherwise shit gets update just because IPv6 = None * Rearrange thing a bit, move path to global variable * Copypasta typo * Dyndns zone file as a global variable * Use helper to write zone content to file * Adding some debugs/info messages * Move the domain guessing to a dedicated function... * Adding comments.. * Using subprocess check_call instead of os.system for nsupdate * Removing dump of the zone update because it's kinda duplicated from what nsupdate already does * Ignore error if old_ipvx file is non existent * Add docstring for _dyndns_available * Remove parenthesis otherwise this gonna displease Bram-sama :P * Start working on postinstall .. use _dyndns_provides to check if domain is a .nohost.me or .nohost.st * Use _dyndns_available to cleanly check for domain availability * Forget about the weird 'domain split' check... * Clean dyndns stuff in domain.py * Missing argument for string --- locales/en.json | 4 +- src/yunohost/domain.py | 40 ++++--- src/yunohost/dyndns.py | 232 ++++++++++++++++++++++++----------------- src/yunohost/tools.py | 46 +++++--- 4 files changed, 198 insertions(+), 124 deletions(-) diff --git a/locales/en.json b/locales/en.json index 9ef41a3f2..8dac6e799 100644 --- a/locales/en.json +++ b/locales/en.json @@ -158,6 +158,7 @@ "domains_available": "Available domains:", "done": "Done", "downloading": "Downloading...", + "dyndns_could_not_check_provide": "Could not check if {provider:s} can provide {domain:s}.", "dyndns_cron_installed": "The DynDNS cron job has been installed", "dyndns_cron_remove_failed": "Unable to remove the DynDNS cron job", "dyndns_cron_removed": "The DynDNS cron job has been removed", @@ -168,7 +169,8 @@ "dyndns_no_domain_registered": "No domain has been registered with DynDNS", "dyndns_registered": "The DynDNS domain has been registered", "dyndns_registration_failed": "Unable to register DynDNS domain: {error:s}", - "dyndns_unavailable": "Unavailable DynDNS subdomain", + "dyndns_domain_not_provided": "Dyndns provider {provider:s} cannot provide domain {domain:s}.", + "dyndns_unavailable": "Domain {domain:s} is not available.", "executing_command": "Executing command '{command:s}'...", "executing_script": "Executing script '{script:s}'...", "extracting": "Extracting...", diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index f17cc75f6..f828b0973 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -82,28 +82,23 @@ def domain_add(auth, domain, dyndns=False): # DynDNS domain if dyndns: - if len(domain.split('.')) < 3: - raise MoulinetteError(errno.EINVAL, m18n.n('domain_dyndns_invalid')) + # Do not allow to subscribe to multiple dyndns domains... if os.path.exists('/etc/cron.d/yunohost-dyndns'): raise MoulinetteError(errno.EPERM, m18n.n('domain_dyndns_already_subscribed')) - try: - r = requests.get('https://dyndns.yunohost.org/domains', timeout=30) - except requests.ConnectionError as e: - raise MoulinetteError(errno.EHOSTUNREACH, - m18n.n('domain_dyndns_dynette_is_unreachable', error=str(e))) - from yunohost.dyndns import dyndns_subscribe + from yunohost.dyndns import dyndns_subscribe, _dyndns_provides - dyndomains = json.loads(r.text) - dyndomain = '.'.join(domain.split('.')[1:]) - if dyndomain in dyndomains: - dyndns_subscribe(domain=domain) - else: + # Check that this domain can effectively be provided by + # dyndns.yunohost.org. (i.e. is it a nohost.me / noho.st) + if not _dyndns_provides("dyndns.yunohost.org", domain): raise MoulinetteError(errno.EINVAL, m18n.n('domain_dyndns_root_unknown')) + # Actually subscribe + dyndns_subscribe(domain=domain) + try: yunohost.certificate._certificate_install_selfsigned([domain], False) @@ -281,6 +276,25 @@ def get_public_ip(protocol=4): raise MoulinetteError(errno.ENETUNREACH, m18n.n('no_internet_connection')) +def get_public_ips(): + """ + Retrieve the public IPv4 and v6 from ip. and ip6.yunohost.org + + Returns a 2-tuple (ipv4, ipv6). ipv4 or ipv6 can be None if they were not + found. + """ + + try: + ipv4 = get_public_ip() + except: + ipv4 = None + try: + ipv6 = get_public_ip(6) + except: + ipv6 = None + + return (ipv4, ipv6) + def _get_maindomain(): with open('/etc/yunohost/current_host', 'r') as f: diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 621043c3e..55a2be692 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -35,37 +35,72 @@ import subprocess from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_file, write_to_file, rm +from moulinette.utils.network import download_json -from yunohost.domain import get_public_ip, _get_maindomain, _build_dns_conf +from yunohost.domain import get_public_ips, _get_maindomain, _build_dns_conf logger = getActionLogger('yunohost.dyndns') +OLD_IPV4_FILE = '/etc/yunohost/dyndns/old_ip' +OLD_IPV6_FILE = '/etc/yunohost/dyndns/old_ipv6' +DYNDNS_ZONE = '/etc/yunohost/dyndns/zone' -class IPRouteLine(object): - """ Utility class to parse an ip route output line - The output of ip ro is variable and hard to parse completly, it would - require a real parser, not just a regexp, so do minimal parsing here... - - >>> a = IPRouteLine('2001:: from :: via fe80::c23f:fe:1e:cafe dev eth0 src 2000:de:beef:ca:0:fe:1e:cafe metric 0') - >>> a.src_addr - "2000:de:beef:ca:0:fe:1e:cafe" +def _dyndns_provides(provider, domain): """ - regexp = re.compile( - r'(?Punreachable)?.*src\s+(?P[0-9a-f:]+).*') + Checks if a provider provide/manage a given domain. - def __init__(self, line): - self.m = self.regexp.match(line) - if not self.m: - raise ValueError("Not a valid ip route get line") + Keyword arguments: + provider -- The url of the provider, e.g. "dyndns.yunohost.org" + domain -- The full domain that you'd like.. e.g. "foo.nohost.me" - # make regexp group available as object attributes - for k, v in self.m.groupdict().items(): - setattr(self, k, v) + Returns: + True if the provider provide/manages the domain. False otherwise. + """ -re_dyndns_private_key = re.compile( - r'.*/K(?P[^\s\+]+)\.\+157.+\.private$' -) + logger.debug("Checking if %s is managed by %s ..." % (domain, provider)) + + try: + # Dyndomains will be a list of domains supported by the provider + # e.g. [ "nohost.me", "noho.st" ] + dyndomains = download_json('https://%s/domains' % provider, timeout=30) + except MoulinetteError as e: + logger.error(str(e)) + raise MoulinetteError(errno.EIO, + m18n.n('dyndns_could_not_check_provide', + domain=domain, provider=provider)) + + # Extract 'dyndomain' from 'domain', e.g. 'nohost.me' from 'foo.nohost.me' + dyndomain = '.'.join(domain.split('.')[1:]) + + return dyndomain in dyndomains + + +def _dyndns_available(provider, domain): + """ + Checks if a domain is available from a given provider. + + Keyword arguments: + provider -- The url of the provider, e.g. "dyndns.yunohost.org" + domain -- The full domain that you'd like.. e.g. "foo.nohost.me" + + Returns: + True if the domain is avaible, False otherwise. + """ + logger.debug("Checking if domain %s is available on %s ..." + % (domain, provider)) + + try: + r = download_json('https://%s/test/%s' % (provider, domain), + expected_status_code=None) + except MoulinetteError as e: + logger.error(str(e)) + raise MoulinetteError(errno.EIO, + m18n.n('dyndns_could_not_check_available', + domain=domain, provider=provider)) + + return r == u"Domain %s is available" % domain def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None): @@ -81,12 +116,16 @@ def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None if domain is None: domain = _get_maindomain() + # Verify if domain is provided by subscribe_host + if not _dyndns_provides(subscribe_host, domain): + raise MoulinetteError(errno.ENOENT, + m18n.n('dyndns_domain_not_provided', + domain=domain, provider=subscribe_host)) + # Verify if domain is available - try: - if requests.get('https://%s/test/%s' % (subscribe_host, domain)).status_code != 200: - raise MoulinetteError(errno.EEXIST, m18n.n('dyndns_unavailable')) - except requests.ConnectionError: - raise MoulinetteError(errno.ENETUNREACH, m18n.n('no_internet_connection')) + if not _dyndns_available(subscribe_host, domain): + raise MoulinetteError(errno.ENOENT, + m18n.n('dyndns_unavailable', domain=domain)) if key is None: if len(glob.glob('/etc/yunohost/dyndns/*.key')) == 0: @@ -133,73 +172,40 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, ipv6 -- IPv6 address to send """ - # IPv4 + # Get old ipv4/v6 + + old_ipv4, old_ipv6 = (None, None) # (default values) + + if os.path.isfile(OLD_IPV4_FILE): + old_ipv4 = read_file(OLD_IPV4_FILE).rstrip() + + if os.path.isfile(OLD_IPV6_FILE): + old_ipv6 = read_file(OLD_IPV6_FILE).rstrip() + + # Get current IPv4 and IPv6 + (ipv4_, ipv6_) = get_public_ips() + if ipv4 is None: - ipv4 = get_public_ip() + ipv4 = ipv4_ - try: - with open('/etc/yunohost/dyndns/old_ip', 'r') as f: - old_ip = f.readline().rstrip() - except IOError: - old_ip = '0.0.0.0' - - # IPv6 if ipv6 is None: - try: - ip_route_out = subprocess.check_output( - ['ip', 'route', 'get', '2000::']).split('\n') + ipv6 = ipv6_ - if len(ip_route_out) > 0: - route = IPRouteLine(ip_route_out[0]) - if not route.unreachable: - ipv6 = route.src_addr - - except (OSError, ValueError) as e: - # Unlikely case "ip route" does not return status 0 - # or produces unexpected output - raise MoulinetteError(errno.EBADMSG, - "ip route cmd error : {}".format(e)) - - if ipv6 is None: - logger.info(m18n.n('no_ipv6_connectivity')) - - try: - with open('/etc/yunohost/dyndns/old_ipv6', 'r') as f: - old_ipv6 = f.readline().rstrip() - except IOError: - old_ipv6 = '0000:0000:0000:0000:0000:0000:0000:0000' + logger.debug("Old IPv4/v6 are (%s, %s)" % (old_ipv4, old_ipv6)) + logger.debug("Requested IPv4/v6 are (%s, %s)" % (ipv4, ipv6)) # no need to update - if old_ip == ipv4 and old_ipv6 == ipv6: + if old_ipv4 == ipv4 and old_ipv6 == ipv6: + logger.info("No updated needed.") return + else: + logger.info("Updated needed, going on...") + # If domain is not given, try to guess it from keys available... if domain is None: - # Retrieve the first registered domain - for path in glob.iglob('/etc/yunohost/dyndns/K*.private'): - match = re_dyndns_private_key.match(path) - if not match: - continue - _domain = match.group('domain') - - try: - # Check if domain is registered - request_url = 'https://{0}/test/{1}'.format(dyn_host, _domain) - if requests.get(request_url, timeout=30).status_code == 200: - continue - except requests.ConnectionError: - raise MoulinetteError(errno.ENETUNREACH, - m18n.n('no_internet_connection')) - except requests.exceptions.Timeout: - logger.warning("Correction timed out on {}, skip it".format( - request_url)) - domain = _domain - key = path - break - if not domain: - raise MoulinetteError(errno.EINVAL, - m18n.n('dyndns_no_domain_registered')) - - if key is None: + (domain, key) = _guess_current_dyndns_domain(dyn_host) + # If key is not given, pick the first file we find with the domain given + elif key is None: keys = glob.glob('/etc/yunohost/dyndns/K{0}.+*.private'.format(domain)) if not keys: @@ -207,9 +213,12 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, key = keys[0] + # Extract 'host', e.g. 'nohost.me' from 'foo.nohost.me' host = domain.split('.')[1:] host = '.'.join(host) + logger.debug("Building zone update file ...") + lines = [ 'server %s' % dyn_host, 'zone %s' % host, @@ -246,21 +255,27 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, 'send' ] - with open('/etc/yunohost/dyndns/zone', 'w') as zone: - zone.write('\n'.join(lines)) + # Write the actions to do to update to a file, to be able to pass it + # to nsupdate as argument + write_to_file(DYNDNS_ZONE, '\n'.join(lines)) - if os.system('/usr/bin/nsupdate -k %s /etc/yunohost/dyndns/zone' % key) != 0: - os.system('rm -f /etc/yunohost/dyndns/old_ip') - os.system('rm -f /etc/yunohost/dyndns/old_ipv6') + logger.info("Now pushing new conf to DynDNS host...") + + try: + command = ["/usr/bin/nsupdate", "-k", key, DYNDNS_ZONE] + subprocess.check_call(command) + except subprocess.CalledProcessError: + rm(OLD_IPV4_FILE, force=True) # Remove file (ignore if non-existent) + rm(OLD_IPV6_FILE, force=True) # Remove file (ignore if non-existent) raise MoulinetteError(errno.EPERM, m18n.n('dyndns_ip_update_failed')) logger.success(m18n.n('dyndns_ip_updated')) - with open('/etc/yunohost/dyndns/old_ip', 'w') as f: - f.write(ipv4) + + if ipv4 is not None: + write_to_file(OLD_IPV4_FILE, ipv4) if ipv6 is not None: - with open('/etc/yunohost/dyndns/old_ipv6', 'w') as f: - f.write(ipv6) + write_to_file(OLD_IPV6_FILE, ipv6) def dyndns_installcron(): @@ -287,3 +302,34 @@ def dyndns_removecron(): raise MoulinetteError(errno.EIO, m18n.n('dyndns_cron_remove_failed')) logger.success(m18n.n('dyndns_cron_removed')) + + +def _guess_current_dyndns_domain(dyn_host): + """ + This function tries to guess which domain should be updated by + "dyndns_update()" because there's not proper management of the current + dyndns domain :/ (and at the moment the code doesn't support having several + dyndns domain, which is sort of a feature so that people don't abuse the + 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) + if not match: + continue + _domain = match.group('domain') + + # Verify if domain is registered (i.e., if it's available, skip + # current domain beause that's not the one we want to update..) + if _dyndns_available(dyn_host, _domain): + continue + else: + return (_domain, path) + + raise MoulinetteError(errno.EINVAL, + m18n.n('dyndns_no_domain_registered')) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 22ac7894f..042671125 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -45,7 +45,7 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_json, write_to_json from yunohost.app import app_fetchlist, app_info, app_upgrade, app_ssowatconf, app_list, _install_appslist_fetch_cron from yunohost.domain import domain_add, domain_list, get_public_ip, _get_maindomain, _set_maindomain -from yunohost.dyndns import dyndns_subscribe +from yunohost.dyndns import _dyndns_available, _dyndns_provides from yunohost.firewall import firewall_upnp from yunohost.service import service_status, service_regen_conf, service_log from yunohost.monitor import monitor_disk, monitor_system @@ -253,29 +253,41 @@ def tools_postinstall(domain, password, ignore_dyndns=False): password -- YunoHost admin password """ - dyndns = not ignore_dyndns + dyndns_provider = "dyndns.yunohost.org" # Do some checks at first if os.path.isfile('/etc/yunohost/installed'): raise MoulinetteError(errno.EPERM, m18n.n('yunohost_already_installed')) - if len(domain.split('.')) >= 3 and not ignore_dyndns: - try: - r = requests.get('https://dyndns.yunohost.org/domains') - except requests.ConnectionError: - pass - 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: + if not ignore_dyndns: + # Check if yunohost dyndns can handle the given domain + # (i.e. is it a .nohost.me ? a .noho.st ?) + try: + is_nohostme_or_nohost = _dyndns_provides(dyndns_provider, domain) + # If an exception is thrown, most likely we don't have internet + # connectivity or something. Assume that this domain isn't manageable + # and inform the user that we could not contact the dyndns host server. + except: + logger.warning(m18n.n('dyndns_provider_unreachable', + provider=dyndns_provider)) + is_nohostme_or_nohost = False + + # If this is a nohost.me/noho.st, actually check for availability + if is_nohostme_or_nohost: + # (Except if the user explicitly said he/she doesn't care about dyndns) + if ignore_dyndns: dyndns = False + # Check if the domain is available... + elif _dyndns_available(dyndns_provider, domain): + dyndns = True + # If not, abort the postinstall + else: + raise MoulinetteError(errno.EEXIST, + m18n.n('dyndns_unavailable', + domain=domain)) + else: + dyndns = False else: dyndns = False