mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge branch 'unstable' into tsig-sha256
This commit is contained in:
commit
34433f07a4
17 changed files with 416 additions and 171 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -126,9 +126,6 @@ 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
|
||||
Section: misc
|
||||
Priority: optional
|
||||
|
@ -143,7 +140,6 @@ EOF
|
|||
|| 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
|
||||
}
|
||||
|
||||
# Remove fake package and its dependencies
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -59,6 +59,11 @@ 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 '-'
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
45
debian/changelog
vendored
45
debian/changelog
vendored
|
@ -1,3 +1,48 @@
|
|||
yunohost (2.7.5) stable; urgency=low
|
||||
|
||||
(Bumping version number for stable release)
|
||||
|
||||
-- Alexandre Aubin <alex.aubin@mailoo.org> 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 <alex.aubin@mailoo.org> 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 <alex.aubin@mailoo.org> Thu, 12 Oct 2017 16:18:51 -0400
|
||||
|
||||
yunohost (2.7.2) stable; urgency=low
|
||||
|
||||
* [mod] pep8
|
||||
|
|
|
@ -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...",
|
||||
|
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
@ -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 "/"
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'(?P<unreachable>unreachable)?.*src\s+(?P<src_addr>[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<domain>[^\s\+]+)\.\+157.+\.private$'
|
||||
)
|
||||
|
||||
|
||||
RE_DYNDNS_PRIVATE_KEY_SHA512 = re.compile(
|
||||
r'.*/K(?P<domain>[^\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()
|
||||
|
||||
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')
|
||||
|
||||
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))
|
||||
ipv4 = ipv4_
|
||||
|
||||
if ipv6 is None:
|
||||
logger.info(m18n.n('no_ipv6_connectivity'))
|
||||
ipv6 = ipv6_
|
||||
|
||||
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'))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,27 +253,39 @@ 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:
|
||||
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'))
|
||||
m18n.n('dyndns_unavailable',
|
||||
domain=domain))
|
||||
else:
|
||||
dyndns = False
|
||||
else:
|
||||
|
|
8
src/yunohost/vendor/acme_tiny/acme_tiny.py
vendored
8
src/yunohost/vendor/acme_tiny/acme_tiny.py
vendored
|
@ -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!")
|
||||
|
|
Loading…
Add table
Reference in a new issue