Merge pull request #1715 from YunoHost/dyndns

enh: Handle dyndns subscribe/unsubscribe in web admin
This commit is contained in:
Alexandre Aubin 2023-09-29 21:35:20 +02:00 committed by GitHub
commit c6633873ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 109 additions and 37 deletions

View file

@ -91,6 +91,7 @@
"ask_new_path": "New path", "ask_new_path": "New path",
"ask_password": "Password", "ask_password": "Password",
"ask_dyndns_recovery_password_explain": "Please pick a recovery password for your DynDNS domain, in case you need to reset it later.", "ask_dyndns_recovery_password_explain": "Please pick a recovery password for your DynDNS domain, in case you need to reset it later.",
"ask_dyndns_recovery_password_explain_unavailable": "This DynDNS domain is already registered. If you are the person who originally registered this domain, you may enter the recovery password to reclaim this domain.",
"ask_dyndns_recovery_password": "DynDNS recovery password", "ask_dyndns_recovery_password": "DynDNS recovery password",
"ask_dyndns_recovery_password_explain_during_unsubscribe": "Please enter the recovery password for this DynDNS domain.", "ask_dyndns_recovery_password_explain_during_unsubscribe": "Please enter the recovery password for this DynDNS domain.",
"ask_user_domain": "Domain to use for the user's email address and XMPP account", "ask_user_domain": "Domain to use for the user's email address and XMPP account",
@ -406,6 +407,7 @@
"dyndns_provider_unreachable": "Unable to reach DynDNS provider {provider}: either your YunoHost is not correctly connected to the internet or the dynette server is down.", "dyndns_provider_unreachable": "Unable to reach DynDNS provider {provider}: either your YunoHost is not correctly connected to the internet or the dynette server is down.",
"dyndns_subscribed": "DynDNS domain subscribed", "dyndns_subscribed": "DynDNS domain subscribed",
"dyndns_subscribe_failed": "Could not subscribe DynDNS domain: {error}", "dyndns_subscribe_failed": "Could not subscribe DynDNS domain: {error}",
"dyndns_too_many_requests": "YunoHost's dyndns service received too many requests from you, wait 1 hour or so before trying again.",
"dyndns_unsubscribe_failed": "Could not unsubscribe DynDNS domain: {error}", "dyndns_unsubscribe_failed": "Could not unsubscribe DynDNS domain: {error}",
"dyndns_unsubscribed": "DynDNS domain unsubscribed", "dyndns_unsubscribed": "DynDNS domain unsubscribed",
"dyndns_unsubscribe_denied": "Failed to unsubscribe domain: invalid credentials", "dyndns_unsubscribe_denied": "Failed to unsubscribe domain: invalid credentials",

View file

