Skip to content

dns/ddclient: add a service-specific INWX account class that sets myipv6 (native backend)#5472

Open
jonolt wants to merge 3 commits into
opnsense:masterfrom
jonolt:dns/ddclient-ipv6-myipv6
Open

dns/ddclient: add a service-specific INWX account class that sets myipv6 (native backend)#5472
jonolt wants to merge 3 commits into
opnsense:masterfrom
jonolt:dns/ddclient-ipv6-myipv6

Conversation

@jonolt
Copy link
Copy Markdown

@jonolt jonolt commented May 30, 2026

Important notices

Before you submit a pull request, we ask you kindly to acknowledge the following:

If AI was used, please disclose:

  • Model used: Claude Opus 4.8 (Anthropic)
  • Extent of AI involvement: Root-cause analysis, the code change, and this PR text were prepared with Claude under close supervision. The author reproduced and verified the behaviour independently against a real INWX account, reading each result back from the INWX control panel. The new native-backend code path has not yet been run end-to-end on a live OPNsense system (it emits a request equivalent to the confirmed myipv6= URL — see Testing).

Related issue

#5100 (INWX IPv6, but reported against the Perl ddclient backend). 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 no myipv6=. DynDNS2 has no formal standard and historically defines only myip; IPv6 support was added later by providers in two incompatible ways:

  • family-agnostic myip that accepts either family (e.g. deSEC) — the current code already works here;
  • a separate myipv6= with myip treated 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/ddclient backend already handles IPv6 via usev6/myipv6; the native backend could not set myipv6 at all.

The hard-coded parameter name is in dns/ddclient/src/opnsense/scripts/ddclient/lib/account/dyndns2.py (standard path):

'params': {
    'hostname': ...,
    'myip': self.current_address,   # no family check, no myipv6
    'system': 'dyndns',
    'wildcard': ...
}

