mirror of
https://github.com/opnsense/plugins.git
synced 2026-06-03 13:59:06 -04:00
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:
parent
d1ebcc49ad
commit
2d3ee9f491
2 changed files with 338 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
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
|
||||
Loading…
Reference in a new issue