Skip to content

Commit 30e676e

Browse files
committed
dns/ddclient: add bunny.net DNS provider
1 parent 135f2dc commit 30e676e

2 files changed

Lines changed: 277 additions & 1 deletion

File tree

dns/ddclient/src/opnsense/mvc/app/controllers/OPNsense/DynDNS/forms/dialogAccount.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
<id>account.zone</id>
6363
<label>Zone</label>
6464
<type>text</type>
65-
<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>
65+
<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 service_bunny</style>
6666
<help>Zone containing the host entry.</help>
6767
</field>
6868
<field>
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""
2+
Copyright (c) 2026 Theodoros Orfanidis <teoulas@gmail.com>
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice,
9+
this list of conditions and the following disclaimer.
10+
11+
2. Redistributions in binary form must reproduce the above copyright
12+
notice, this list of conditions and the following disclaimer in the
13+
documentation and/or other materials provided with the distribution.
14+
15+
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
16+
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
17+
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
18+
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
19+
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24+
POSSIBILITY OF SUCH DAMAGE.
25+
26+
bunny.net DNS provider for the OPNsense native Dynamic DNS backend.
27+
28+
API specification: https://core-api-public-docs.b-cdn.net/docs/v3/public.json
29+
30+
UI fields:
31+
zone - DNS zone domain
32+
password - API access key
33+
hostnames - FQDN(s) to update, comma-separated
34+
"""
35+
import syslog
36+
37+
import requests
38+
39+
from . import BaseAccount
40+
41+
42+
class Bunny(BaseAccount):
43+
"""Update existing bunny.net A and AAAA records."""
44+
_priority = 65535
45+
_services = {'bunny': 'api.bunny.net'}
46+
47+
def __init__(self, account: dict):
48+
super().__init__(account)
49+
50+
@staticmethod
51+
def known_services():
52+
return {'bunny': 'bunny.net'}
53+
54+
@staticmethod
55+
def match(account):
56+
return account.get('service') in Bunny._services
57+
58+
def _get_headers(self):
59+
return {
60+
'User-Agent': 'OPNsense-dyndns',
61+
'AccessKey': self.settings.get('password', ''),
62+
'Content-Type': 'application/json'
63+
}
64+
65+
def _get_zone(self, zone_domain, headers):
66+
matches = []
67+
page = 1
68+
url = 'https://%s/dnszone' % self._services[self.settings.get('service')]
69+
70+
while True:
71+
response = requests.get(
72+
url,
73+
headers=headers,
74+
params={'page': page, 'perPage': 1000, 'search': zone_domain}
75+
)
76+
if response.status_code != 200:
77+
syslog.syslog(
78+
syslog.LOG_ERR,
79+
"Account %s failed to find DNS zone: HTTP %d - %s" % (
80+
self.description, response.status_code, response.text.replace('\n', '')
81+
)
82+
)
83+
return None
84+
85+
try:
86+
payload = response.json()
87+
except ValueError:
88+
syslog.syslog(
89+
syslog.LOG_ERR,
90+
"Account %s failed to parse DNS zone response: %s" % (
91+
self.description, response.text.replace('\n', '')
92+
)
93+
)
94+
return None
95+
96+
items = payload.get('Items')
97+
if not isinstance(items, list):
98+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS zone response has no item list" % self.description)
99+
return None
100+
101+
matches.extend([
102+
item for item in items
103+
if str(item.get('Domain', '')).strip().rstrip('.').lower() == zone_domain.lower()
104+
])
105+
if not payload.get('HasMoreItems', False):
106+
break
107+
if not items:
108+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS zone pagination did not advance" % self.description)
109+
return None
110+
page += 1
111+
112+
if not matches:
113+
syslog.syslog(
114+
syslog.LOG_ERR,
115+
"Account %s could not find DNS zone %s" % (self.description, zone_domain)
116+
)
117+
return None
118+
if len(matches) > 1:
119+
syslog.syslog(
120+
syslog.LOG_ERR,
121+
"Account %s found multiple exact matches for DNS zone %s" % (self.description, zone_domain)
122+
)
123+
return None
124+
125+
zone_id = matches[0].get('Id')
126+
domain = str(matches[0].get('Domain', '')).strip().rstrip('.')
127+
if zone_id is None or not domain:
128+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS zone response is incomplete" % self.description)
129+
return None
130+
return str(zone_id), domain
131+
132+
def _list_records(self, zone_id, record_type, headers):
133+
records = []
134+
page = 1
135+
url = 'https://%s/dnszone/%s/records' % (self._services[self.settings.get('service')], zone_id)
136+
137+
while True:
138+
response = requests.get(
139+
url,
140+
headers=headers,
141+
params={'page': page, 'perPage': 1000, 'type': record_type}
142+
)
143+
if response.status_code != 200:
144+
syslog.syslog(
145+
syslog.LOG_ERR,
146+
"Account %s failed to list DNS records: HTTP %d - %s" % (
147+
self.description, response.status_code, response.text.replace('\n', '')
148+
)
149+
)
150+
return None
151+
152+
try:
153+
payload = response.json()
154+
except ValueError:
155+
syslog.syslog(
156+
syslog.LOG_ERR,
157+
"Account %s failed to parse DNS record response: %s" % (
158+
self.description, response.text.replace('\n', '')
159+
)
160+
)
161+
return None
162+
163+
items = payload.get('Items')
164+
if not isinstance(items, list):
165+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS record response has no item list" % self.description)
166+
return None
167+
168+
records.extend(items)
169+
if not payload.get('HasMoreItems', False):
170+
return records
171+
if not items:
172+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS record pagination did not advance" % self.description)
173+
return None
174+
page += 1
175+
176+
@staticmethod
177+
def _record_fqdn(record_name, zone_domain):
178+
name = str(record_name or '').strip().rstrip('.').lower()
179+
domain = zone_domain.strip().rstrip('.').lower()
180+
if name in ['', '@']:
181+
return domain
182+
if name == domain or name.endswith('.' + domain):
183+
return name
184+
return '%s.%s' % (name, domain)
185+
186+
def _update_record(self, zone_id, record_id, headers):
187+
response = requests.post(
188+
'https://%s/dnszone/%s/records/%s' % (
189+
self._services[self.settings.get('service')], zone_id, record_id
190+
),
191+
headers=headers,
192+
json={'Value': str(self.current_address)}
193+
)
194+
if response.status_code != 204:
195+
syslog.syslog(
196+
syslog.LOG_ERR,
197+
"Account %s failed to update DNS record %s: HTTP %d - %s" % (
198+
self.description, record_id, response.status_code, response.text.replace('\n', '')
199+
)
200+
)
201+
return False
202+
return True
203+
204+
def execute(self):
205+
if not super().execute():
206+
return False
207+
208+
configured_zone = str(self.settings.get('zone', '')).strip().rstrip('.')
209+
if not configured_zone:
210+
syslog.syslog(syslog.LOG_ERR, "Account %s has no DNS zone configured" % self.description)
211+
return False
212+
if not str(self.settings.get('password', '')).strip():
213+
syslog.syslog(syslog.LOG_ERR, "Account %s has no API key" % self.description)
214+
return False
215+
216+
hostnames = [
217+
hostname.strip().rstrip('.').lower()
218+
for hostname in self.settings.get('hostnames', '').split(',')
219+
if hostname.strip()
220+
]
221+
if not hostnames:
222+
syslog.syslog(syslog.LOG_ERR, "Account %s has no hostnames configured" % self.description)
223+
return False
224+
225+
headers = self._get_headers()
226+
zone = self._get_zone(configured_zone, headers)
227+
if zone is None:
228+
return False
229+
zone_id, zone_domain = zone
230+
231+
record_type = 1 if ':' in str(self.current_address) else 0
232+
records = self._list_records(zone_id, record_type, headers)
233+
if records is None:
234+
return False
235+
236+
all_success = True
237+
for hostname in hostnames:
238+
matches = [
239+
record for record in records
240+
if record.get('Type') == record_type and
241+
self._record_fqdn(record.get('Name'), zone_domain) == hostname
242+
]
243+
if not matches:
244+
syslog.syslog(
245+
syslog.LOG_ERR,
246+
"Account %s could not find hostname %s with record type %s" % (
247+
self.description, hostname, 'AAAA' if record_type == 1 else 'A'
248+
)
249+
)
250+
all_success = False
251+
continue
252+
if len(matches) > 1:
253+
syslog.syslog(
254+
syslog.LOG_ERR,
255+
"Account %s found multiple records for hostname %s with record type %s" % (
256+
self.description, hostname, 'AAAA' if record_type == 1 else 'A'
257+
)
258+
)
259+
all_success = False
260+
continue
261+
262+
record_id = matches[0].get('Id')
263+
if record_id is None or not self._update_record(zone_id, record_id, headers):
264+
all_success = False
265+
continue
266+
267+
if self.is_verbose:
268+
syslog.syslog(
269+
syslog.LOG_NOTICE,
270+
"Account %s set new IP %s for %s" % (self.description, self.current_address, hostname)
271+
)
272+
273+
if all_success:
274+
self.update_state(address=self.current_address)
275+
return True
276+
return False

0 commit comments

Comments
 (0)