diff --git a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml
index 0ffcf2040..3ada63a84 100644
--- a/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml
+++ b/dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml
@@ -62,7 +62,7 @@
account.zone
text
-
+
Zone containing the host entry.
diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/allinkl.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/allinkl.py
new file mode 100644
index 000000000..c799f6288
--- /dev/null
+++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/allinkl.py
@@ -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_json = json.dumps(params_dict)
+ # Escape XML special characters in the JSON string
+ params_json = (params_json
+ .replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>'))
+ return (
+ ''
+ ''
+ ''
+ ''
+ '' + params_json + ''
+ ''
+ ''
+ ''
+ )
+
+ 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:
+ - record_namedyn
+ - record_typeA
+ - record_id12345
+ """
+ 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