diff --git a/locales/en.json b/locales/en.json index 60f58f639..e7deb9e95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -91,6 +91,7 @@ "ask_new_path": "New path", "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_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_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", @@ -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_subscribed": "DynDNS domain subscribed", "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_unsubscribed": "DynDNS domain unsubscribed", "dyndns_unsubscribe_denied": "Failed to unsubscribe domain: invalid credentials", diff --git a/src/dns.py b/src/dns.py index e25d0f3ec..fbb461463 100644 --- a/src/dns.py +++ b/src/dns.py @@ -545,6 +545,9 @@ def _get_registrar_config_section(domain): "value": "yunohost", } ) + registrar_infos["recovery_password"] = OrderedDict( + {"type": "password", "ask": m18n.n("ask_dyndns_recovery_password"), "default": ""} + ) return OrderedDict(registrar_infos) elif is_special_use_tld(dns_zone): registrar_infos["registrar"] = OrderedDict( diff --git a/src/domain.py b/src/domain.py index 8fc9799cd..79194df2d 100644 --- a/src/domain.py +++ b/src/domain.py @@ -342,6 +342,7 @@ def domain_remove( dyndns_recovery_password -- Recovery password used at the creation of the DynDNS domain ignore_dyndns -- If we just remove the DynDNS domain, without unsubscribing """ + import glob from yunohost.hook import hook_callback from yunohost.app import app_ssowatconf, app_info, app_remove from yunohost.utils.ldap import _get_ldap_interface @@ -427,14 +428,20 @@ def domain_remove( global domain_list_cache domain_list_cache = [] - stuff_to_delete = [ - f"/etc/yunohost/certs/{domain}", - f"/etc/yunohost/dyndns/K{domain}.+*", - f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", - ] + # If a password is provided, delete the DynDNS record + if dyndns: + try: + # 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(stuff, force=True, recursive=True) + rm(f"/etc/yunohost/certs/{domain}", 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 # appears as manually modified even though they weren't touched ... @@ -462,13 +469,6 @@ def domain_remove( 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")) @@ -707,6 +707,19 @@ class DomainConfigPanel(ConfigPanel): domain=self.entity, 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() diff --git a/src/dyndns.py b/src/dyndns.py index a3afd655f..387f33930 100644 --- a/src/dyndns.py +++ b/src/dyndns.py @@ -27,7 +27,6 @@ from moulinette import Moulinette, m18n from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger 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.domain import _get_maindomain @@ -63,19 +62,28 @@ def _dyndns_available(domain): Returns: 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} ...") try: - r = download_json( - f"https://{DYNDNS_PROVIDER}/test/{domain}", expected_status_code=None - ) + r = requests.get(f"https://{DYNDNS_PROVIDER}/test/{domain}", timeout=30) except MoulinetteError as e: logger.error(str(e)) raise YunohostError( "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"]) @@ -94,14 +102,26 @@ def dyndns_subscribe(operation_logger, domain=None, recovery_password=None): "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 if not is_subscribing_allowed(): 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 if not recovery_password and Moulinette.interface.type == "cli": 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 regen_conf(["yunohost"]) elif r.status_code == 403: - raise YunohostError("dyndns_unsubscribe_denied") + raise YunohostValidationError("dyndns_unsubscribe_denied") 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: raise YunohostError( "dyndns_unsubscribe_failed", diff --git a/src/tests/test_domains.py b/src/tests/test_domains.py index 1bbbb7890..cc33a87d5 100644 --- a/src/tests/test_domains.py +++ b/src/tests/test_domains.py @@ -1,8 +1,10 @@ import pytest import os -import time import random +from mock import patch + +from moulinette import Moulinette from moulinette.core import MoulinetteError from yunohost.utils.error import YunohostError, YunohostValidationError @@ -75,11 +77,41 @@ def test_domain_add(): assert TEST_DOMAINS[2] in domain_list()["domains"] -def test_domain_add_subscribe(): - time.sleep(35) # Dynette blocks requests that happen too frequently +def test_domain_add_and_remove_dyndns(): + # 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"] domain_add(TEST_DYNDNS_DOMAIN, dyndns_recovery_password=TEST_DYNDNS_PASSWORD) 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(): @@ -94,13 +126,6 @@ def test_domain_remove(): 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(): current_main_domain = _get_maindomain() assert domain_main_domain()["current_main_domain"] == current_main_domain diff --git a/src/tools.py b/src/tools.py index cd48f00ee..088400067 100644 --- a/src/tools.py +++ b/src/tools.py @@ -156,7 +156,7 @@ def tools_postinstall( force_diskspace=False, 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.password import ( assert_password_is_strong_enough, @@ -218,7 +218,14 @@ def tools_postinstall( ) else: 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: raise YunohostValidationError(