[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
This commit is contained in:
Alexandre Aubin 2017-10-08 23:44:07 +02:00 committed by GitHub
parent 0086c8c16a
commit 5ae558edc9
4 changed files with 198 additions and 124 deletions

View file

@ -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...",

View file

@ -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:

View file

@ -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'(?P<unreachable>unreachable)?.*src\s+(?P<src_addr>[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<domain>[^\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<domain>[^\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'))

View file

@ -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