Skip to content

Commit cdd9458

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

2 files changed

Lines changed: 249 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: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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+
@staticmethod
48+
def known_services():
49+
return {'bunny': 'bunny.net'}
50+
51+
@staticmethod
52+
def match(account):
53+
return account.get('service') in Bunny._services
54+
55+
def _get_items(self, url, headers, params, resource):
56+
items = []
57+
page = 1
58+
59+
while True:
60+
params['page'] = page
61+
response = requests.get(url, headers=headers, params=params)
62+
if response.status_code != 200:
63+
syslog.syslog(
64+
syslog.LOG_ERR,
65+
"Account %s failed to fetch %s: HTTP %d - %s" % (
66+
self.description, resource, response.status_code, response.text.replace('\n', '')
67+
)
68+
)
69+
return None
70+
71+
try:
72+
payload = response.json()
73+
except ValueError:
74+
syslog.syslog(
75+
syslog.LOG_ERR,
76+
"Account %s failed to parse %s response: %s" % (
77+
self.description, resource, response.text.replace('\n', '')
78+
)
79+
)
80+
return None
81+
82+
page_items = payload.get('Items')
83+
if not isinstance(page_items, list):
84+
syslog.syslog(
85+
syslog.LOG_ERR,
86+
"Account %s %s response has no item list" % (self.description, resource)
87+
)
88+
return None
89+
90+
items.extend(page_items)
91+
if not payload.get('HasMoreItems', False):
92+
return items
93+
if not page_items:
94+
syslog.syslog(
95+
syslog.LOG_ERR,
96+
"Account %s %s pagination did not advance" % (self.description, resource)
97+
)
98+
return None
99+
page += 1
100+
101+
def _get_zone(self, zone_domain, headers):
102+
url = 'https://%s/dnszone' % self._services[self.settings.get('service')]
103+
items = self._get_items(
104+
url,
105+
headers,
106+
{'perPage': 1000, 'search': zone_domain},
107+
'DNS zone'
108+
)
109+
if items is None:
110+
return None
111+
112+
matches = [
113+
item for item in items
114+
if str(item.get('Domain', '')).strip().rstrip('.').lower() == zone_domain.lower()
115+
]
116+
if not matches:
117+
syslog.syslog(
118+
syslog.LOG_ERR,
119+
"Account %s could not find DNS zone %s" % (self.description, zone_domain)
120+
)
121+
return None
122+
if len(matches) > 1:
123+
syslog.syslog(
124+
syslog.LOG_ERR,
125+
"Account %s found multiple exact matches for DNS zone %s" % (self.description, zone_domain)
126+
)
127+
return None
128+
129+
zone_id = matches[0].get('Id')
130+
domain = str(matches[0].get('Domain', '')).strip().rstrip('.')
131+
if zone_id is None or not domain:
132+
syslog.syslog(syslog.LOG_ERR, "Account %s DNS zone response is incomplete" % self.description)
133+
return None
134+
return str(zone_id), domain
135+
136+
def _list_records(self, zone_id, record_type, headers):
137+
url = 'https://%s/dnszone/%s/records' % (self._services[self.settings.get('service')], zone_id)
138+
return self._get_items(
139+
url,
140+
headers,
141+
{'perPage': 1000, 'type': record_type},
142+
'DNS record'
143+
)
144+
145+
@staticmethod
146+
def _record_fqdn(record_name, zone_domain):
147+
name = str(record_name or '').strip().rstrip('.').lower()
148+
domain = zone_domain.strip().rstrip('.').lower()
149+
if name in ['', '@']:
150+
return domain
151+
if name == domain or name.endswith('.' + domain):
152+
return name
153+
return '%s.%s' % (name, domain)
154+
155+
def _update_record(self, zone_id, record_id, headers):
156+
response = requests.post(
157+
'https://%s/dnszone/%s/records/%s' % (
158+
self._services[self.settings.get('service')], zone_id, record_id
159+
),
160+
headers=headers,
161+
json={'Value': str(self.current_address)}
162+
)
163+
if response.status_code != 204:
164+
syslog.syslog(
165+
syslog.LOG_ERR,
166+
"Account %s failed to update DNS record %s: HTTP %d - %s" % (
167+
self.description, record_id, response.status_code, response.text.replace('\n', '')
168+
)
169+
)
170+
return False
171+
return True
172+
173+
def execute(self):
174+
if not super().execute():
175+
return False
176+
177+
configured_zone = str(self.settings.get('zone', '')).strip().rstrip('.')
178+
if not configured_zone:
179+
syslog.syslog(syslog.LOG_ERR, "Account %s has no DNS zone configured" % self.description)
180+
return False
181+
if not str(self.settings.get('password', '')).strip():
182+
syslog.syslog(syslog.LOG_ERR, "Account %s has no API key" % self.description)
183+
return False
184+
185+
hostnames = [
186+
hostname.strip().rstrip('.').lower()
187+
for hostname in self.settings.get('hostnames', '').split(',')
188+
if hostname.strip()
189+
]
190+
if not hostnames:
191+
syslog.syslog(syslog.LOG_ERR, "Account %s has no hostnames configured" % self.description)
192+
return False
193+
194+
headers = {
195+
'User-Agent': 'OPNsense-dyndns',
196+
'AccessKey': self.settings.get('password', '')
197+
}
198+
zone = self._get_zone(configured_zone, headers)
199+
if zone is None:
200+
return False
201+
zone_id, zone_domain = zone
202+
203+
record_type, record_type_name = (1, 'AAAA') if ':' in str(self.current_address) else (0, 'A')
204+
records = self._list_records(zone_id, record_type, headers)
205+
if records is None:
206+
return False
207+
208+
all_success = True
209+
for hostname in hostnames:
210+
matches = [
211+
record for record in records
212+
if record.get('Type') == record_type and
213+
self._record_fqdn(record.get('Name'), zone_domain) == hostname
214+
]
215+
if not matches:
216+
syslog.syslog(
217+
syslog.LOG_ERR,
218+
"Account %s could not find hostname %s with record type %s" % (
219+
self.description, hostname, record_type_name
220+
)
221+
)
222+
all_success = False
223+
continue
224+
if len(matches) > 1:
225+
syslog.syslog(
226+
syslog.LOG_ERR,
227+
"Account %s found multiple records for hostname %s with record type %s" % (
228+
self.description, hostname, record_type_name
229+
)
230+
)
231+
all_success = False
232+
continue
233+
234+
record_id = matches[0].get('Id')
235+
if record_id is None or not self._update_record(zone_id, record_id, headers):
236+
all_success = False
237+
continue
238+
239+
if self.is_verbose:
240+
syslog.syslog(
241+
syslog.LOG_NOTICE,
242+
"Account %s set new IP %s for %s" % (self.description, self.current_address, hostname)
243+
)
244+
245+
if all_success:
246+
self.update_state(address=self.current_address)
247+
return True
248+
return False

0 commit comments

Comments
 (0)