@ -545,6 +545,9 @@ def _get_registrar_config_section(domain):
"value": "yunohost", "value": "yunohost",
} }
) )
registrar_infos["recovery_password"] = OrderedDict(
{"type": "password", "ask": m18n.n("ask_dyndns_recovery_password"), "default": ""}
)
return OrderedDict(registrar_infos) return OrderedDict(registrar_infos)
elif is_special_use_tld(dns_zone): elif is_special_use_tld(dns_zone):
registrar_infos["registrar"] = OrderedDict( registrar_infos["registrar"] = OrderedDict(

View file

@ -342,6 +342,7 @@ def domain_remove(
dyndns_recovery_password -- Recovery password used at the creation of the DynDNS domain dyndns_recovery_password -- Recovery password used at the creation of the DynDNS domain
ignore_dyndns -- If we just remove the DynDNS domain, without unsubscribing ignore_dyndns -- If we just remove the DynDNS domain, without unsubscribing
""" """
import glob
from yunohost.hook import hook_callback from yunohost.hook import hook_callback
from yunohost.app import app_ssowatconf, app_info, app_remove from yunohost.app import app_ssowatconf, app_info, app_remove
from yunohost.utils.ldap import _get_ldap_interface from yunohost.utils.ldap import _get_ldap_interface
@ -427,14 +428,20 @@ def domain_remove(
global domain_list_cache global domain_list_cache
domain_list_cache = [] domain_list_cache = []
stuff_to_delete = [ # If a password is provided, delete the DynDNS record
f"/etc/yunohost/certs/{domain}", if dyndns:
f"/etc/yunohost/dyndns/K{domain}.+*", try:
f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", # Actually unsubscribe
] domain_dyndns_unsubscribe(
domain=domain, recovery_password=dyndns_recovery_password
)
except Exception as e:
logger.warning(str(e))
for stuff in stuff_to_delete: rm(f"/etc/yunohost/certs/{domain}", force=True, recursive=True)
rm(stuff, force=True, recursive=True) for key_file in glob.glob(f"/etc/yunohost/dyndns/K{domain}.+*"):
rm(key_file, force=True)
rm(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", force=True)
# Sometime we have weird issues with the regenconf where some files # Sometime we have weird issues with the regenconf where some files
# appears as manually modified even though they weren't touched ... # appears as manually modified even though they weren't touched ...
@ -462,13 +469,6 @@ def domain_remove(
hook_callback("post_domain_remove", args=[domain]) hook_callback("post_domain_remove", args=[domain])
# If a password is provided, delete the DynDNS record
if dyndns:
# Actually unsubscribe
domain_dyndns_unsubscribe(
domain=domain, recovery_password=dyndns_recovery_password
)
logger.success(m18n.n("domain_deleted")) logger.success(m18n.n("domain_deleted"))
@ -707,6 +707,19 @@ class DomainConfigPanel(ConfigPanel):
domain=self.entity, domain=self.entity,
other_app=app_map(raw=True)[self.entity]["/"]["id"], other_app=app_map(raw=True)[self.entity]["/"]["id"],
) )
if (
"recovery_password" in self.new_values
and self.new_values["recovery_password"]
):
domain_dyndns_set_recovery_password(
self.entity, self.new_values["recovery_password"]
)
# Do not save password in yaml settings
if "recovery_password" in self.values:
del self.values["recovery_password"]
if "recovery_password" in self.new_values:
del self.new_values["recovery_password"]
assert "recovery_password" not in self.future_values
super()._apply() super()._apply()

View file

@ -27,7 +27,6 @@ from moulinette import Moulinette, m18n
from moulinette.core import MoulinetteError from moulinette.core import MoulinetteError
from moulinette.utils.log import getActionLogger from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import write_to_file, rm, chown, chmod from moulinette.utils.filesystem import write_to_file, rm, chown, chmod
from moulinette.utils.network import download_json
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.domain import _get_maindomain from yunohost.domain import _get_maindomain
@ -63,19 +62,28 @@ def _dyndns_available(domain):
Returns: Returns:
True if the domain is available, False otherwise. True if the domain is available, False otherwise.
""" """
import requests # lazy loading this module for performance reasons
logger.debug(f"Checking if domain {domain} is available on {DYNDNS_PROVIDER} ...") logger.debug(f"Checking if domain {domain} is available on {DYNDNS_PROVIDER} ...")
try: try:
r = download_json( r = requests.get(f"https://{DYNDNS_PROVIDER}/test/{domain}", timeout=30)
f"https://{DYNDNS_PROVIDER}/test/{domain}", expected_status_code=None
)
except MoulinetteError as e: except MoulinetteError as e:
logger.error(str(e)) logger.error(str(e))
raise YunohostError( raise YunohostError(
"dyndns_could_not_check_available", domain=domain, provider=DYNDNS_PROVIDER "dyndns_could_not_check_available", domain=domain, provider=DYNDNS_PROVIDER
) )
return r == f"Domain {domain} is available" if r.status_code == 200:
return r.text.strip('"') == f"Domain {domain} is available"
elif r.status_code == 409:
return False
elif r.status_code == 429:
raise YunohostValidationError("dyndns_too_many_requests")
else:
raise YunohostError(
"dyndns_could_not_check_available", domain=domain, provider=DYNDNS_PROVIDER
)
@is_unit_operation(exclude=["recovery_password"]) @is_unit_operation(exclude=["recovery_password"])
@ -94,14 +102,26 @@ def dyndns_subscribe(operation_logger, domain=None, recovery_password=None):
"dyndns_domain_not_provided", domain=domain, provider=DYNDNS_PROVIDER "dyndns_domain_not_provided", domain=domain, provider=DYNDNS_PROVIDER
) )
# Verify if domain is available
if not _dyndns_available(domain):
raise YunohostValidationError("dyndns_unavailable", domain=domain)
# Check adding another dyndns domain is still allowed # Check adding another dyndns domain is still allowed
if not is_subscribing_allowed(): if not is_subscribing_allowed():
raise YunohostValidationError("domain_dyndns_already_subscribed") raise YunohostValidationError("domain_dyndns_already_subscribed")
# Verify if domain is available
if not _dyndns_available(domain):
# Prompt for a password if running in CLI and no password provided
if not recovery_password and Moulinette.interface.type == "cli":
logger.warning(m18n.n("ask_dyndns_recovery_password_explain_unavailable"))
recovery_password = Moulinette.prompt(
m18n.n("ask_dyndns_recovery_password"), is_password=True
)
if recovery_password:
# Try to unsubscribe the domain so it can be subscribed again
# If successful, it will be resubscribed with the same recovery password
dyndns_unsubscribe(domain=domain, recovery_password=recovery_password)
else:
raise YunohostValidationError("dyndns_unavailable", domain=domain)
# Prompt for a password if running in CLI and no password provided # Prompt for a password if running in CLI and no password provided
if not recovery_password and Moulinette.interface.type == "cli": if not recovery_password and Moulinette.interface.type == "cli":
logger.warning(m18n.n("ask_dyndns_recovery_password_explain")) logger.warning(m18n.n("ask_dyndns_recovery_password_explain"))
@ -252,9 +272,11 @@ def dyndns_unsubscribe(operation_logger, domain, recovery_password=None):
# in /etc/yunohost/dyndns # in /etc/yunohost/dyndns
regen_conf(["yunohost"]) regen_conf(["yunohost"])
elif r.status_code == 403: elif r.status_code == 403:
raise YunohostError("dyndns_unsubscribe_denied") raise YunohostValidationError("dyndns_unsubscribe_denied")
elif r.status_code == 409: elif r.status_code == 409:
raise YunohostError("dyndns_unsubscribe_already_unsubscribed") raise YunohostValidationError("dyndns_unsubscribe_already_unsubscribed")
elif r.status_code == 429:
raise YunohostValidationError("dyndns_too_many_requests")
else: else:
raise YunohostError( raise YunohostError(
"dyndns_unsubscribe_failed", "dyndns_unsubscribe_failed",

View file

@ -1,8 +1,10 @@
import pytest import pytest
import os import os
import time
import random import random
from mock import patch
from moulinette import Moulinette
from moulinette.core import MoulinetteError from moulinette.core import MoulinetteError
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
@ -75,11 +77,41 @@ def test_domain_add():
assert TEST_DOMAINS[2] in domain_list()["domains"] assert TEST_DOMAINS[2] in domain_list()["domains"]
def test_domain_add_subscribe(): def test_domain_add_and_remove_dyndns():
time.sleep(35) # Dynette blocks requests that happen too frequently # Devs: if you get `too_many_request` errors, ask the team to add your IP to the rate limit excempt
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"] assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
domain_add(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD) domain_add(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD)
assert TEST_DYNDNS_DOMAIN in domain_list()["domains"] assert TEST_DYNDNS_DOMAIN in domain_list()["domains"]
domain_remove(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD)
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
def test_domain_dyndns_recovery():
# Devs: if you get `too_many_request` errors, ask the team to add your IP to the rate limit excempt
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
# mocked as API call to avoid CLI prompts
with patch.object(Moulinette.interface, "type", "api"):
# add domain without recovery password
domain_add(TEST_DYNDNS_DOMAIN)
assert TEST_DYNDNS_DOMAIN in domain_list()["domains"]
# set the recovery password with config panel
domain_config_set(TEST_DYNDNS_DOMAIN, "dns.registrar.recovery_password", TEST_DYNDNS_PASSWORD)
# remove domain without unsubscribing
domain_remove(TEST_DYNDNS_DOMAIN, ignore_dyndns=True)
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
# readding domain with bad password should fail
with pytest.raises(YunohostValidationError):
domain_add(
TEST_DYNDNS_DOMAIN, dyndns_recovery_password="wrong" + TEST_DYNDNS_PASSWORD
)
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
# readding domain with password should work
domain_add(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD)
assert TEST_DYNDNS_DOMAIN in domain_list()["domains"]
# remove the dyndns domain
domain_remove(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD)
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
def test_domain_add_existing_domain(): def test_domain_add_existing_domain():
@ -94,13 +126,6 @@ def test_domain_remove():
assert TEST_DOMAINS[1] not in domain_list()["domains"] assert TEST_DOMAINS[1] not in domain_list()["domains"]
def test_domain_remove_unsubscribe():
time.sleep(35) # Dynette blocks requests that happen too frequently
assert TEST_DYNDNS_DOMAIN in domain_list()["domains"]
domain_remove(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD)
assert TEST_DYNDNS_DOMAIN not in domain_list()["domains"]
def test_main_domain(): def test_main_domain():
current_main_domain = _get_maindomain() current_main_domain = _get_maindomain()
assert domain_main_domain()["current_main_domain"] == current_main_domain assert domain_main_domain()["current_main_domain"] == current_main_domain

View file

@ -156,7 +156,7 @@ def tools_postinstall(
force_diskspace=False, force_diskspace=False,
overwrite_root_password=True, overwrite_root_password=True,
): ):
from yunohost.dyndns import _dyndns_available from yunohost.dyndns import _dyndns_available, dyndns_unsubscribe
from yunohost.utils.dns import is_yunohost_dyndns_domain from yunohost.utils.dns import is_yunohost_dyndns_domain
from yunohost.utils.password import ( from yunohost.utils.password import (
assert_password_is_strong_enough, assert_password_is_strong_enough,
@ -218,7 +218,14 @@ def tools_postinstall(
) )
else: else:
if not available: if not available:
raise YunohostValidationError("dyndns_unavailable", domain=domain) if dyndns_recovery_password:
# Try to unsubscribe the domain so it can be subscribed again
# If successful, it will be resubscribed with the same recovery password
dyndns_unsubscribe(
domain=domain, recovery_password=dyndns_recovery_password
)
else:
raise YunohostValidationError("dyndns_unavailable", domain=domain)
if os.system("iptables -V >/dev/null 2>/dev/null") != 0: if os.system("iptables -V >/dev/null 2>/dev/null") != 0:
raise YunohostValidationError( raise YunohostValidationError(