From 6c9e00d6e8a9f0e83be89dc4daa043ba3b1fc325 Mon Sep 17 00:00:00 2001 From: Ingo Lafrenz Date: Fri, 11 Aug 2023 23:08:00 +0200 Subject: [PATCH] dns/ddclient: add Netcup DNS API Netcup is a German hosting provider who offers an API for DNS manipulation which this plugin makes use of, see: - Wiki: https://www.netcup-wiki.de/wiki/DNS_API - Technical documentation: https://ccp.netcup.net/run/webservice/servers/endpoint.php --- .../OPNsense/DynDNS/forms/dialogAccount.xml | 9 +- .../scripts/ddclient/lib/account/netcup.py | 153 ++++++++++++++++++ .../templates/OPNsense/ddclient/ddclient.json | 1 + 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100755 dns/ddclient/src/opnsense/scripts/ddclient/lib/account/netcup.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 12aedb288a..701c4677c5 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 @@ -51,6 +51,13 @@ password Password associated with this account + + account.netcupAPIKey + + password + + Netcup API key. Enter Netcup customer number in username field and Netcup API password in password field. + account.wildcard @@ -77,7 +84,7 @@ account.ttl text - + Time to Live for the DNS entry diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/netcup.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/netcup.py new file mode 100755 index 0000000000..f56d2474f1 --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/netcup.py @@ -0,0 +1,153 @@ +""" + Copyright (c) 2023 Ingo Lafrenz + 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. + ---------------------------------------------------------------------------------------------------- + Netcup DNS provider, see https://ccp.netcup.net/run/webservice/servers/endpoint.php + +""" +import syslog +import requests +from . import BaseAccount + +class Netcup(BaseAccount): + _services = ['netcup'] + _netcupAPIURL = 'https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON' + + def __init__(self, account: dict): + super().__init__(account) + self.netcupCustomerNr = self.settings.get('username') + self.netcupAPIPassword = self.settings.get('password') + self.netcupAPIKey = self.settings.get('netcupAPIKey') + hostnames = self.settings.get('hostnames').split(',') + if (len(hostnames) > 1): + syslog.syslog(syslog.LOG_WARNING, "Multiple hostnames detected, ignoring all except first. Consider using CNAMEs or create separate DynDNS instances for each hostname.") + self.hostname, self.domain = hostnames[0].split('.', 1) + self.ttl = self.settings.get('ttl') + if self.ttl < 300: + syslog.syslog(syslog.LOG_WARNING, "TTL was auto corrected to 300s since Netcup doesn't allow smaller values.") + self.ttl = 300 + + @staticmethod + def known_services(): + return Netcup._services + + @staticmethod + def match(account): + return account.get('service') in Netcup._services + + def execute(self): + super().execute() + netcupAPISessionID = self._login() + dnsZoneInfo = self._infoDnsZone(netcupAPISessionID) + ttl = int(dnsZoneInfo['ttl']) + if (self.ttl != ttl): + dnsZoneInfo['ttl'] = '300' + self._updateDNSZone(dnsZoneInfo, netcupAPISessionID) + dnsRecordsInfo = self._infoDnsRecords(netcupAPISessionID) + recordType = 'AAAA' if ':' in self.current_address else 'A' + self._updateIpAddress(recordType, dnsRecordsInfo, netcupAPISessionID) + self._logout(netcupAPISessionID) + self.update_state(address = self.current_address) + return True + + def _login(self): + requestPayload = { + 'action': 'login', + 'param': { + 'customernumber': self.netcupCustomerNr, + 'apikey': self.netcupAPIKey, + 'apipassword': self.netcupAPIPassword + } + } + return Netcup._sendRequest(requestPayload)['responsedata']['apisessionid'] + + def _infoDnsZone(self, netcupAPISessionID): + return Netcup._sendRequest(self._createRequestPayload('infoDnsZone', netcupAPISessionID))['responsedata'] + + def _updateDNSZone(self, dnsZone, netcupAPISessionID): + return Netcup._sendRequest(self._createRequestPayload('updateDnsZone', netcupAPISessionID, {'dnszone': dnsZone}))['responsedata'] + + def _infoDnsRecords(self, netcupAPISessionID): + return Netcup._sendRequest(self._createRequestPayload('infoDnsRecords', netcupAPISessionID))['responsedata'] + + def _updateDnsRecords(self, hostRecord, netcupAPISessionID): + return Netcup._sendRequest(self._createRequestPayload('updateDnsRecords', netcupAPISessionID, {'dnsrecordset': {'dnsrecords': [hostRecord]}}))['responsedata'] + + def _logout(self, netcupAPISessionID): + requestPayload = { + 'action': 'logout', + 'param': { + 'customernumber': self.netcupCustomerNr, + 'apikey': self.netcupAPIKey, + 'apisessionid': netcupAPISessionID + } + } + return Netcup._sendRequest(requestPayload)['responsedata'] + + def _updateIpAddress(self, recordType, dnsRecordsInfo, netcupAPISessionID): + matchingRecords = [r for r in dnsRecordsInfo['dnsrecords'] if r['type'] == recordType and r['hostname'] == self.hostname] + if len(matchingRecords) > 1: + raise Exception(f'Too many {recordType} records for hostname {self.hostname} in DNS zone {self.domain}.') + hostRecord = { + 'id': matchingRecords[0]['id'], + 'hostname': matchingRecords[0]['hostname'], + 'type': matchingRecords[0]['type'], + 'priority': matchingRecords[0]['priority'], + 'destination': matchingRecords[0]['destination'], + 'deleterecord': matchingRecords[0]['deleterecord'], + 'state': matchingRecords[0]['state'], + } if matchingRecords else { + 'hostname': self.hostname, + 'type': recordType, + 'destination': None + } + currentNetcupIPAddress = hostRecord['destination'] + if self.current_address != currentNetcupIPAddress: + syslog.syslog(syslog.LOG_NOTICE, f'IP address change detected. Old IP: {currentNetcupIPAddress}, new IP: {self.current_address}') + hostRecord['destination'] = self.current_address + self._updateDnsRecords(hostRecord, netcupAPISessionID) + syslog.syslog(syslog.LOG_NOTICE, f'Successfully updated {recordType} record for {self.hostname}.{self.domain} to {self.current_address}') + else: + syslog.syslog(syslog.LOG_NOTICE, 'IP address has not changed. Nothing to do.') + + def _createRequestPayload(self, action, netcupAPISessionID, extraParameters = {}): + requestPayload = { + 'action': action, + 'param': { + 'domainname': self.domain, + 'customernumber': self.netcupCustomerNr, + 'apikey': self.netcupAPIKey, + 'apisessionid': netcupAPISessionID, + } + } + requestPayload['param'].update(extraParameters) + return requestPayload + + @staticmethod + def _sendRequest(requestPayload): + response = requests.post(Netcup._netcupAPIURL, json = requestPayload).json(); + if response['status'] == 'success': + return response + raise Exception(f"{requestPayload['action']} failed with status {response['status']}. response: {response}") + diff --git a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json index 64e0ec7bc1..bf18812ef4 100644 --- a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json +++ b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.json @@ -17,6 +17,7 @@ "resourceId": "{{ account.resourceId }}", "username": "{{ account.username }}", "password": "{{ account.password }}", + "netcupAPIKey": "{{ account.netcupAPIKey }}", "hostnames": "{{ account.hostnames }}", "wildcard": {{ "true" if account.wildcard == '1' else "false"}}, "zone": "{{ account.zone }}",