dns/ddclient: add a service-specific INWX account class that sets myipv6 (native backend)#5472
dns/ddclient: add a service-specific INWX account class that sets myipv6 (native backend)#5472jonolt wants to merge 3 commits into
myipv6 (native backend)#5472Conversation
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 opnsense#3233/opnsense#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 opnsense#5100, opnsense#2872. AI assistance: Claude (Anthropic), Claude Opus 4.8. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
If a specific service requires different parameters, best just override |
|
Thanks for the feedback. I have some questions before I start implementing.
|
|
I would start easy with a service specific implementation, there are other examples as well in the |
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 <noreply@anthropic.com>
myipv6 for INWX/Dynu on the native backendmyipv6 (native backend)
|
I have asked Claude to check if the wildcard parameter copied from dyndns2 is used by INWX (answer see below). As it is not used, i will remove. Conclusion: INWX's DynDNS update endpoint does not support a Primary Sources
Implication for Our Code
|
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 <noreply@anthropic.com>
Important notices
Before you submit a pull request, we ask you kindly to acknowledge the following:
If AI was used, please disclose:
myipv6=URL — see Testing).Related issue
#5100 (INWX IPv6, but reported against the Perl
ddclientbackend). Also relates to the same CGNAT symptom in #2872 and to native IPv6-only change detection in #3069. This PR does not close any of them — it fixes a distinct, code-level cause on the native backend.(No separate tracking issue was opened first: per CONTRIBUTING.md that step is asked of new plugins; this is a bugfix to an existing plugin, already documented through the issues above — hence the unchecked box.)
Describe the problem
On the native backend the dyndns2 update path always sent the resolved address as
myip=, with no IP-family awareness and nomyipv6=. DynDNS2 has no formal standard and historically defines onlymyip; IPv6 support was added later by providers in two incompatible ways:myipthat accepts either family (e.g. deSEC) — the current code already works here;myipv6=withmyiptreated as IPv4-only (e.g. INWX) — the current code fails here.Against the second group, a detected IPv6 lands in
myip, is ignored for AAAA, and the provider falls back to the connection's source IP. Behind CGNAT/DS-Lite (no usable public IPv4) the result is the worst case: the A record is set to the carrier's shared CGNAT IPv4 (wrong — it routes to the carrier, not the host) and AAAA is never set.This is a client-side interoperability gap, not a provider bug: the plugin's own Perl/
ddclientbackend already handles IPv6 viausev6/myipv6; the native backend could not setmyipv6at all.The hard-coded parameter name is in
dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py(standard path):current_addressis set once inlib/account/__init__.py::BaseAccount.execute()and reused verbatim by both the standard and custom-GET paths.Describe the proposed solution
Following the maintainer guidance on #5312 ("better to add a separate implementation when specific parameters are needed"), this keeps the generic
DynDNS2class spec-pure — it continues to send onlymyip, per the dyndns2 legacy standard — and gives INWX its own small service-specific account class, modelled on the existingdomeneshop.py/duckdns.pyproviders.inwxis removed fromDynDNS2._services(one line);DynDNS2no longer claims it.lib/account/inwx.pydefinesINWX(BaseAccount)with_services = {'inwx': 'dyndns.inwx.com'}. It is auto-discovered bypoller.py::AccountFactory(globsaccount/*.py, collectsBaseAccountsubclasses) — no registration wiring needed.INWX.execute()mirrors the generic/nic/updatestandard path, then sends the address asmyipv6=when it is IPv6 (detected with':' in str(self.current_address)— the same colon-in-string checkduckdns.pyuses) andmyip=otherwise. The rest (URL build honouringforce_ssl,hostname, HTTP basic auth,User-Agent) matches the generic class. The deliberate difference is the two dropped dyndns2 leftovers,systemandwildcard(next bullet), so an IPv4 INWX request equals whatDynDNS2emitted before minus those two no-op keys — verified by the regression test below.systemandwildcardparameters. Both are legacy dyndns2 (dyn.com) tokens the genericDynDNS2class always appends (system=dyndns,wildcard=ON|NOCHG). INWX implements neither — its documented update endpoint reads onlyhostname/myip/myipv6. Forwildcardspecifically, INWX's own KB tells users to create a manual*.hostCNAME for wildcard subdomains rather than pass a URL flag, and the OPNsense GUI already hides the Wildcard checkbox for INWX (dialogAccount.xmlshows it only fordyndns2/cloudflare/easydns/custom/woima), so it could only ever emitwildcard=NOCHG. The live test (below) confirms both are inert: a bare request (hostname only) produced results identical to one carryingsystem=dyndns&wildcard=NOCHG. Rather than carry inert parameters, the INWX class omits both.The
<inwx>INWX</inwx>GUI dropdown option and the stored service valueinwxare unchanged; only the handling class moves. No model/form/template changes are needed.Scope (deliberately narrow).
myipv6) is not touched in this PR — it stays handled byDynDNS2exactly as onmaster(its IPv6 case remains unfixed and can be a focused follow-up once reproduced).myip/myipv6), keeping the plugin's one-IP-family-per-account model (as with the existingdesec-v4/desec-v6,nsupdatev4/nsupdatev6,he-net/he-net-tunnelsplits). Full dual-stack on INWX is achieved the standard, documented way — two accounts on the same hostname, one per family — provided each uses a separate INWX DynDNS login bound to its own A/AAAA record (confirmed in production). This fix is precisely what makes the IPv6 account work, since it must sendmyipv6. It does not attempt to send both families in one request (os-ddclient - Ability to choose IPv4 or IPv6 or IPv4/IPv6 for each entry #3233/ddclient OPNsense backend: Add new account class for single call URL Update for ipv4 and ipv6 #3535).Noticed, not addressed (pre-existing, out of scope). These were observed during review of this change; none are introduced by it, and all are left untouched so this PR stays INWX-scoped:
req.text.split()[0]crashes on a whitespace-only response. The dyndns2 text-response family parses the reply withstatus=req.text.split()[0] if req.text else ''(dyndns2.py:108,gandi.py:71,domeneshop.py:76). A non-empty but whitespace-only 2xx body (e.g."\n") is truthy yet.split()is[], so[0]raisesIndexError— after the update has already succeeded at the provider. The poller catches it and logs a misleadingLOG_ERR "…raised fatal error (list index out of range)", and because state is never persisted the update re-fires (and re-logs) every poll cycle.INWXdeliberately keeps this same idiom for consistency with the family; a one-line guard (parts = req.text.split(); parts[0] if parts else '') would fix all four at once in a separate PR.myipv6vsmyipis chosen with':' in str(self.current_address)— the house idiom shared byaws,duckdns,netcup,hetzner,allinkl, and nowinwx. It is not validated (any colon-bearing string routes to the v6 param), butcheckip()only ever yields a clean family-specific address, so it is safe in practice.INWXfollows the idiom.ddclienttemplate reads it. Possible separate follow-up.cloudflare— the GUI shows the checkbox forservice_cloudflare, butcloudflare.pynever reads the setting (the inverse of the INWX situation this PR cleans up).myipv6but is not touched here (see first bullet of this Scope section) — its IPv6 case stays handled byDynDNS2as onmaster.The INWX single-login footgun, surfaced via the log. The catch is that INWX scopes record deletion to the DynDNS login: a single login bound to both A and AAAA drops whichever family is omitted from an update (INWX offers no
preservetoken). So dual-stack must use a separate INWX login per family — not two OPNsense accounts sharing one INWX login, which would clobber. To make this self-diagnosable without a GUI/form change,INWX.execute()emits aLOG_NOTICEon each successful update naming the parameter/record it set (myipv6 (AAAA)ormyip (A)) and the correct dual-stack setup. Because updates only fire on an address change, this is not per-poll-cycle noise. A verbose-only line additionally logs the chosen parameter name for parameter-quirk diagnosis. This per-login record scope is also documented in the module docstring.Testing
Static:
python3 -m py_compilepasses for bothinwx.pyand the trimmeddyndns2.py.pycodestyle --max-line-length=125(the repo's style gate) reports no new violations.Dispatch (native factory):
AccountFactory().get({'service':'inwx', ...})now returns anINWXinstance (notDynDNS2);AccountFactory().known_services()still containsinwx(now viaINWX);'inwx' not in DynDNS2._services;dyndns2and the other services still route toDynDNS2.Unit (mocked
requests.get, no network) —test_inwx_class.py, all 15 checks pass:current_address(2001:db8::1) → params carrymyipv6and notmyip;192.0.2.1) → params carrymyipand notmyipv6;hostnameand omit the no-opsystem/wildcardparams;DynDNS2output for the same settings, aside from the omittedsystem/wildcardkeys.Live, against a real INWX account: requests were hand-crafted in two independent passes — once with the bare INWX endpoint (hostname only) and once mirroring the exact OPNsense
dyndns2.pyURL construction (system=dyndns,wildcard=NOCHG). Both passes produced identical results, and each resulting A/AAAA record was read back from the INWX control panel. (This identity is exactly what licenses dropping bothsystemandwildcard: the no-extra-params pass behaves the same as thesystem=dyndns&wildcard=NOCHGpass.) All rows below were taken against a single INWX login covering both records — which is why the single-family rows drop the other record (with a separate INWX login per family, bound to its own A/AAAA record, the updates do not interfere — see Scope):myip=<IPv4>goodmyipv6=<IPv6>goodmyip=<IPv4>+myipv6=<IPv6>goodmyip=<IPv6>goodThe decisive comparison holds the address constant and changes only the parameter name:
myip=<IPv6>→ AAAA deleted, A polluted with the connection's IPv4 — exactly the reported CGNAT symptom;myipv6=<IPv6>→ AAAA set correctly.So the parameter name, not IP detection, decides whether INWX writes the AAAA record. This fix makes the native path emit that same known-good
myipv6=<address>request automatically for INWX.Note:
dyndns.inwx.comhas no AAAA record, so requests run over IPv4 transport regardless of the address family in the payload — an INWX infrastructure constraint, not a test limitation, and orthogonal to which parameter carries the address.Not yet exercised end-to-end: the new code path has not been run on a live OPNsense native backend — the production confirmation above was via hand-crafted HTTP requests to the real INWX endpoint, not through this code. Because it emits a request equivalent to the confirmed
myipv6=<address>URL, the behaviour is well-evidenced; a native-backend run by a maintainer or the reporter would fully close the loop.The change adheres to 2-Clause BSD licensing and is based on the latest
master.References
*CNAME, not a URL parameter — basis for droppingwildcard)preservetoken (the mechanism INWX lacks)ddclientbackend · #2872 — CGNAT A/AAAA symptom · #3069 — native IPv6-only change detection