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 %}",