From 6a6f5aedefbdf3d13942355f066af804b16f535b Mon Sep 17 00:00:00 2001 From: Oliver Traber Date: Thu, 9 Oct 2025 09:01:28 +0200 Subject: [PATCH] dns/ddclient: Add support for PowerDNS API (#4772) --- .../OPNsense/DynDNS/forms/dialogAccount.xml | 2 +- .../mvc/app/models/OPNsense/DynDNS/DynDNS.php | 10 + .../scripts/ddclient/lib/account/powerdns.py | 209 ++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 dns/ddclient/src/opnsense/scripts/ddclient/lib/account/powerdns.py 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 eb0fb768e..8eada3ee4 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 @@ -31,7 +31,7 @@ When a URI is provided, the tag __MYIP__ will be replaced with the current detected address for this service and __HOSTNAME__ will contain the (comma separated) list of hostnames provided. - + account.resourceId diff --git a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.php b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.php index 31682aae0..a3cbb5a86 100644 --- a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.php +++ b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.php @@ -52,6 +52,16 @@ class DynDNS extends BaseModel } } foreach ($validate_servers as $key => $node) { + if ((string)$node->service == 'powerdns') { + if (empty($srv) || filter_var($srv, FILTER_VALIDATE_URL) === false) { + $messages->appendMessage( + new Message( + gettext("A valid URI is required."), + $key . ".server" + ) + ); + } + } if ((string)$node->service != 'custom') { continue; } diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/powerdns.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/powerdns.py new file mode 100644 index 000000000..ea931a0cf --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/powerdns.py @@ -0,0 +1,209 @@ +""" + Copyright (c) 2023 Ad Schellevis + Copyright (c) 2024 Olly Baker + Copyright (c) 2025 Oliver Traber + 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. +""" +import syslog +import requests +from . import BaseAccount + + +class PowerDNS(BaseAccount): + + def __init__(self, account: dict): + super().__init__(account) + # min TTL set to 300 + self.settings['ttl'] = max(int(self.settings["ttl"]) if self.settings.get("ttl", "").isdigit() else 0, 300) + + @staticmethod + def known_services(): + return {"powerdns": "PowerDNS API"} + + @staticmethod + def match(account): + return account.get("service") in ['powerdns'] + + + def _send_request(self, method, url, params=None, json=None): + headers = { + "User-Agent": "OPNsense-dyndns", + "X-API-Key": self.settings.get("password"), + } + + base_url = "%s/api/v1/servers/%s" % ( + self.settings.get('server'), + self.settings.get("server_id", "localhost") + ) + + url = base_url + url + return requests.request(method=method, url=url, headers=headers, params=params, json=json) + + + def _find_zone_id(self, hostname): + # Get the zone that a record belongs to + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s trying to get zone ID for hostname %s" + % (self.description, hostname), + ) + + zone = hostname + while (True): + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s checking if zone %s exists" + % (self.description, zone), + ) + + response = self._send_request(method="GET", url="/zones", params={"zone": zone}) + + if response.status_code == 200: + try: + payload = response.json() + # Check if a zone was found + if len(payload) == 0: + # Move one up in hierarchy + zone = '.'.join(zone.split('.')[1:]) + # Fail if root is reached + if zone == "": + syslog.syslog( + syslog.LOG_ERR, + "Account %s error getting zone ID for hostname %s - No matching zone found on server" + % (self.description, hostname), + ) + return None + else: + continue + else: + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found zone %s for hostname %s" + % (self.description, zone, hostname), + ) + return payload[0].get("id") + except requests.exceptions.JSONDecodeError: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error getting zone ID for hostname %s - Failed to decode response as JSON. Response: %s" + % (self.description, hostname, response.text), + ) + return None + else: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error getting zone ID for hostname %s HTTP %s. Response: %s" + % ( + self.description, + hostname, + response.status_code, + response.text, + ), + ) + return None + + def _replace_rrset(self, hostname, zone_id, record_type, content): + # Replace or create rrset for record + payload = { + "rrsets": [ + { + "name": hostname, + "type": record_type, + "ttl": int(self.settings.get("ttl")), + "changetype": "REPLACE", + "records": [ + {"content": content} + ] + } + ] + } + + response = self._send_request(method="PATCH", url=("/zones/" + zone_id), json=payload) + if response.status_code == 204: + # Success + return True + else: + # Failure + syslog.syslog( + syslog.LOG_ERR, + "Account %s error updating hostname %s in zone %s - HTTP %s Response: %s" + % ( + self.description, + hostname, + zone_id, + response.status_code, + response.text, + ), + ) + return False + + def execute(self): + if super().execute(): + record_type = "AAAA" if str(self.current_address).find(":") > 1 else "A" + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s current IP is %s (%s)" + % (self.description, self.current_address, record_type), + ) + + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updating hostnames %s" + % (self.description, self.settings.get("hostnames", "")), + ) + + # Update each hostname + for hostname in self.settings.get("hostnames", "").split(","): + + # Make hostname absolute + if not hostname.endswith("."): + hostname = hostname + "." + + # Get id of zone the hostname belongs to + zone_id = self._find_zone_id(hostname) + + # If zone can't be found, skip + if zone_id == None: + continue + + # Update record set + if self._replace_rrset(hostname, zone_id, record_type, content=self.current_address) and self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s successfully updated hostname %s (%s) to IP %s" + % ( + self.description, + hostname, + record_type, + self.current_address, + ), + ) + self.update_state(address=self.current_address) + return True + return False