diff --git a/data/helpers.d/backend b/data/helpers.d/backend index c54e82754..c32ef02ac 100644 --- a/data/helpers.d/backend +++ b/data/helpers.d/backend @@ -6,7 +6,7 @@ # # If no argument provided, a standard directory will be use. /var/log/${app} # You can provide a path with the directory only or with the logfile. -# /parentdir/logdir/ +# /parentdir/logdir # /parentdir/logdir/logfile.log # # It's possible to use this helper several times, each config will be added to the same logrotate config file. @@ -24,7 +24,7 @@ ynh_use_logrotate () { if [ "$(echo ${1##*.})" == "log" ]; then # Keep only the extension to check if it's a logfile logfile=$1 # In this case, focus logrotate on the logfile else - logfile=$1/.log # Else, uses the directory and all logfile into it. + logfile=$1/*.log # Else, uses the directory and all logfile into it. fi else logfile="/var/log/${app}/*.log" # Without argument, use a defaut directory in /var/log @@ -123,6 +123,9 @@ ynh_add_nginx_config () { # To avoid a break by set -u, use a void substitution ${var:-}. If the variable is not set, it's simply set with an empty variable. # Substitute in a nginx config file only if the variable is not empty if test -n "${path_url:-}"; then + # path_url_slash_less is path_url, or a blank value if path_url is only '/' + path_url_slash_less=${path_url%/} + ynh_replace_string "__PATH__/" "$path_url_slash_less/" "$finalnginxconf" ynh_replace_string "__PATH__" "$path_url" "$finalnginxconf" fi if test -n "${domain:-}"; then diff --git a/data/helpers.d/mysql b/data/helpers.d/mysql index 1c0ece114..42c204f95 100644 --- a/data/helpers.d/mysql +++ b/data/helpers.d/mysql @@ -73,7 +73,7 @@ ynh_mysql_drop_db() { # | arg: db - the database name to dump # | ret: the mysqldump output ynh_mysql_dump_db() { - mysqldump -u "root" -p"$(sudo cat $MYSQL_ROOT_PWD_FILE)" "$1" + mysqldump -u "root" -p"$(sudo cat $MYSQL_ROOT_PWD_FILE)" --single-transaction --skip-dump-date "$1" } # Create a user diff --git a/data/helpers.d/package b/data/helpers.d/package index 36777aa52..f28691579 100644 --- a/data/helpers.d/package +++ b/data/helpers.d/package @@ -126,10 +126,7 @@ ynh_install_app_dependencies () { version=$(grep '\"version\": ' "$manifest_path" | cut -d '"' -f 4) # Retrieve the version number in the manifest file. dep_app=${app//_/-} # Replace all '_' by '-' - if ynh_package_is_installed "${dep_app}-ynh-deps"; then - echo "A package named ${dep_app}-ynh-deps is already installed" >&2 - else - cat > /tmp/${dep_app}-ynh-deps.control << EOF # Make a control file for equivs-build + cat > /tmp/${dep_app}-ynh-deps.control << EOF # Make a control file for equivs-build Section: misc Priority: optional Package: ${dep_app}-ynh-deps @@ -139,11 +136,10 @@ Architecture: all Description: Fake package for ${app} (YunoHost app) dependencies This meta-package is only responsible of installing its dependencies. EOF - ynh_package_install_from_equivs /tmp/${dep_app}-ynh-deps.control \ - || ynh_die "Unable to install dependencies" # Install the fake package and its dependencies - rm /tmp/${dep_app}-ynh-deps.control - ynh_app_setting_set $app apt_dependencies $dependencies - fi + ynh_package_install_from_equivs /tmp/${dep_app}-ynh-deps.control \ + || ynh_die "Unable to install dependencies" # Install the fake package and its dependencies + rm /tmp/${dep_app}-ynh-deps.control + ynh_app_setting_set $app apt_dependencies $dependencies } # Remove fake package and its dependencies diff --git a/data/helpers.d/string b/data/helpers.d/string index 772681fb9..13399ffe0 100644 --- a/data/helpers.d/string +++ b/data/helpers.d/string @@ -10,17 +10,50 @@ ynh_string_random() { | sed -n 's/\(.\{'"${1:-24}"'\}\).*/\1/p' } -# Substitute/replace a string by another in a file +# Substitute/replace a string (or expression) by another in a file # # usage: ynh_replace_string match_string replace_string target_file # | arg: match_string - String to be searched and replaced in the file # | arg: replace_string - String that will replace matches # | arg: target_file - File in which the string will be replaced. +# +# As this helper is based on sed command, regular expressions and +# references to sub-expressions can be used +# (see sed manual page for more information) ynh_replace_string () { - delimit=@ - match_string=${1//${delimit}/"\\${delimit}"} # Escape the delimiter if it's in the string. - replace_string=${2//${delimit}/"\\${delimit}"} - workfile=$3 + local delimit=@ + local match_string=$1 + local replace_string=$2 + local workfile=$3 + + # Escape the delimiter if it's in the string. + match_string=${match_string//${delimit}/"\\${delimit}"} + replace_string=${replace_string//${delimit}/"\\${delimit}"} sudo sed --in-place "s${delimit}${match_string}${delimit}${replace_string}${delimit}g" "$workfile" } + +# Substitute/replace a special string by another in a file +# +# usage: ynh_replace_special_string match_string replace_string target_file +# | arg: match_string - String to be searched and replaced in the file +# | arg: replace_string - String that will replace matches +# | arg: target_file - File in which the string will be replaced. +# +# This helper will use ynh_replace_string, but as you can use special +# characters, you can't use some regular expressions and sub-expressions. +ynh_replace_special_string () { + local match_string=$1 + local replace_string=$2 + local workfile=$3 + + # Escape any backslash to preserve them as simple backslash. + match_string=${match_string//\\/"\\\\"} + replace_string=${replace_string//\\/"\\\\"} + + # Escape the & character, who has a special function in sed. + match_string=${match_string//&/"\&"} + replace_string=${replace_string//&/"\&"} + + ynh_replace_string "$match_string" "$replace_string" "$workfile" +} diff --git a/data/helpers.d/utils b/data/helpers.d/utils index 44c679471..2cb18c5c0 100644 --- a/data/helpers.d/utils +++ b/data/helpers.d/utils @@ -59,14 +59,19 @@ ynh_restore_upgradebackup () { # ynh_abort_if_errors # ynh_backup_before_upgrade () { + if [ ! -e "/etc/yunohost/apps/$app/scripts/backup" ] + then + echo "This app doesn't have any backup script." >&2 + return + fi backup_number=1 old_backup_number=2 app_bck=${app//_/-} # Replace all '_' by '-' - - # Check if a backup already exists with the prefix 1 + + # Check if a backup already exists with the prefix 1 if sudo yunohost backup list | grep -q $app_bck-pre-upgrade1 - then - # Prefix becomes 2 to preserve the previous backup + then + # Prefix becomes 2 to preserve the previous backup backup_number=2 old_backup_number=1 fi @@ -74,7 +79,7 @@ ynh_backup_before_upgrade () { # Create backup sudo yunohost backup create --ignore-system --apps $app --name $app_bck-pre-upgrade$backup_number if [ "$?" -eq 0 ] - then + then # If the backup succeeded, remove the previous backup if sudo yunohost backup list | grep -q $app_bck-pre-upgrade$old_backup_number then diff --git a/data/templates/yunohost/firewall.yml b/data/templates/yunohost/firewall.yml index df5b0fe88..201a39092 100644 --- a/data/templates/yunohost/firewall.yml +++ b/data/templates/yunohost/firewall.yml @@ -1,7 +1,7 @@ uPnP: enabled: false - TCP: [22, 25, 53, 80, 443, 465, 587, 993, 5222, 5269] - UDP: [53] + TCP: [22, 25, 80, 443, 465, 587, 993, 5222, 5269] + UDP: [] ipv4: TCP: [22, 25, 53, 80, 443, 465, 587, 993, 5222, 5269] UDP: [53, 5353] diff --git a/data/templates/yunohost/services.yml b/data/templates/yunohost/services.yml index 514cf5258..fb8c076f9 100644 --- a/data/templates/yunohost/services.yml +++ b/data/templates/yunohost/services.yml @@ -47,6 +47,7 @@ yunohost-api: log: /var/log/yunohost/yunohost-api.log yunohost-firewall: status: service + need_lock: true nslcd: status: service log: /var/log/syslog diff --git a/debian/changelog b/debian/changelog index 312c626e3..bd7430cb7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,48 @@ +yunohost (2.7.5) stable; urgency=low + + (Bumping version number for stable release) + + -- Alexandre Aubin Sat, 02 Dec 2017 12:38:00 -0500 + +yunohost (2.7.4) testing; urgency=low + + * [fix] Update acme-tiny as LE updated its ToS (#386) + * [fix] Fix helper for old apps without backup script (#388) + * [mod] Remove port 53 from UPnP (but keep it open on local network) (#362) + * [i18n] Improve French translation + +Thanks to all contributors <3 ! (jibec, Moul, Maniack, Aleks) + + -- Alexandre Aubin Tue, 28 Nov 2017 19:01:41 -0500 + +yunohost (2.7.3) testing; urgency=low + + Major changes : + + * [fix] Refactor/clean madness related to DynDNS (#353) + * [i18n] Improve french translation (#355) + * [fix] Use cryptorandom to generate password (#358) + * [enh] Support for single app upgrade from the webadmin (#359) + * [enh] Be able to give lock to son processes detached by systemctl (#367) + * [enh] Make MySQL dumps with a single transaction to ensure backup consistency (#370) + + Misc fixes/improvements : + + * [enh] Escape some special character in ynh_replace_string (#354) + * [fix] Allow dash at the beginning of app settings value (#357) + * [enh] Handle root path in nginx conf (#361) + * [enh] Add debugging in ldap init (#365) + * [fix] Fix app_upgrade_string with missing key + * [fix] Fix for change_url path normalizing with root url (#368) + * [fix] Missing 'ask_path' string (#369) + * [enh] Remove date from sql dump (#371) + * [fix] Fix unicode error in backup/restore (#375) + * [fix] Fix an error in ynh_replace_string (#379) + +Thanks to all contributors <3 ! (Bram, Maniack C, ljf, JimboJoe, ariasuni, Jibec, Aleks) + + -- Alexandre Aubin Thu, 12 Oct 2017 16:18:51 -0400 + yunohost (2.7.2) stable; urgency=low * [mod] pep8 diff --git a/locales/en.json b/locales/en.json index 3a8c2b13c..b2d7c847a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -55,6 +55,7 @@ "ask_main_domain": "Main domain", "ask_new_admin_password": "New administration password", "ask_password": "Password", + "ask_path": "Path", "backup_abstract_method": "This backup method hasn't yet been implemented", "backup_action_required": "You must specify something to save", "backup_app_failed": "Unable to back up the app '{app:s}'", @@ -157,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", @@ -167,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/locales/fr.json b/locales/fr.json index 86df7b10d..8baccbd70 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,7 +1,7 @@ { "action_invalid": "Action « {action:s} » incorrecte", "admin_password": "Mot de passe d'administration", - "admin_password_change_failed": "Impossible de modifier le mot de passe d'administration", + "admin_password_change_failed": "Impossible de changer le mot de passe", "admin_password_changed": "Le mot de passe d'administration a été modifié", "app_already_installed": "{app:s} est déjà installé", "app_argument_choice_invalid": "Choix invalide pour le paramètre « {name:s} », il doit être l'un de {choices:s}", @@ -14,7 +14,7 @@ "app_install_files_invalid": "Fichiers d'installation incorrects", "app_location_already_used": "Une application est déjà installée à cet emplacement", "app_location_install_failed": "Impossible d'installer l'application à cet emplacement", - "app_manifest_invalid": "Manifeste d'application incorrect", + "app_manifest_invalid": "Manifeste d'application incorrect : {error}", "app_no_upgrade": "Aucune application à mettre à jour", "app_not_correctly_installed": "{app:s} semble être mal installé", "app_not_installed": "{app:s} n'est pas installé", @@ -98,7 +98,7 @@ "dyndns_no_domain_registered": "Aucun domaine n'a été enregistré avec DynDNS", "dyndns_registered": "Le domaine DynDNS a été enregistré", "dyndns_registration_failed": "Impossible d'enregistrer le domaine DynDNS : {error:s}", - "dyndns_unavailable": "Sous-domaine DynDNS indisponible", + "dyndns_unavailable": "Le domaine {domain:s} est indisponible.", "executing_command": "Exécution de la commande « {command:s} »...", "executing_script": "Exécution du script « {script:s} »...", "extracting": "Extraction...", @@ -320,7 +320,7 @@ "backup_archive_system_part_not_available": "La partie « {part:s} » du système n’est pas disponible dans cette sauvegarde", "backup_archive_mount_failed": "Le montage de l’archive de sauvegarde a échoué", "backup_archive_writing_error": "Impossible d’ajouter les fichiers à la sauvegarde dans l’archive compressée", - "backup_ask_for_copying_if_needed": "Votre système ne prend pas complètement en charge la méthode rapide d’organisation des fichiers dans l’archive, voulez-vous les organiser en copiant {size:s} Mio ?", + "backup_ask_for_copying_if_needed": "Certains fichiers n’ont pas pu être préparés pour être sauvegardée en utilisant la méthode qui évite de temporairement gaspiller de l’espace sur le système. Pour mener la sauvegarde, {size:s} Mio doivent être temporairement utilisés. Acceptez-vous ?", "backup_borg_not_implemented": "La méthode de sauvegarde Bord n’est pas encore implémentée", "backup_cant_mount_uncompress_archive": "Impossible de monter en lecture seule le dossier de l’archive décompressée", "backup_copying_to_organize_the_archive": "Copie de {size:s} Mio pour organiser l’archive", @@ -344,5 +344,28 @@ "restore_mounting_archive": "Montage de l’archive dans « {path:s} »", "restore_may_be_not_enough_disk_space": "Votre système semble ne pas avoir suffisamment d’espace disponible (libre : {free_space:d} octets, nécessaire : {needed_space:d} octets, marge de sécurité : {margin:d} octets)", "restore_not_enough_disk_space": "Espace disponible insuffisant (libre : {free_space:d} octets, nécessaire : {needed_space:d} octets, marge de sécurité : {margin:d} octets)", - "restore_system_part_failed": "Impossible de restaurer la partie « {part:s} » du système" + "restore_system_part_failed": "Impossible de restaurer la partie « {part:s} » du système", + "backup_couldnt_bind": "Impossible de lier {src:s} avec {dest:s}.", + "domain_dns_conf_is_just_a_recommendation": "Cette page montre la configuration *recommandée*. Elle ne configure *pas* le DNS pour vous. Il est de votre responsabilité que de configurer votre zone DNS chez votre registrar DNS avec cette recommandation.", + "domain_dyndns_dynette_is_unreachable": "Impossible de contacter la dynette YunoHost, soit YunoHost n’est pas correctement connecté à internet ou alors le serveur de dynette est arrêté. Erreur : {error}", + "migrations_backward": "Migration en arrière.", + "migrations_bad_value_for_target": "Nombre invalide pour le paramètre « target », les numéros de migration sont ou {}", + "migrations_cant_reach_migration_file": "Impossible d’accéder aux fichiers de migrations avec le chemin %s", + "migrations_current_target": "La cible de migration est {}", + "migrations_error_failed_to_load_migration": "ERREUR : échec du chargement de migration {number} {name}", + "migrations_forward": "Migration en avant", + "migrations_loading_migration": "Chargement de la migration {number} {name}…", + "migrations_migration_has_failed": "La migration {number} {name} a échoué avec l’exception {exception}, annulation", + "migrations_no_migrations_to_run": "Aucune migration à lancer", + "migrations_show_currently_running_migration": "Application de la migration {number} {name}…", + "migrations_show_last_migration": "La dernière migration appliquée est {}", + "migrations_skip_migration": "Omission de la migration {number} {name}…", + "server_shutdown": "Le serveur sera éteint", + "server_shutdown_confirm": "Le serveur immédiatement être éteint, le voulez-vous vraiment ? [{answers:s}]", + "server_reboot": "Le serveur va redémarrer", + "server_reboot_confirm": "Le serveur va redémarrer immédiatement, le voulez-vous vraiment ? [{answers:s}]", + "app_upgrade_some_app_failed": "Impossible de mettre à jour certaines applications", + "ask_path": "Chemin", + "dyndns_could_not_check_provide": "Impossible de vérifier si {provider:s} peut fournir {domain:s}.", + "dyndns_domain_not_provided": "Le fournisseur Dyndns {provider:s} ne peut pas fournir le domaine {domain:s}." } diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 84092580f..403e76cc4 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -38,7 +38,7 @@ import pwd import grp from collections import OrderedDict -from moulinette import msignals, m18n +from moulinette import msignals, m18n, msettings from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger @@ -445,8 +445,9 @@ def app_change_url(auth, app, domain, path): # Normalize path and domain format domain = domain.strip().lower() - old_path = '/' + old_path.strip("/").strip() + '/' - path = '/' + path.strip("/").strip() + '/' + + old_path = normalize_url_path(old_path) + path = normalize_url_path(path) if (domain, path) == (old_domain, old_path): raise MoulinetteError(errno.EINVAL, m18n.n("app_change_url_identical_domains", domain=domain, path=path)) @@ -531,6 +532,9 @@ def app_upgrade(auth, app=[], url=None, file=None): """ from yunohost.hook import hook_add, hook_remove, hook_exec + # Retrieve interface + is_api = msettings.get('interface') == 'api' + try: app_list() except MoulinetteError: @@ -632,6 +636,10 @@ def app_upgrade(auth, app=[], url=None, file=None): logger.success(m18n.n('upgrade_complete')) + # Return API logs if it is an API call + if is_api: + return {"log": service_log('yunohost-api', number="100").values()[0]} + def app_install(auth, app, label=None, args=None, no_remove_on_failure=False): """ @@ -2105,3 +2113,10 @@ def random_password(length=8): char_set = string.ascii_uppercase + string.digits + string.ascii_lowercase return ''.join([random.SystemRandom().choice(char_set) for x in range(length)]) + + +def normalize_url_path(url_path): + if url_path.strip("/").strip(): + return '/' + url_path.strip("/").strip() + '/' + + return "/" diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index d794a2fca..def7fb27b 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -166,11 +166,11 @@ class BackupRestoreTargetsManager(object): or (exclude and isinstance(exclude, list) and not include) if include: - return [target for target in self.targets[category] + return [target.encode("Utf-8") for target in self.targets[category] if self.results[category][target] in include] if exclude: - return [target for target in self.targets[category] + return [target.encode("Utf-8") for target in self.targets[category] if self.results[category][target] not in exclude] 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 d65f5f7ed..c11d0a067 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -36,45 +36,82 @@ 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') - -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" - """ - regexp = re.compile( - r'(?Punreachable)?.*src\s+(?P[0-9a-f:]+).*') - - def __init__(self, line): - self.m = self.regexp.match(line) - if not self.m: - raise ValueError("Not a valid ip route get line") - - # make regexp group available as object attributes - for k, v in self.m.groupdict().items(): - setattr(self, k, v) - +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): + """ + Checks if a provider provide/manage a given domain. + + 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 provider provide/manages the domain. False otherwise. + """ + + 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): """ Subscribe to a DynDNS service @@ -88,12 +125,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), timeout=30).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: @@ -141,75 +182,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_SHA512.match(path) - if not match: - match = RE_DYNDNS_PRIVATE_KEY_MD5.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: @@ -221,9 +227,12 @@ def dyndns_update(dyn_host="dyndns.yunohost.org", domain=None, key=None, if "+157" in key: key = _migrate_from_md5_tsig_to_sha512_tsig(key, domain, dyn_host) + # 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, @@ -260,21 +269,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 _migrate_from_md5_tsig_to_sha512_tsig(private_key_path, domain, dyn_host): @@ -356,3 +371,32 @@ 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...) + """ + + # Retrieve the first registered domain + for path in glob.iglob('/etc/yunohost/dyndns/K*.private'): + match = RE_DYNDNS_PRIVATE_KEY_MD5.match(path) + if not match: + 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 + # 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/service.py b/src/yunohost/service.py index 2ea953873..5401a1fab 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -39,10 +39,10 @@ from moulinette.utils import log, filesystem from yunohost.hook import hook_callback - BASE_CONF_PATH = '/home/yunohost.conf' BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, 'backup') PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, 'pending') +MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock" logger = log.getActionLogger('yunohost.service') @@ -493,7 +493,8 @@ def _run_service_command(action, service): service -- Service name """ - if service not in _get_services().keys(): + services = _get_services() + if service not in services.keys(): raise MoulinetteError(errno.EINVAL, m18n.n('service_unknown', service=service)) cmd = None @@ -505,8 +506,23 @@ def _run_service_command(action, service): else: raise ValueError("Unknown action '%s'" % action) + need_lock = (services[service].get('need_lock') or False) \ + and action in ['start', 'stop', 'restart', 'reload'] + try: - ret = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) + # Launch the command + logger.debug("Running '%s'" % cmd) + p = subprocess.Popen(cmd.split(), stderr=subprocess.STDOUT) + # If this command needs a lock (because the service uses yunohost + # commands inside), find the PID and add a lock for it + if need_lock: + PID = _give_lock(action, service, p) + # Wait for the command to complete + p.communicate() + # Remove the lock if one was given + if need_lock and PID != 0: + _remove_lock(PID) + except subprocess.CalledProcessError as e: # TODO: Log output? logger.warning(m18n.n('service_cmd_exec_failed', command=' '.join(e.cmd))) @@ -514,6 +530,41 @@ def _run_service_command(action, service): return True +def _give_lock(action, service, p): + + # Depending of the action, systemctl calls the PID differently :/ + if action == "start" or action == "restart": + systemctl_PID_name = "MainPID" + else: + systemctl_PID_name = "ControlPID" + + cmd_get_son_PID ="systemctl show %s -p %s" % (service, systemctl_PID_name) + son_PID = 0 + # As long as we did not found the PID and that the command is still running + while son_PID == 0 and p.poll() == None: + # Call systemctl to get the PID + # Output of the command is e.g. ControlPID=1234 + son_PID = subprocess.check_output(cmd_get_son_PID.split()) \ + .strip().split("=")[1] + son_PID = int(son_PID) + time.sleep(1) + + # If we found a PID + if son_PID != 0: + # Append the PID to the lock file + logger.debug("Giving a lock to PID %s for service %s !" + % (str(son_PID), service)) + filesystem.append_to_file(MOULINETTE_LOCK, "\n%s" % str(son_PID)) + + return son_PID + +def _remove_lock(PID_to_remove): + + PIDs = filesystem.read_file(MOULINETTE_LOCK).split("\n") + PIDs_to_keep = [ PID for PID in PIDs if int(PID) != PID_to_remove ] + filesystem.write_to_file(MOULINETTE_LOCK, '\n'.join(PIDs_to_keep)) + + def _get_services(): """ Get a dict of managed services with their parameters 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 diff --git a/src/yunohost/vendor/acme_tiny/acme_tiny.py b/src/yunohost/vendor/acme_tiny/acme_tiny.py index d0ba33d1e..6fd8558d5 100644 --- a/src/yunohost/vendor/acme_tiny/acme_tiny.py +++ b/src/yunohost/vendor/acme_tiny/acme_tiny.py @@ -39,7 +39,7 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): 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() + 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 = { @@ -82,10 +82,10 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): 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')) + common_name = re.search(r"Subject:.*? CN\s?=\s?([^\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) + 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:"): @@ -95,7 +95,7 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): 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", + "agreement": json.loads(urlopen(CA + "/directory").read().decode('utf8'))['meta']['terms-of-service'], }) if code == 201: log.info("Registered!")