From 012c3e87a715bc26747ddb560f55b90e3edfbd0e Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Fri, 13 Mar 2026 07:45:19 +0100 Subject: [PATCH] dns/ddclient: Preserve the other address family (A/AAAA) on deSEC update (#5232) --- .../OPNsense/DynDNS/forms/dialogAccount.xml | 22 +++++ .../mvc/app/models/OPNsense/DynDNS/DynDNS.xml | 20 +++- .../OPNsense/DynDNS/Migrations/M1_5_2.php | 44 +++++++++ .../mvc/app/views/OPNsense/DynDNS/index.volt | 20 +++- .../scripts/ddclient/lib/account/desec.py | 97 +++++++++++++++++++ .../scripts/ddclient/lib/account/dyndns2.py | 2 - .../templates/OPNsense/ddclient/ddclient.json | 3 + 7 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/Migrations/M1_5_2.php create mode 100755 dns/ddclient/src/opnsense/scripts/ddclient/lib/account/desec.py diff --git a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml index 0ffcf2040e..09f826f443 100644 --- a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml +++ b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml @@ -38,6 +38,7 @@ text true + account.username @@ -51,6 +52,13 @@ password Password associated with this account + + account.token_secret + + password + + Token secret for the domain. This is not your deSEC account password. + account.wildcard @@ -58,6 +66,20 @@ add a DNS wildcard CNAME record that points to the configured host. + + account.prune_a + + checkbox + + Delete existing A (IPv4) records when this IPv6 domain updates. Leave unchecked to preserve them. + + + account.prune_aaaa + + checkbox + + Delete existing AAAA (IPv6) records when this IPv4 domain updates. Leave unchecked to preserve them. + account.zone diff --git a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml index 75c390ce1e..15cf28afca 100644 --- a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml +++ b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml @@ -1,6 +1,6 @@ //OPNsense/DynDNS - 1.5.1 + 1.5.2 Dynamic DNS client @@ -113,6 +113,16 @@ N /^[^\n]*$/ + + + N + /^[^\n]*$/ + N /^[^\n]*$/ @@ -131,6 +141,14 @@ 0 Y + + 0 + Y + + + 0 + Y + N N diff --git a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/Migrations/M1_5_2.php b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/Migrations/M1_5_2.php new file mode 100644 index 0000000000..04e6e249d0 --- /dev/null +++ b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/Migrations/M1_5_2.php @@ -0,0 +1,44 @@ +accounts->account->iterateItems() as $account) { + $service = (string)$account->service; + if ($service == 'desec-v4' || $service == 'desec-v6') { + /* + * Older deSEC entries used "password" to store the token secret; + * the deSEC account password was never supported. Copy the value + * to the explicit field, but leave the old value so unchanged + * migrated entries can roll back. Entries created after this + * migration only use token_secret. + */ + $legacy_token = $account->password->getValue(); + if ((string)$account->token_secret == '' && $legacy_token != '') { + $account->token_secret = $legacy_token; + } + /* + * deSEC never used "username" as an account login. If set at + * all, it could only mirror Hostname(s), which is already the + * authoritative update target. + */ + $account->username = ''; + /* + * The original deSEC path deleted the opposite address family + * when "preserve" was omitted. Existing accounts keep that + * behavior; new accounts get the model defaults and preserve. + */ + if ($service == 'desec-v4') { + $account->prune_aaaa = '1'; + } else { + $account->prune_a = '1'; + } + } + } + } +} diff --git a/dns/ddclient/src/opnsense/mvc/app/views/OPNsense/DynDNS/index.volt b/dns/ddclient/src/opnsense/mvc/app/views/OPNsense/DynDNS/index.volt index 88ee8897e5..126f9f882a 100644 --- a/dns/ddclient/src/opnsense/mvc/app/views/OPNsense/DynDNS/index.volt +++ b/dns/ddclient/src/opnsense/mvc/app/views/OPNsense/DynDNS/index.volt @@ -57,8 +57,8 @@ POSSIBILITY OF SUCH DAMAGE. updateServiceControlUI('dyndns'); } }); - $("#account\\.service").change(function(){ - let service = $(this).val(); + function updateAccountServiceControls() { + let service = $("#account\\.service").val(); $("#frm_DialogAccount .optional_setting").each(function(){ let this_item = $(this); if (this_item.hasClass("service_"+service)) { @@ -69,6 +69,22 @@ POSSIBILITY OF SUCH DAMAGE. this_item.prop( "disabled", true ); } }); + let is_desec = ['desec-v4', 'desec-v6'].includes(service); + // deSEC's dynDNS "username" was never an account login; the only + // useful value was a duplicate of Hostname(s), so migration clears + // it. Use Hostname(s) plus Token secret and keep legacy Password + // hidden for rollback without exposing or rewriting it. + $("#account\\.username, #account\\.password") + .prop("disabled", is_desec) + .closest("tr") + .toggle(!is_desec); + } + + $("#account\\.service").change(updateAccountServiceControls); + $(document).ajaxComplete(function(event, xhr, settings) { + if (settings.url && settings.url.indexOf('/api/dyndns/accounts/get_item/') !== -1) { + updateAccountServiceControls(); + } }); $('#DialogAccount').on('shown.bs.modal', function (e) { $("#account\\.service").change(); diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/desec.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/desec.py new file mode 100755 index 0000000000..86fc41b784 --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/desec.py @@ -0,0 +1,97 @@ +import syslog +import requests +from . import BaseAccount + + +class DeSEC(BaseAccount): + _checked_values = {'1', 'true', 'yes', 'on'} + _preserve_value = 'preserve' + _user_agent = 'OPNsense-dyndns' + + _services = { + 'desec-v4': { + 'label': 'deSEC (IPv4)', + 'server': 'update.dedyn.io', + 'address_param': 'myipv4', + 'other_param': 'myipv6', + 'prune_setting': 'prune_aaaa' + }, + 'desec-v6': { + 'label': 'deSEC (IPv6)', + 'server': 'update6.dedyn.io', + 'address_param': 'myipv6', + 'other_param': 'myipv4', + 'prune_setting': 'prune_a' + } + } + + @classmethod + def known_services(cls): + return {key: item['label'] for key, item in cls._services.items()} + + @classmethod + def match(cls, account): + return account.get('service') in cls._services + + @classmethod + def _is_checked(cls, value): + return value is True or str(value).lower() in cls._checked_values + + def _token_secret(self): + # Legacy deSEC accounts stored the token secret in "password"; a deSEC + # account password was never accepted by this backend. + return self.settings.get('token_secret') or self.settings.get('password') or '' + + def _address_parameters(self, service_settings): + # deSEC prunes the other address family when its parameter is empty. + # New accounts preserve by default; migrated accounts may set prune_* to + # keep the historic behavior. + other_address = ( + '' + if self._is_checked(self.settings.get(service_settings['prune_setting'], False)) + else self._preserve_value + ) + return { + 'hostname': self.settings.get('hostnames'), + service_settings['address_param']: str(self.current_address), + service_settings['other_param']: other_address + } + + def _request_options(self, service_settings): + uri_proto = 'https' if self.settings.get('force_ssl', False) else 'http' + # deSEC's dynDNS "username" is the domain being updated, not an + # account login. We already send that via the hostname parameter, so use + # token authentication and keep the generic Username field irrelevant. + return { + 'url': f"{uri_proto}://{service_settings['server']}/nic/update", + 'params': self._address_parameters(service_settings), + 'headers': { + 'User-Agent': self._user_agent, + 'Authorization': f"Token {self._token_secret()}" + } + } + + def execute(self): + if not super().execute(): + return False + + service_settings = self._services[self.settings.get('service')] + req = requests.get(**self._request_options(service_settings)) + + if 200 <= req.status_code < 300: + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new ip %s [%s]" % (self.description, self.current_address, req.text.strip()) + ) + + self.update_state(address=self.current_address, status=req.text.split()[0] if req.text else '') + return True + + syslog.syslog( + syslog.LOG_ERR, + "Account %s failed to set new ip %s [%d - %s]" % ( + self.description, self.current_address, req.status_code, req.text.replace('\n', '') + ) + ) + return False diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py index e93d0e5abe..a5d9635c26 100755 --- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py @@ -34,8 +34,6 @@ class DynDNS2(BaseAccount): _services = { 'dyndns2': 'members.dyndns.org', - 'desec-v4': 'update.dedyn.io', - 'desec-v6': 'update6.dedyn.io', 'dns-o-matic': 'updates.dnsomatic.com', 'dynu': 'api.dynu.com', 'he-net': 'dyn.dns.he.net', diff --git a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json index 4a2cc7a852..af408a152a 100644 --- a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json +++ b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json @@ -17,8 +17,11 @@ "resourceId": {{ account.resourceId | default('') | tojson }}, "username": {{ account.username | default('') | tojson }}, "password": {{ account.password | default('') | tojson }}, + "token_secret": {{ account.token_secret | default('') | tojson }}, "hostnames": "{{ account.hostnames }}", "wildcard": {{ "true" if account.wildcard == '1' else "false"}}, + "prune_a": {{ "true" if account.prune_a|default('0') == '1' else "false"}}, + "prune_aaaa": {{ "true" if account.prune_aaaa|default('0') == '1' else "false"}}, "zone": "{{ account.zone }}", "checkip": "{{ account.checkip }}", "interface": "{% if account.interface %}{{physical_interface(account.interface)}}{% endif %}",