This commit is contained in:
Melvin Groenhoff 2026-02-18 16:58:16 -08:00 committed by GitHub
commit f8cdf07a3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 230 additions and 3 deletions

View file

@ -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_transip</style>
<help>Zone containing the host entry.</help>
</field>
<field>
@ -77,7 +77,7 @@
<id>account.ttl</id>
<label>TTL</label>
<type>text</type>
<style>optional_setting service_aws service_netcup service_gandi service_godaddy</style>
<style>optional_setting service_aws service_netcup service_gandi service_godaddy service_transip</style>
<help>Time to Live for the DNS entry</help>
</field>
<field>

View file

@ -84,6 +84,7 @@
<sitelutions>Sitelutions</sitelutions>
<spdyn>spDYN</spdyn>
<strato>STRATO</strato>
<transip>TransIP</transip>
<woima>Woima</woima>
<yandex>Yandex</yandex>
<zoneedit1>Zoneedit</zoneedit1>

View file

@ -0,0 +1,226 @@
"""
Copyright (c) 2026 Melvin Groenhoff <melvingroenhoff@gmail.com>
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

View file

@ -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}}, \