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..fcc0a2ed1 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. @@ -77,7 +77,7 @@ account.ttl text - + Time to Live for the DNS entry diff --git a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml index 75c390ce1..99c7a1b41 100644 --- a/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml +++ b/dns/ddclient/src/opnsense/mvc/app/models/OPNsense/DynDNS/DynDNS.xml @@ -84,6 +84,7 @@ Sitelutions spDYN STRATO + TransIP Woima Yandex Zoneedit diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/transip.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/transip.py new file mode 100644 index 000000000..d6bd95188 --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/transip.py @@ -0,0 +1,226 @@ +""" + Copyright (c) 2026 Melvin Groenhoff + 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. +""" +from . import BaseAccount +import base64 +import cryptography +import cryptography.hazmat.primitives.asymmetric.padding +import cryptography.hazmat.primitives.hashes +import cryptography.hazmat.primitives.serialization +import re +import requests +import secrets +import syslog +import ujson + +class TransIP(BaseAccount): + _priority = 65535 + + _services = ['transip'] + + _api_base = "https://api.transip.nl/v6" + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + return {'transip': 'TransIP'} + + @staticmethod + def match(account): + return account.get('service') in TransIP._services + + def execute(self): + """Update DNS records according to https://api.transip.nl/rest/docs.html#domains-domains""" + if super().execute(): + domain = self.settings.get('zone') + + access_token = self._get_access_token(self.settings.get('password')) + if not access_token: + return False + + dns_entries = self._get_dns_entries(access_token, domain) + + # Determine record type based on the IP format + record_type = "AAAA" if str(self.current_address).find(':') > -1 else "A" + + # Update DNS records + record_names = self.settings.get('hostnames', '').split(',') + records_updated = 0 + for dns_entry in dns_entries: + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Found DNS entry %s (%s)" + % (dns_entry["name"], dns_entry["type"]) + ) + + for record_name in record_names: + # Only update A/AAAA records + if record_name == dns_entry["name"] and record_type == dns_entry["type"]: + if dns_entry["content"] != str(self.current_address): + content = str(self.current_address) + expire = int(self.settings.get('ttl', 300)) + + if self._update_record(access_token, domain, record_name, record_type, content, expire): + records_updated += 1 + + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updated record %s (%s) from %s to %s and ttl from %s to %s" % ( + self.description, + record_name, + record_type, + dns_entry["content"], + content, + dns_entry["expire"], + expire + ) + ) + + if records_updated > 0: + self.update_state(address=self.current_address) + return True + + return False + + def _normalize_pem(self, data): + m = re.match(r'(?ims)(-----BEGIN .+-----)(.+)(-----END .+-----)', re.sub(r'[\r\n]', '', data.strip())) + if not m: + return None + + groups = list(m.groups()) + groups[1] = groups[1].replace(' ', '') + return "\n".join(groups) + + def _get_access_token(self, private_key): + """Retrieve auth token according to https://api.transip.nl/rest/docs.html#header-authentication""" + + private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key( + self._normalize_pem(private_key).encode(), + password=None + ) + + data = ujson.dumps({ + "login": self.settings.get('username'), + "nonce": secrets.token_hex(16), + "read_only": False, + "expiration_time": "30 seconds", + "label": "OPNsense-dyndns", + "global_key": True # Bypass IP whitelist because that's the whole point of dynamic dns + }) + + signature = private_key.sign( + data.encode(), + cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15(), + cryptography.hazmat.primitives.hashes.SHA512() + ) + + headers = { + "Content-Type": "application/json", + "Signature": base64.b64encode(signature) + } + + response = requests.post(f"{self._api_base}/auth", data=data, headers=headers) + + if 200 < response.status_code >= 300: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error getting auth token: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return None + + try: + payload = response.json() + except requests.exceptions.JSONDecodeError: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response: %s" % (self.description, response.text) + ) + return None + + return payload.get("token") + + def _get_dns_entries(self, access_token, domain): + url = f"{self._api_base}/domains/{domain}/dns" + + headers = { + "Authorization": f"Bearer {access_token}" + } + + response = requests.get(url, headers=headers) + + if 200 < response.status_code >= 300: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching dns entries: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return None + + try: + payload = response.json() + except requests.exceptions.JSONDecodeError: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response: %s" % (self.description, response.text) + ) + return None + + return payload.get("dnsEntries") + + def _update_record(self, access_token, domain, record_name, record_type, content, expire): + url = f"{self._api_base}/domains/{domain}/dns" + + data = { + "dnsEntry": { + "name": record_name, + "type": record_type, + "content": content, + "expire": expire + } + } + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + response = requests.patch(url, json=data, headers=headers) + + if 200 < response.status_code >= 300: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error updating dns entry: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + return True diff --git a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf index d82eb19d9..da9830d0e 100644 --- a/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf +++ b/dns/ddclient/src/opnsense/service/templates/OPNsense/ddclient/ddclient.conf @@ -44,7 +44,7 @@ protocol={{account.service}}, \ dynurl=https://ipv4.cloudns.net/api/dynamicURL/?q={{account.password}}, \ {% elif account.service == 'hosting1984' %} protocol=1984, \ -{% elif account.service in ['godaddy', 'gandi'] %} +{% elif account.service in ['godaddy', 'gandi', 'transip'] %} protocol={{account.service}}, \ zone={{account.zone}}, \ ttl={{account.ttl}}, \