Skip to content

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

Open
jonolt wants to merge 2 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 2 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 byte-for-byte 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. Everything else (URL build honouring force_ssl, hostname/system='dyndns'/wildcard, HTTP basic auth, User-Agent) is identical to the generic class, so an IPv4 INWX request is byte-for-byte equivalent to what DynDNS2 emitted before — verified by the regression test below.
service address parameter behaviour
inwx IPv6 myipv6 fixed
inwx IPv4 myip unchanged (byte-identical)
any other IPv6 myip unchanged
any other IPv4 myip unchanged

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: on the native backend the global Allow IPv6 toggle appears to be a no-op (only the Perl/ddclient template reads it) — a possible separate follow-up, untouched here.

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.


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 / system='dyndns' / wildcard;
  • regression: an IPv4 INWX request is byte-identical (URL + params + auth + headers) to the generic DynDNS2 output for the same settings.

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. 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 byte-for-byte 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
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