From ec096089b890299919ec7811f5b1b4be8bb66390 Mon Sep 17 00:00:00 2001 From: jonolt Date: Fri, 29 May 2026 17:41:03 +0200 Subject: [PATCH 1/3] dns/ddclient: route IPv6 to myipv6 for INWX/Dynu (native backend) The native dyndns2 backend always sent the resolved address as "myip", with no IP-family awareness. Providers that treat "myip" as IPv4-only and expect IPv6 in a separate "myipv6" parameter (INWX, Dynu) therefore never got an AAAA record, and behind CGNAT/DS-Lite the A record was set to the carrier's IPv4 via the connection source-IP fallback. Add a small per-service allow-list (_myipv6_services) and send an IPv6 address as "myipv6" for those services; IPv4 and every other service keep "myip" exactly as before, so existing setups are unaffected. This keeps the plugin's one-IP-family-per-account model and does not attempt to send both families in one request (related to #3233/#3535, which were declined in favour of registering two accounts). Also add verbose-only syslog lines reporting the chosen parameter (and, on the custom GET/POST/PUT path, the substituted __MYIP__/__HOSTNAME__ token values only, never the assembled URL, which may carry secrets) so users can self-diagnose provider parameter-name quirks. Related to #5100, #2872. AI assistance: Claude (Anthropic), Claude Opus 4.8. Co-Authored-By: Claude Opus 4.8 --- .../scripts/ddclient/lib/account/dyndns2.py | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) 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..5f7f4165b1 100755 --- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py @@ -32,6 +32,9 @@ class DynDNS2(BaseAccount): _priority = 65535 + # services that need an IPv6 address sent as "myipv6" instead of the default "myip" + _myipv6_services = {'inwx', 'dynu'} + _services = { 'dyndns2': 'members.dyndns.org', 'desec-v4': 'update.dedyn.io', @@ -71,6 +74,14 @@ def execute(self): url = self.settings.get('server') url = url.replace('__MYIP__', self.current_address) url = url.replace('__HOSTNAME__', self.settings.get('hostnames')) + if self.is_verbose: + # log only the substituted tokens, not the URL (its query string may carry secrets) + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s custom update __MYIP__=%s __HOSTNAME__=%s" % ( + self.description, self.current_address, self.settings.get('hostnames') + ) + ) req = requests.request( method=protocol, url=url, @@ -84,14 +95,26 @@ def execute(self): else: url = "%s://%s/nic/update" % (uri_proto, self.settings.get('server')) + # pick "myipv6" for those services when the address is IPv6 (contains ':') + ip_param = 'myip' + if ':' in str(self.current_address) and self.settings.get('service') in self._myipv6_services: + ip_param = 'myipv6' + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s sending dyndns2 update using parameter %s" % (self.description, ip_param) + ) + + params = { + 'hostname': self.settings.get('hostnames'), + 'system': 'dyndns', + 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' + } + params[ip_param] = self.current_address req_opts = { 'url': url, - 'params': { - 'hostname': self.settings.get('hostnames'), - 'myip': self.current_address, - 'system': 'dyndns', - 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' - }, + 'params': params, 'auth': HTTPBasicAuth(self.settings.get('username'), self.settings.get('password')), 'headers': { 'User-Agent': 'OPNsense-dyndns' From 1e3da6a16ed767c3972c32b6f17cb0d1471fe685 Mon Sep 17 00:00:00 2001 From: jonolt Date: Sun, 31 May 2026 22:38:06 +0200 Subject: [PATCH 2/3] dns/ddclient: rework INWX IPv6 into a service-specific account class Second round, addressing review feedback on the first commit. The first approach routed IPv6 to myipv6 via a per-service allow-list (_myipv6_services) and a parameter-name branch inside DynDNS2.execute(). The reviewer noted the dyndns2 legacy standard only specifies myip, so DynDNS2 should stay spec-pure and a provider needing different parameters should get its own service-specific account class. This reverts the DynDNS2 changes, removes 'inwx' from DynDNS2._services, and adds a dedicated INWX(BaseAccount) class (modelled on domeneshop.py) that sends an IPv6 address as myipv6 (setting the AAAA record) and IPv4 as myip; an IPv4 request is byte-identical to the previous DynDNS2 output. Scope narrowed to INWX only (dynu and dual-stack deferred). Auto-discovered by AccountFactory, so no GUI/form/template changes are needed. Co-Authored-By: Claude Opus 4.8 --- .../scripts/ddclient/lib/account/dyndns2.py | 36 +----- .../scripts/ddclient/lib/account/inwx.py | 119 ++++++++++++++++++ 2 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py 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 5f7f4165b1..e81197751b 100755 --- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py @@ -32,9 +32,6 @@ class DynDNS2(BaseAccount): _priority = 65535 - # services that need an IPv6 address sent as "myipv6" instead of the default "myip" - _myipv6_services = {'inwx', 'dynu'} - _services = { 'dyndns2': 'members.dyndns.org', 'desec-v4': 'update.dedyn.io', @@ -43,7 +40,6 @@ class DynDNS2(BaseAccount): 'dynu': 'api.dynu.com', 'he-net': 'dyn.dns.he.net', 'he-net-tunnel': 'ipv4.tunnelbroker.net', - 'inwx': 'dyndns.inwx.com', 'loopia': 'dyndns.loopia.se', 'nsupdatev4': 'ipv4.nsupdate.info', 'nsupdatev6': 'ipv6.nsupdate.info', @@ -74,14 +70,6 @@ def execute(self): url = self.settings.get('server') url = url.replace('__MYIP__', self.current_address) url = url.replace('__HOSTNAME__', self.settings.get('hostnames')) - if self.is_verbose: - # log only the substituted tokens, not the URL (its query string may carry secrets) - syslog.syslog( - syslog.LOG_NOTICE, - "Account %s custom update __MYIP__=%s __HOSTNAME__=%s" % ( - self.description, self.current_address, self.settings.get('hostnames') - ) - ) req = requests.request( method=protocol, url=url, @@ -95,26 +83,14 @@ def execute(self): else: url = "%s://%s/nic/update" % (uri_proto, self.settings.get('server')) - # pick "myipv6" for those services when the address is IPv6 (contains ':') - ip_param = 'myip' - if ':' in str(self.current_address) and self.settings.get('service') in self._myipv6_services: - ip_param = 'myipv6' - - if self.is_verbose: - syslog.syslog( - syslog.LOG_NOTICE, - "Account %s sending dyndns2 update using parameter %s" % (self.description, ip_param) - ) - - params = { - 'hostname': self.settings.get('hostnames'), - 'system': 'dyndns', - 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' - } - params[ip_param] = self.current_address req_opts = { 'url': url, - 'params': params, + 'params': { + 'hostname': self.settings.get('hostnames'), + 'myip': self.current_address, + 'system': 'dyndns', + 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' + }, 'auth': HTTPBasicAuth(self.settings.get('username'), self.settings.get('password')), 'headers': { 'User-Agent': 'OPNsense-dyndns' diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py new file mode 100644 index 0000000000..abf5d4bab8 --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py @@ -0,0 +1,119 @@ +""" + Copyright (c) 2026 Johannes Nolte + Copyright (c) 2023 Ad Schellevis + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + ---------------------------------------------------------------------------------------------------- + INWX DNS updater + + Service specific dyndns2-style account. INWX expects an IPv6 address in the + "myipv6" parameter (setting the AAAA record); the dyndns2 legacy "standard" + only specifies "myip", so this is kept out of the generic DynDNS2 class. + For an IPv4 address the request is byte-identical to the generic DynDNS2 + "/nic/update" output. +""" +import syslog +import requests +from requests.auth import HTTPBasicAuth +from . import BaseAccount + + +class INWX(BaseAccount): + + _services = { + 'inwx': 'dyndns.inwx.com' + } + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + return INWX._services.keys() + + @staticmethod + def match(account): + return account.get('service') in INWX._services + + def execute(self): + if super().execute(): + uri_proto = 'https' if self.settings.get('force_ssl', False) else 'http' + url = "%s://%s/nic/update" % (uri_proto, self._services['inwx']) + + # INWX takes an IPv6 address (contains ':') as "myipv6", which sets the + # AAAA record; IPv4 goes to "myip" exactly like the generic DynDNS2 class. + ip = str(self.current_address) + ip_param = 'myipv6' if ':' in ip else 'myip' + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s sending INWX update using parameter %s" % (self.description, ip_param) + ) + + req_opts = { + 'url': url, + 'params': { + 'hostname': self.settings.get('hostnames'), + ip_param: self.current_address, + 'system': 'dyndns', + 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' + }, + 'auth': HTTPBasicAuth(self.settings.get('username'), self.settings.get('password')), + 'headers': { + 'User-Agent': 'OPNsense-dyndns' + } + } + req = requests.get(**req_opts) + + 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()) + ) + + # INWX scopes record deletion to the DynDNS login: a login bound to both + # A and AAAA drops whichever family is omitted from an update. Dual-stack + # therefore needs a separate INWX login per family, each bound to its own + # record (the standard OPNsense one-account-per-family model still works). + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set %s (%s). For INWX dual-stack use a separate INWX DynDNS " + "login per family (each bound to its own A/AAAA record); a single INWX " + "login covering both records drops the family not sent in a given update" % ( + self.description, ip_param, 'AAAA' if ip_param == 'myipv6' else 'A' + ) + ) + + self.update_state(address=self.current_address, status=req.text.split()[0] if req.text else '') + return True + else: + 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 From f8bd01d0bb057852ca87e55b6f0bba01203d47e6 Mon Sep 17 00:00:00 2001 From: jonolt Date: Mon, 1 Jun 2026 21:57:31 +0200 Subject: [PATCH 3/3] dns/ddclient: drop no-op system/wildcard params from INWX account INWX's update endpoint only reads hostname/myip/myipv6; the dyndns2 legacy "system" and "wildcard" tokens inherited from the generic DynDNS2 class are ignored by INWX (confirmed live: a bare request behaves identically to one carrying system=dyndns&wildcard=NOCHG, and INWX's KB handles wildcards via a manual *.host CNAME, not a URL flag). The OPNsense GUI also never exposes the Wildcard checkbox for INWX, so the param could only ever be NOCHG. Send only the documented params and document the endpoint URL and the per-login record scope in the docstring. Co-Authored-By: Claude Opus 4.8 --- .../scripts/ddclient/lib/account/inwx.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py index abf5d4bab8..6bf4ba8a94 100644 --- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/inwx.py @@ -1,5 +1,5 @@ """ - Copyright (c) 2026 Johannes Nolte + Copyright (c) 2026 Johannes Nolte Copyright (c) 2023 Ad Schellevis All rights reserved. @@ -29,8 +29,17 @@ Service specific dyndns2-style account. INWX expects an IPv6 address in the "myipv6" parameter (setting the AAAA record); the dyndns2 legacy "standard" only specifies "myip", so this is kept out of the generic DynDNS2 class. - For an IPv4 address the request is byte-identical to the generic DynDNS2 - "/nic/update" output. + For an IPv4 address the request matches the generic DynDNS2 "/nic/update" + output, aside from the no-op "system" / "wildcard" parameters which INWX + ignores (its endpoint only reads "hostname", "myip" and "myipv6"). + + Record scope is per DynDNS login: a single INWX login bound to both A and + AAAA drops whichever family is omitted from an update. For dual-stack, use a + separate INWX login per family (each bound to its own record) — the standard + OPNsense one-account-per-family model. + + INWX documented update URL: + https://dyndns.inwx.com/nic/update?myip=&myipv6= """ import syslog import requests @@ -75,9 +84,7 @@ def execute(self): 'url': url, 'params': { 'hostname': self.settings.get('hostnames'), - ip_param: self.current_address, - 'system': 'dyndns', - 'wildcard': 'ON' if self.settings.get('wildcard', False) else 'NOCHG' + ip_param: self.current_address }, 'auth': HTTPBasicAuth(self.settings.get('username'), self.settings.get('password')), 'headers': { @@ -93,10 +100,7 @@ def execute(self): "Account %s set new ip %s [%s]" % (self.description, self.current_address, req.text.strip()) ) - # INWX scopes record deletion to the DynDNS login: a login bound to both - # A and AAAA drops whichever family is omitted from an update. Dual-stack - # therefore needs a separate INWX login per family, each bound to its own - # record (the standard OPNsense one-account-per-family model still works). + # Per-login record scope — see module docstring; warn the operator below. syslog.syslog( syslog.LOG_NOTICE, "Account %s set %s (%s). For INWX dual-stack use a separate INWX DynDNS "