dns/ddclient: add all-inkl.com KAS API DynDNS provider (#5339)

* dns/ddclient: add all-inkl.com KAS API DynDNS provider

Adds a new Python provider for all-inkl.com hosting using the KAS SOAP API
(KasApi.wsdl). Supports A and AAAA records, including root (@) and wildcard (*)
entries. Credentials are passed per-request (no separate auth step).

- allinkl.py: new provider class AllInkl, service key 'allinkl'
- dialogAccount.xml: show Zone field for service_allinkl
- DynDNS.xml: add allinkl to static service list (ddclient backend fallback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* dns/ddclient: address review feedback for all-inkl.com provider

- Remove allinkl entry from DynDNS.xml; known_services() handles
  registration automatically for Python providers
- Replace regex-based XML parsing with xml.etree.ElementTree:
  fault detection, record lookup and update success check
- Also catches ET.ParseError for malformed responses
- Fix German comments in docstring to English

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Carsten <carsten@kallies-net.de>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cakallie 2026-04-03 14:55:45 +02:00 committed by GitHub
parent d1ebcc49ad
commit 2d3ee9f491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 338 additions and 1 deletions

View file

@ -62,7 +62,7 @@
<id>account.zone</id>
<label>Zone</label>
<type>text</type>
<style>optional_setting service_aws service_zoneedit1 service_cloudflare service_nsupdate service_gandi service_godaddy service_nfsn service_hetzner service_digitalocean service_dnspodcn</style>
<style>optional_setting service_aws service_zoneedit1 service_cloudflare service_nsupdate service_gandi service_godaddy service_nfsn service_hetzner service_digitalocean service_dnspodcn service_allinkl</style>
<help>Zone containing the host entry.</help>
</field>
<field>

View file

@ -0,0 +1,337 @@
"""
Copyright (c) 2026 Carsten Kallies
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.
all-inkl.com KAS API DynDNS provider for OPNsense ddclient.
Uses the KAS SOAP API (KasApi.wsdl) to update A/AAAA records.
API endpoint: https://kasapi.kasserver.com/soap/KasApi.php
WSDL: https://kasapi.kasserver.com/soap/wsdl/KasApi.wsdl
UI fields:
username - KAS login (all-inkl username, e.g. "w0xxxxx")
password - KAS password (plaintext, transmitted over HTTPS)
hostnames - FQDN(s) to update, comma-separated (e.g. "example.com,*.example.com")
zone - DNS zone (e.g. "example.com"); derived from hostname if left empty
"""
import json
import syslog
import time
import xml.etree.ElementTree as ET
import requests
from . import BaseAccount
class AllInkl(BaseAccount):
"""all-inkl.com DynDNS via KAS SOAP API (KasApi)."""
_priority = 65535
_services = {
'allinkl': 'kasapi.kasserver.com'
}
_URL = 'https://kasapi.kasserver.com/soap/KasApi.php'
_ACTION = '"urn:xmethodsKasApi#KasApi"'
def __init__(self, account: dict):
super().__init__(account)
@staticmethod
def known_services():
return {'allinkl': 'all-inkl.com (KAS API)'}
@staticmethod
def match(account):
return account.get('service') in AllInkl._services
# ------------------------------------------------------------------
# SOAP / KAS helpers
# ------------------------------------------------------------------
def _build_envelope(self, params_dict):
"""Build a KasApi SOAP envelope. params_dict is JSON-serialised into <Params>."""
params_json = json.dumps(params_dict)
# Escape XML special characters in the JSON string
params_json = (params_json
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
return (
'<?xml version="1.0" encoding="utf-8"?>'
'<SOAP-ENV:Envelope'
' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"'
' xmlns:ns1="urn:xmethodsKasApi"'
' xmlns:xsd="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"'
' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
'<SOAP-ENV:Body>'
'<ns1:KasApi>'
'<Params xsi:type="xsd:string">' + params_json + '</Params>'
'</ns1:KasApi>'
'</SOAP-ENV:Body>'
'</SOAP-ENV:Envelope>'
)
def _kas_api(self, action, request_params):
"""Execute a KAS API action. Returns response text or None on failure."""
params = {
'kas_login': self.settings.get('username', ''),
'kas_auth_type': 'plain',
'kas_auth_data': self.settings.get('password', ''),
'kas_action': action,
'KasRequestParams': request_params,
}
envelope = self._build_envelope(params)
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s KAS action '%s' params: %s" % (
self.description, action, json.dumps(request_params)
)
)
try:
resp = requests.post(
self._URL,
data=envelope.encode('utf-8'),
headers={
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': self._ACTION,
'User-Agent': 'OPNsense-dyndns',
},
timeout=30
)
except requests.RequestException as exc:
syslog.syslog(
syslog.LOG_ERR,
"Account %s KAS request failed: %s" % (self.description, exc)
)
return None
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s KAS '%s' HTTP %d: %s" % (
self.description, action, resp.status_code, resp.text[:600]
)
)
try:
root = ET.fromstring(resp.text)
except ET.ParseError:
syslog.syslog(
syslog.LOG_ERR,
"Account %s KAS '%s' invalid XML response: %s" % (
self.description, action, resp.text[:200]
)
)
return None
fault = root.find('.//{*}Fault')
if fault is not None:
faultstring = fault.find('{*}faultstring') or fault.find('faultstring')
syslog.syslog(
syslog.LOG_ERR,
"Account %s KAS '%s' SOAP fault: %s" % (
self.description, action,
faultstring.text if faultstring is not None else resp.text[:200]
)
)
return None
return resp.text
# ------------------------------------------------------------------
# Response parsing
# ------------------------------------------------------------------
def _find_record_id(self, xml_text, record_label, record_type):
"""
Parse get_dns_settings response and return record_id for the matching
record_name / record_type, or None.
The KAS response contains ns2:Map items with key/value pairs:
<item><key ...>record_name</key><value ...>dyn</value></item>
<item><key ...>record_type</key><value ...>A</value></item>
<item><key ...>record_id</key><value ...>12345</value></item>
"""
root = ET.fromstring(xml_text)
for map_item in root.findall('.//{*}KasApiResponse//{*}item'):
kv = {}
for sub in map_item.findall('{*}item'):
key_el = sub.find('{*}key')
value_el = sub.find('{*}value')
if key_el is not None and value_el is not None:
kv[key_el.text or ''] = value_el.text or ''
if kv.get('record_name') == record_label and kv.get('record_type') == record_type:
return kv.get('record_id')
return None
# ------------------------------------------------------------------
# Zone / label helpers
# ------------------------------------------------------------------
def _get_zone(self, hostname):
"""Return the DNS zone for a hostname (from config or derived)."""
zone = self.settings.get('zone', '').strip().rstrip('.')
if zone:
return zone
parts = hostname.split('.')
if len(parts) > 2:
return '.'.join(parts[1:])
return hostname
def _get_label(self, hostname, zone):
"""Return the record label (left of zone) for a hostname.
Examples:
dyn.example.com / zone example.com 'dyn'
*.example.com / zone example.com '*'
example.com / zone example.com '' (root record)
"""
if hostname == zone:
return ''
if hostname.endswith('.' + zone):
return hostname[:-len(zone) - 1]
return hostname.split('.')[0]
# ------------------------------------------------------------------
# Main entry point
# ------------------------------------------------------------------
def execute(self):
if not super().execute():
return False
record_type = "AAAA" if ':' in str(self.current_address) else "A"
hostnames_raw = self.settings.get('hostnames', '')
hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()]
if not hostnames:
syslog.syslog(
syslog.LOG_ERR,
"Account %s no hostnames configured" % self.description
)
return False
all_success = True
last_zone = None
dns_response = None
for hostname in hostnames:
zone = self._get_zone(hostname)
zone_host = zone + '.'
label = self._get_label(hostname, zone)
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s updating %s (zone: %s, label: '%s', type: %s) → %s" % (
self.description, hostname, zone_host,
label, record_type, self.current_address
)
)
# Fetch DNS records once per zone (cache for multiple hostnames in same zone)
if zone != last_zone:
dns_response = self._kas_api('get_dns_settings', {'zone_host': zone_host})
last_zone = zone
if dns_response is None:
syslog.syslog(
syslog.LOG_ERR,
"Account %s failed to retrieve DNS settings for %s" % (
self.description, zone_host
)
)
all_success = False
continue
# Respect KasFloodDelay between consecutive API calls
time.sleep(2)
record_id = self._find_record_id(dns_response, label, record_type)
if record_id is None:
syslog.syslog(
syslog.LOG_ERR,
"Account %s record '%s' type %s not found in zone %s" % (
self.description, label, record_type, zone_host
)
)
all_success = False
continue
if self.is_verbose:
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s found record_id %s for label '%s' %s" % (
self.description, record_id, label, record_type
)
)
update_response = self._kas_api('update_dns_settings', {
'zone_host': zone_host,
'record_id': record_id,
'record_name': label,
'record_type': record_type,
'record_data': str(self.current_address),
'record_aux': '0',
})
if update_response is None:
all_success = False
continue
update_root = ET.fromstring(update_response)
if any(v.text and v.text.upper() == 'TRUE'
for v in update_root.findall('.//{*}value')):
syslog.syslog(
syslog.LOG_NOTICE,
"Account %s set new IP %s for %s" % (
self.description, self.current_address, hostname
)
)
else:
syslog.syslog(
syslog.LOG_ERR,
"Account %s update_dns_settings failed for %s: %s" % (
self.description, hostname, update_response[:300]
)
)
all_success = False
time.sleep(2)
if all_success:
self.update_state(address=self.current_address)
return True
return False