current_address is set once in lib/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 DynDNS2 class spec-pure — it continues to send only myip, per the dyndns2 legacy standard — and gives INWX its own small service-specific account class, modelled on the existing domeneshop.py/duckdns.py providers.

  • inwx is removed from DynDNS2._services (one line); DynDNS2 no longer claims it.
  • A new lib/account/inwx.py defines INWX(BaseAccount) with _services = {'inwx': 'dyndns.inwx.com'}. It is auto-discovered by poller.py::AccountFactory (globs account/*.py, collects BaseAccount subclasses) — no registration wiring needed.
  • INWX.execute() mirrors the generic /nic/update standard path, then sends the address as myipv6= when it is IPv6 (detected with ':' in str(self.current_address) — the same colon-in-string check duckdns.py uses) and myip= otherwise. The rest (URL build honouring force_ssl, hostname, HTTP basic auth, User-Agent) matches the generic class. The deliberate difference is the two dropped dyndns2 leftovers, system and wildcard (next bullet), so an IPv4 INWX request equals what DynDNS2 emitted before minus those two no-op keys — verified by the regression test below.
  • Dropped the no-op system and wildcard parameters. Both are legacy dyndns2 (dyn.com) tokens the generic DynDNS2 class always appends (system=dyndns, wildcard=ON|NOCHG). INWX implements neither — its documented update endpoint reads only hostname/myip/myipv6. For wildcard specifically, INWX's own KB tells users to create a manual *.host CNAME for wildcard subdomains rather than pass a URL flag, and the OPNsense GUI already hides the Wildcard checkbox for INWX (dialogAccount.xml shows it only for dyndns2/cloudflare/easydns/custom/woima), so it could only ever emit wildcard=NOCHG. The live test (below) confirms both are inert: a bare request (hostname only) produced results identical to one carrying system=dyndns&wildcard=NOCHG. Rather than carry inert parameters, the INWX class omits both.

The <inwx>INWX</inwx> GUI dropdown option and the stored service value inwx are unchanged; only the handling class moves. No model/form/template changes are needed.

Scope (deliberately narrow).

  • INWX only. Dynu (which also documents myipv6) is not touched in this PR — it stays handled by DynDNS2 exactly as on master (its IPv6 case remains unfixed and can be a focused follow-up once reproduced).
  • Single family per account; dual-stack via two accounts. Each account still routes its one resolved address to the correct parameter (myip/myipv6), keeping the plugin's one-IP-family-per-account model (as with the existing desec-v4/desec-v6, nsupdatev4/nsupdatev6, he-net/he-net-tunnel splits). 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 send myipv6. 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:

  1. req.text.split()[0] crashes on a whitespace-only response. The dyndns2 text-response family parses the reply with status=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] raises IndexErrorafter the update has already succeeded at the provider. The poller catches it and logs a misleading LOG_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. INWX deliberately 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.
  2. Substring-based IP-family detection. myipv6 vs myip is chosen with ':' in str(self.current_address) — the house idiom shared by aws, duckdns, netcup, hetzner, allinkl, and now inwx. It is not validated (any colon-bearing string routes to the v6 param), but checkip() only ever yields a clean family-specific address, so it is safe in practice. INWX follows the idiom.
  3. Global Allow IPv6 toggle is a no-op on the native backend — only the Perl/ddclient template reads it. Possible separate follow-up.
  4. Dead Wildcard checkbox for cloudflare — the GUI shows the checkbox for service_cloudflare, but cloudflare.py never reads the setting (the inverse of the INWX situation this PR cleans up).
  5. Dynu also documents myipv6 but is not touched here (see first bullet of this Scope section) — its IPv6 case stays handled by DynDNS2 as on master.

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 preserve token). 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 a LOG_NOTICE on each successful update naming the parameter/record it set (myipv6 (AAAA) or myip (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_compile passes for both inwx.py and the trimmed dyndns2.py. pycodestyle --max-line-length=125 (the repo's style gate) reports no new violations.

Dispatch (native factory): AccountFactory().get({'service':'inwx', ...}) now returns an INWX instance (not DynDNS2); AccountFactory().known_services() still contains inwx (now via INWX); 'inwx' not in DynDNS2._services; dyndns2 and the other services still route to DynDNS2.

Unit (mocked requests.get, no network) — test_inwx_class.py, all 15 checks pass:

  • IPv6 current_address (2001:db8::1) → params carry myipv6 and not myip;
  • IPv4 (192.0.2.1) → params carry myip and not myipv6;
  • both keep hostname and omit the no-op system / wildcard params;
  • regression: an IPv4 INWX request matches (URL + params + auth + headers) the generic DynDNS2 output for the same settings, aside from the omitted system/wildcard keys.

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.py URL 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 both system and wildcard: the no-extra-params pass behaves the same as the system=dyndns&wildcard=NOCHG pass.) 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):

params sent HTTP A record AAAA record
myip=<IPv4> good = sent IPv4 deleted
myipv6=<IPv6> good deleted = sent IPv6 (fix path)
myip=<IPv4> + myipv6=<IPv6> good = sent IPv4 = sent IPv6
myip=<IPv6> good connection-source IPv4 (an address in neither URL) deleted (bug path)

The 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.com has 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

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>
@AdSchellevis
Copy link
Copy Markdown
Member

If a specific service requires different parameters, best just override BaseAccount and build it's own implementation for clarity. The dyndns2 (legacy)"standard" only specifies myip (https://help.dyn.com/perform-update.html)

@jonolt
Copy link
Copy Markdown
Author

jonolt commented May 31, 2026

Thanks for the feedback. I have some questions before I start implementing.

  1. I'm not sure which way you prefer:
    a. subclass DynDNS2, moving the parameter construction into a private, overridable method
    b. subclass BaseAccount, duplicating most of execute
  2. Use a service-specific class for INWX, or a general-purpose myipv6 class (shared by INWX and Dynu)?
  3. Also, since I'm subclassing anyway, should we include the dual-stack problem too? It's a much larger code change though.

@AdSchellevis
Copy link
Copy Markdown
Member

I would start easy with a service specific implementation, there are other examples as well in the account library, usually these don't need a lot of glue to function. having one entry bound to two addresses (ipv4+ipv6) likely causes other challenges which I wouldn't combine in a first PR.

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>
@jonolt jonolt changed the title dns/ddclient: route IPv6 to myipv6 for INWX/Dynu on the native backend dns/ddclient: add a service-specific INWX account class that sets myipv6 (native backend) May 31, 2026
@jonolt jonolt marked this pull request as draft June 1, 2026 16:11
@jonolt
Copy link
Copy Markdown
Author

jonolt commented Jun 1, 2026

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 wildcard
parameter. It accepts only myip and myipv6; wildcard/system are
dyndns2-protocol leftovers that INWX silently ignores.

Primary Sources

Implication for Our Code

inwx.py:80 (and dyndns2.py:92) emit wildcard=NOCHG, a harmless no-op.
But the OPNsense "Wildcard" checkbox (dialogAccount.xml:55-59) is misleading
for INWX — ticking it sends wildcard=ON, which INWX ignores; the user must
instead create the * CNAME manually per the KB article above.

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>
@jonolt jonolt marked this pull request as ready for review June 1, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants