From 53f50911303d13c7759aa6f2645e07a57da63dd6 Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Thu, 11 Dec 2025 11:55:04 +0100 Subject: [PATCH 01/11] ddclient: add Hetzner DNS provider Add native support for Hetzner Cloud DNS API (api.hetzner.cloud). Hetzner is migrating from dns.hetzner.com to Cloud Console, with the old API shutting down in May 2026. Features: - Bearer token authentication - A and AAAA record support - Multiple hostnames (comma-separated) - Configurable TTL --- .../scripts/ddclient/lib/account/hetzner.py | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py new file mode 100644 index 0000000000..68074597ee --- /dev/null +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py @@ -0,0 +1,545 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + 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. + + Hetzner DNS providers for OPNsense DynDNS + + Supports both APIs: + - Hetzner DNS (api.hetzner.cloud) - new Cloud API for migrated zones + - Hetzner DNS Legacy (dns.hetzner.com) - old API, shutting down May 2026 +""" +import syslog +import requests +from . import BaseAccount + + +class Hetzner(BaseAccount): + """ + Hetzner Cloud DNS API provider + Uses the new Cloud API (api.hetzner.cloud) + API Documentation: https://docs.hetzner.cloud/#dns + """ + _priority = 65535 + + _services = ['hetzner'] + + _api_base = "https://api.hetzner.cloud/v1" + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + return {'hetzner': 'Hetzner DNS'} + + @staticmethod + def match(account): + return account.get('service') in Hetzner._services + + def _get_headers(self): + return { + 'User-Agent': 'OPNsense-dyndns', + 'Authorization': 'Bearer ' + self.settings.get('password', ''), + 'Content-Type': 'application/json' + } + + def _get_zone_name(self): + """Get zone name from settings - try 'zone' field first, then 'username' as fallback""" + zone_name = self.settings.get('zone', '').strip() + if not zone_name: + zone_name = self.settings.get('username', '').strip() + return zone_name + + def _get_zone_id(self, headers): + """Get zone ID by zone name""" + zone_name = self._get_zone_name() + + url = f"{self._api_base}/zones" + params = {'name': zone_name} + + response = requests.get(url, headers=headers, params=params) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching zones: 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 + + zones = payload.get('zones', []) + if not zones: + syslog.syslog( + syslog.LOG_ERR, + "Account %s zone '%s' not found" % (self.description, zone_name) + ) + return None + + zone_id = zones[0].get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found zone ID %s for %s" % (self.description, zone_id, zone_name) + ) + + return zone_id + + def _get_record(self, headers, zone_id, record_name, record_type): + """Get existing record by name and type""" + url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" + + response = requests.get(url, headers=headers) + + if response.status_code == 404: + return None + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return None + + try: + payload = response.json() + return payload.get('rrset') + except requests.exceptions.JSONDecodeError: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response: %s" % (self.description, response.text) + ) + return None + + def _update_record(self, headers, zone_id, record_name, record_type, address): + """Update existing record with new address""" + url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" + + data = { + 'records': [{'value': str(address)}], + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.put(url, headers=headers, json=data) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error updating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updated %s %s to %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _create_record(self, headers, zone_id, record_name, record_type, address): + """Create new record""" + url = f"{self._api_base}/zones/{zone_id}/rrsets" + + data = { + 'name': record_name, + 'type': record_type, + 'records': [{'value': str(address)}], + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in [200, 201]: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error creating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s created %s %s with %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _extract_record_name(self, hostname, zone_name): + """Extract record name from hostname, handling FQDN format""" + hostname = hostname.rstrip('.') + + if hostname.endswith('.' + zone_name): + record_name = hostname[:-len(zone_name) - 1] + elif hostname == zone_name: + record_name = '@' + else: + record_name = hostname + + if not record_name or record_name == '@': + record_name = '@' + + return record_name + + def execute(self): + if super().execute(): + record_type = "AAAA" if ':' in str(self.current_address) else "A" + headers = self._get_headers() + + zone_id = self._get_zone_id(headers) + if not zone_id: + return False + + zone_name = self._get_zone_name() + + hostnames_raw = self.settings.get('hostnames', '') + hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()] + + if not hostnames: + syslog.syslog( + syslog.LOG_ERR, + "Account %s no hostnames configured" % self.description + ) + return False + + all_success = True + for hostname in hostnames: + record_name = self._extract_record_name(hostname, zone_name) + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updating %s (record: %s, type: %s) to %s" % ( + self.description, hostname, record_name, record_type, self.current_address + ) + ) + + existing = self._get_record(headers, zone_id, record_name, record_type) + + if existing: + success = self._update_record( + headers, zone_id, record_name, record_type, self.current_address + ) + else: + success = self._create_record( + headers, zone_id, record_name, record_type, self.current_address + ) + + if success: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new IP %s for %s" % ( + self.description, self.current_address, hostname + ) + ) + else: + all_success = False + + if all_success: + self.update_state(address=self.current_address) + return True + + return False + + +class HetznerLegacy(BaseAccount): + """ + Hetzner DNS Console (Legacy) API provider + Uses the old API at dns.hetzner.com - will be shut down May 2026 + For zones not yet migrated to Hetzner Cloud Console + API Documentation: https://dns.hetzner.com/api-docs + """ + _priority = 65535 + + _services = ['hetzner-legacy'] + + _api_base = "https://dns.hetzner.com/api/v1" + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + return {'hetzner-legacy': 'Hetzner DNS Legacy (deprecated)'} + + @staticmethod + def match(account): + return account.get('service') in HetznerLegacy._services + + def _get_headers(self): + return { + 'User-Agent': 'OPNsense-dyndns', + 'Auth-API-Token': self.settings.get('password', ''), + 'Content-Type': 'application/json' + } + + def _get_zone_name(self): + """Get zone name from settings - try 'zone' field first, then 'username' as fallback""" + zone_name = self.settings.get('zone', '').strip() + if not zone_name: + zone_name = self.settings.get('username', '').strip() + return zone_name + + def _get_zone_id(self, headers): + """Get zone ID by zone name""" + zone_name = self._get_zone_name() + + url = f"{self._api_base}/zones" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching zones: 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 + + zones = payload.get('zones', []) + for zone in zones: + if zone.get('name') == zone_name: + zone_id = zone.get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found zone ID %s for %s" % (self.description, zone_id, zone_name) + ) + return zone_id + + syslog.syslog( + syslog.LOG_ERR, + "Account %s zone '%s' not found" % (self.description, zone_name) + ) + return None + + def _get_record_id(self, headers, zone_id, record_name, record_type): + """Get record ID by name and type""" + url = f"{self._api_base}/records" + params = {'zone_id': zone_id} + + response = requests.get(url, headers=headers, params=params) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching records: 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 + + records = payload.get('records', []) + for record in records: + if record.get('name') == record_name and record.get('type') == record_type: + record_id = record.get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found record ID %s for %s %s" % ( + self.description, record_id, record_name, record_type + ) + ) + return record_id + + return None + + def _update_record(self, headers, zone_id, record_id, record_name, record_type, address): + """Update existing record with new address""" + url = f"{self._api_base}/records/{record_id}" + + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': record_name, + 'value': str(address), + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.put(url, headers=headers, json=data) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error updating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updated %s %s to %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _create_record(self, headers, zone_id, record_name, record_type, address): + """Create new record""" + url = f"{self._api_base}/records" + + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': record_name, + 'value': str(address), + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in [200, 201]: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error creating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s created %s %s with %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _extract_record_name(self, hostname, zone_name): + """Extract record name from hostname, handling FQDN format""" + hostname = hostname.rstrip('.') + + if hostname.endswith('.' + zone_name): + record_name = hostname[:-len(zone_name) - 1] + elif hostname == zone_name: + record_name = '@' + else: + record_name = hostname + + if not record_name or record_name == '@': + record_name = '@' + + return record_name + + def execute(self): + if super().execute(): + record_type = "AAAA" if ':' in str(self.current_address) else "A" + headers = self._get_headers() + + zone_id = self._get_zone_id(headers) + if not zone_id: + return False + + zone_name = self._get_zone_name() + + hostnames_raw = self.settings.get('hostnames', '') + hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()] + + if not hostnames: + syslog.syslog( + syslog.LOG_ERR, + "Account %s no hostnames configured" % self.description + ) + return False + + all_success = True + for hostname in hostnames: + record_name = self._extract_record_name(hostname, zone_name) + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updating %s (record: %s, type: %s) to %s" % ( + self.description, hostname, record_name, record_type, self.current_address + ) + ) + + record_id = self._get_record_id(headers, zone_id, record_name, record_type) + + if record_id: + success = self._update_record( + headers, zone_id, record_id, record_name, record_type, self.current_address + ) + else: + success = self._create_record( + headers, zone_id, record_name, record_type, self.current_address + ) + + if success: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new IP %s for %s" % ( + self.description, self.current_address, hostname + ) + ) + else: + all_success = False + + if all_success: + self.update_state(address=self.current_address) + return True + + return False From 46f441cd64d6545f3647592966822b8a3e9586df Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Sat, 13 Dec 2025 01:04:24 +0100 Subject: [PATCH 02/11] fix: use DELETE+POST workaround for Cloud API PUT bug --- .../scripts/ddclient/lib/account/hetzner.py | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py index 68074597ee..aa07e2b44c 100644 --- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py +++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py @@ -144,34 +144,26 @@ def _get_record(self, headers, zone_id, record_name, record_type): return None def _update_record(self, headers, zone_id, record_name, record_type, address): - """Update existing record with new address""" - url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" - - data = { - 'records': [{'value': str(address)}], - 'ttl': int(self.settings.get('ttl', 300)) - } + """Update existing record with new address - response = requests.put(url, headers=headers, json=data) + NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update. + Workaround: DELETE old record, then POST new record. + """ + # DELETE old record first + delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" + delete_response = requests.delete(delete_url, headers=headers) - if response.status_code != 200: + if delete_response.status_code not in [200, 201, 204]: syslog.syslog( syslog.LOG_ERR, - "Account %s error updating record: HTTP %d - %s" % ( - self.description, response.status_code, response.text + "Account %s error deleting record for update: HTTP %d - %s" % ( + self.description, delete_response.status_code, delete_response.text ) ) return False - if self.is_verbose: - syslog.syslog( - syslog.LOG_NOTICE, - "Account %s updated %s %s to %s" % ( - self.description, record_name, record_type, address - ) - ) - - return True + # CREATE new record + return self._create_record(headers, zone_id, record_name, record_type, address) def _create_record(self, headers, zone_id, record_name, record_type, address): """Create new record""" From 67ac72c03f24a7254160e68723355941bc70ff84 Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Mon, 15 Dec 2025 21:05:45 +0100 Subject: [PATCH 03/11] Initial release v2.0.0 --- net/hclouddns/Makefile | 8 + net/hclouddns/pkg-descr | 16 + net/hclouddns/pkg-plist | 51 + .../src/etc/inc/plugins.inc.d/hclouddns.inc | 135 ++ .../src/etc/rc.syshook.d/monitor/50-hclouddns | 42 + .../HCloudDNS/Api/AccountsController.php | 215 ++ .../HCloudDNS/Api/EntriesController.php | 825 +++++++ .../HCloudDNS/Api/GatewaysController.php | 174 ++ .../HCloudDNS/Api/HetznerController.php | 519 +++++ .../HCloudDNS/Api/HistoryController.php | 303 +++ .../HCloudDNS/Api/ServiceController.php | 241 +++ .../HCloudDNS/Api/SettingsController.php | 364 ++++ .../OPNsense/HCloudDNS/DnsController.php | 46 + .../OPNsense/HCloudDNS/IndexController.php | 103 + .../OPNsense/HCloudDNS/SettingsController.php | 48 + .../HCloudDNS/forms/dialogAccount.xml | 32 + .../OPNsense/HCloudDNS/forms/dialogEntry.xml | 71 + .../HCloudDNS/forms/dialogGateway.xml | 38 + .../HCloudDNS/forms/dialogScheduled.xml | 34 + .../OPNsense/HCloudDNS/forms/failover.xml | 24 + .../OPNsense/HCloudDNS/forms/general.xml | 20 + .../app/models/OPNsense/HCloudDNS/ACL/ACL.xml | 9 + .../models/OPNsense/HCloudDNS/HCloudDNS.php | 39 + .../models/OPNsense/HCloudDNS/HCloudDNS.xml | 345 +++ .../models/OPNsense/HCloudDNS/Menu/Menu.xml | 10 + .../views/OPNsense/HCloudDNS/accounts.volt | 229 ++ .../mvc/app/views/OPNsense/HCloudDNS/dns.volt | 961 +++++++++ .../app/views/OPNsense/HCloudDNS/entries.volt | 358 +++ .../views/OPNsense/HCloudDNS/gateways.volt | 155 ++ .../app/views/OPNsense/HCloudDNS/general.volt | 366 ++++ .../app/views/OPNsense/HCloudDNS/index.volt | 1916 +++++++++++++++++ .../views/OPNsense/HCloudDNS/settings.volt | 921 ++++++++ .../app/views/OPNsense/HCloudDNS/status.volt | 397 ++++ .../app/views/OPNsense/HCloudDNS/zones.volt | 393 ++++ .../scripts/HCloudDNS/create_record.py | 77 + .../scripts/HCloudDNS/delete_record.py | 62 + .../scripts/HCloudDNS/gateway_health.py | 337 +++ .../scripts/HCloudDNS/get_hetzner_ip.py | 64 + .../opnsense/scripts/HCloudDNS/hcloud_api.py | 91 + .../scripts/HCloudDNS/lib/__init__.py | 6 + .../scripts/HCloudDNS/lib/hetzner_api.py | 632 ++++++ .../scripts/HCloudDNS/list_records.py | 62 + .../opnsense/scripts/HCloudDNS/list_zones.py | 49 + .../scripts/HCloudDNS/refresh_status.py | 136 ++ .../scripts/HCloudDNS/simulate_failover.py | 149 ++ .../src/opnsense/scripts/HCloudDNS/status.py | 124 ++ .../opnsense/scripts/HCloudDNS/test_notify.py | 184 ++ .../scripts/HCloudDNS/update_record.py | 78 + .../scripts/HCloudDNS/update_records.py | 304 +++ .../scripts/HCloudDNS/update_records_v2.py | 500 +++++ .../scripts/HCloudDNS/validate_token.py | 52 + .../ddclient/lib/account/hetzner_cloud.py | 275 +++ .../ddclient/lib/account/hetzner_legacy.py | 310 +++ .../conf/actions.d/actions_hclouddns.conf | 119 + 54 files changed, 13019 insertions(+) create mode 100644 net/hclouddns/Makefile create mode 100644 net/hclouddns/pkg-descr create mode 100644 net/hclouddns/pkg-plist create mode 100644 net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc create mode 100644 net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py create mode 100644 net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py create mode 100644 net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py create mode 100644 net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf diff --git a/net/hclouddns/Makefile b/net/hclouddns/Makefile new file mode 100644 index 0000000000..30dc093609 --- /dev/null +++ b/net/hclouddns/Makefile @@ -0,0 +1,8 @@ +PLUGIN_NAME= hclouddns +PLUGIN_VERSION= 2.0.0 +PLUGIN_COMMENT= Hetzner Cloud DNS Management with Multi-Zone and Failover +PLUGIN_MAINTAINER= info@arcan-it.de +PLUGIN_WWW= https://github.com/ArcanConsulting/os-hclouddns +PLUGIN_DEPENDS= python311 + +.include "../../Mk/plugins.mk" diff --git a/net/hclouddns/pkg-descr b/net/hclouddns/pkg-descr new file mode 100644 index 0000000000..3a34c9e81e --- /dev/null +++ b/net/hclouddns/pkg-descr @@ -0,0 +1,16 @@ +Hetzner Cloud DNS Management Plugin for OPNsense + +Features: +- Multi-account support (multiple Hetzner API tokens) +- Multi-zone DNS management +- Dynamic DNS with automatic failover between WAN interfaces +- IPv4 and IPv6 (Dual-Stack) support +- DNS record templates for quick setup +- Direct DNS management (view/edit/delete records) +- Change history with undo functionality +- Notifications (Email, Webhook, Ntfy) +- Configuration backup/restore + +Supports both Hetzner Cloud API and legacy DNS Console API. + +WWW: https://github.com/ArcanConsulting/os-hclouddns diff --git a/net/hclouddns/pkg-plist b/net/hclouddns/pkg-plist new file mode 100644 index 0000000000..f14463d2da --- /dev/null +++ b/net/hclouddns/pkg-plist @@ -0,0 +1,51 @@ +etc/inc/plugins.inc.d/hclouddns.inc +etc/rc.syshook.d/monitor/50-hclouddns +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php +opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php +opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml +opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php +opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml +opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml +opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt +opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt +opnsense/scripts/ddclient/lib/account/hetzner_cloud.py +opnsense/scripts/ddclient/lib/account/hetzner_legacy.py +opnsense/scripts/HCloudDNS/create_record.py +opnsense/scripts/HCloudDNS/delete_record.py +opnsense/scripts/HCloudDNS/gateway_health.py +opnsense/scripts/HCloudDNS/get_hetzner_ip.py +opnsense/scripts/HCloudDNS/hcloud_api.py +opnsense/scripts/HCloudDNS/lib/__init__.py +opnsense/scripts/HCloudDNS/lib/hetzner_api.py +opnsense/scripts/HCloudDNS/list_records.py +opnsense/scripts/HCloudDNS/list_zones.py +opnsense/scripts/HCloudDNS/refresh_status.py +opnsense/scripts/HCloudDNS/simulate_failover.py +opnsense/scripts/HCloudDNS/status.py +opnsense/scripts/HCloudDNS/test_notify.py +opnsense/scripts/HCloudDNS/update_record.py +opnsense/scripts/HCloudDNS/update_records.py +opnsense/scripts/HCloudDNS/update_records_v2.py +opnsense/scripts/HCloudDNS/validate_token.py +opnsense/service/conf/actions.d/actions_hclouddns.conf diff --git a/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc new file mode 100644 index 0000000000..f13564995b --- /dev/null +++ b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc @@ -0,0 +1,135 @@ +general->enabled == '1') { + $services[] = array( + 'description' => gettext('Hetzner Cloud Dynamic DNS'), + 'configd' => array( + 'restart' => array('hclouddns update'), + ), + 'name' => 'hclouddns', + ); + } + + return $services; +} + +/** + * Register cron jobs for HCloudDNS + * Only active when explicitly enabled - automatic triggers (gateway syshook, newwanip) + * handle most use cases without needing scheduled updates. + * @return array + */ +function hclouddns_cron() +{ + $jobs = []; + + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + // Cron is only registered when both service AND cron are enabled + if ((string)$mdl->general->enabled == '1' && (string)$mdl->general->cronEnabled == '1') { + // Use cronInterval setting (in minutes) - cast to string first as model fields are objects + $minutes = intval((string)$mdl->general->cronInterval); + if (empty($minutes) || $minutes < 1) { + $minutes = 5; // Default 5 minutes + } + if ($minutes > 60) { + $minutes = 60; + } + + // autocron format: [command, minute, hour, monthday, month, weekday] + $jobs[]['autocron'] = [ + '/usr/local/sbin/configctl hclouddns update', + "*/{$minutes}" + ]; + } + + return $jobs; +} + +/** + * Register plugin hooks - triggers on interface IP changes + * @return array + */ +function hclouddns_configure() +{ + return [ + 'newwanip' => ['hclouddns_configure_do:2'], + ]; +} + +/** + * Called when WAN IP changes - trigger DNS update + * @param bool $verbose + */ +function hclouddns_configure_do($verbose = false) +{ + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + if ((string)$mdl->general->enabled != '1') { + return; + } + + service_log('Hetzner Cloud DDNS: Interface IP changed, updating DNS...', $verbose); + + // Trigger update via configd + configd_run('hclouddns update'); + + service_log("done.\n", $verbose); +} + +/** + * Register syslog facility + * @return array + */ +function hclouddns_syslog() +{ + $logfacilities = []; + $logfacilities['hclouddns'] = ['facility' => ['hclouddns']]; + return $logfacilities; +} + +/** + * XML-RPC sync handler + * @return array + */ +function hclouddns_xmlrpc_sync() +{ + $result = array(); + $result['id'] = 'hclouddns'; + $result['section'] = 'OPNsense.HCloudDNS'; + $result['description'] = gettext('Hetzner Cloud Dynamic DNS'); + return array($result); +} diff --git a/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns b/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns new file mode 100644 index 0000000000..2f10e00944 --- /dev/null +++ b/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns @@ -0,0 +1,42 @@ +#!/bin/sh + +# +# Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +# 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. +# + +# HCloudDNS Gateway Monitor Syshook +# Called by rc.routing_configure when gateway status changes +# Arguments: $1 = comma-separated list of gateway names that triggered the alarm + +GATEWAYS="${1}" + +# Log the gateway alarm +logger -t hclouddns "Gateway alarm triggered for: ${GATEWAYS}" + +# Trigger async DNS update via configd (non-blocking) +# The -d flag runs the command detached so we don't block the routing reconfigure +/usr/local/sbin/configctl -d hclouddns update + +exit 0 diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php new file mode 100644 index 0000000000..101b365311 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php @@ -0,0 +1,215 @@ +searchBase( + 'accounts.account', + ['enabled', 'name', 'apiType', 'description'], + 'name' + ); + } + + /** + * Get single account + * @param string $uuid + * @return array + */ + public function getItemAction($uuid = null) + { + return $this->getBase('account', 'accounts.account', $uuid); + } + + /** + * Check if token already exists in another account + * @param string $token the token to check + * @param string $excludeUuid optional UUID to exclude (for updates) + * @return string|null account name if duplicate found, null otherwise + */ + private function findDuplicateToken($token, $excludeUuid = null) + { + if (empty($token)) { + return null; + } + + $mdl = $this->getModel(); + foreach ($mdl->accounts->account->iterateItems() as $uuid => $account) { + if ($excludeUuid !== null && $uuid === $excludeUuid) { + continue; + } + if ((string)$account->apiToken === $token) { + return (string)$account->name; + } + } + return null; + } + + /** + * Add new account + * @return array + */ + public function addItemAction() + { + // Check for duplicate token before adding + $postData = $this->request->getPost('account'); + if (is_array($postData) && !empty($postData['apiToken'])) { + $existingAccount = $this->findDuplicateToken($postData['apiToken']); + if ($existingAccount !== null) { + return [ + 'status' => 'error', + 'validations' => [ + 'account.apiToken' => sprintf('This token is already used by account "%s"', $existingAccount) + ] + ]; + } + } + return $this->addBase('account', 'accounts.account'); + } + + /** + * Update account + * @param string $uuid + * @return array + */ + public function setItemAction($uuid) + { + // Check for duplicate token before updating + $postData = $this->request->getPost('account'); + if (is_array($postData) && !empty($postData['apiToken'])) { + $existingAccount = $this->findDuplicateToken($postData['apiToken'], $uuid); + if ($existingAccount !== null) { + return [ + 'status' => 'error', + 'validations' => [ + 'account.apiToken' => sprintf('This token is already used by account "%s"', $existingAccount) + ] + ]; + } + } + return $this->setBase('account', 'accounts.account', $uuid); + } + + /** + * Delete account and all associated DNS entries (cascade delete) + * @param string $uuid + * @return array + */ + public function delItemAction($uuid) + { + if (empty($uuid)) { + return ['status' => 'error', 'message' => 'Invalid UUID']; + } + + $mdl = $this->getModel(); + + // Find and delete all entries associated with this account + $entriesToDelete = []; + foreach ($mdl->entries->entry->iterateItems() as $entryUuid => $entry) { + if ((string)$entry->account === $uuid) { + $entriesToDelete[] = $entryUuid; + } + } + + // Delete associated entries + $deletedEntries = 0; + foreach ($entriesToDelete as $entryUuid) { + $mdl->entries->entry->del($entryUuid); + $deletedEntries++; + } + + // Now delete the account itself + $result = $this->delBase('accounts.account', $uuid); + + // Add info about deleted entries to result + if ($deletedEntries > 0) { + $result['deletedEntries'] = $deletedEntries; + $result['message'] = "Account deleted along with $deletedEntries associated DNS entries"; + } + + return $result; + } + + /** + * Toggle account enabled status + * @param string $uuid + * @param int $enabled + * @return array + */ + public function toggleItemAction($uuid, $enabled = null) + { + return $this->toggleBase('accounts.account', $uuid, $enabled); + } + + /** + * Get count of entries associated with an account + * @param string $uuid + * @return array + */ + public function getEntryCountAction($uuid = null) + { + if (empty($uuid)) { + return ['status' => 'error', 'count' => 0]; + } + + $mdl = $this->getModel(); + $count = 0; + $entries = []; + + foreach ($mdl->entries->entry->iterateItems() as $entryUuid => $entry) { + if ((string)$entry->account === $uuid) { + $count++; + $entries[] = (string)$entry->recordName . '.' . (string)$entry->zoneName; + } + } + + return [ + 'status' => 'ok', + 'count' => $count, + 'entries' => $entries + ]; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php new file mode 100644 index 0000000000..387768ec90 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php @@ -0,0 +1,825 @@ +searchBase( + 'entries.entry', + ['enabled', 'account', 'zoneName', 'recordName', 'recordType', 'primaryGateway', 'failoverGateway', 'currentIp', 'status', 'linkedEntry'], + 'account,recordName' + ); + + // Load live state data + $stateFile = '/var/run/hclouddns_state.json'; + $state = []; + if (file_exists($stateFile)) { + $content = file_get_contents($stateFile); + $state = json_decode($content, true) ?? []; + } + + // Merge live data into results + if (isset($result['rows']) && isset($state['entries'])) { + foreach ($result['rows'] as &$row) { + $uuid = $row['uuid']; + if (isset($state['entries'][$uuid])) { + $entryState = $state['entries'][$uuid]; + $row['currentIp'] = $entryState['hetznerIp'] ?? $row['currentIp']; + $row['status'] = $entryState['status'] ?? $row['status']; + } + } + unset($row); + } + + return $result; + } + + /** + * Get entry by UUID + * @param string $uuid item unique id + * @return array entry data + */ + public function getItemAction($uuid = null) + { + return $this->getBase('entry', 'entries.entry', $uuid); + } + + /** + * Validate that failover gateway differs from primary + * @return array|null error response or null if valid + */ + private function validateGatewaySelection() + { + $entry = $this->request->getPost('entry'); + if (is_array($entry)) { + $primary = $entry['primaryGateway'] ?? ''; + $failover = $entry['failoverGateway'] ?? ''; + if (!empty($primary) && !empty($failover) && $primary === $failover) { + return [ + 'status' => 'error', + 'validations' => [ + 'entry.failoverGateway' => 'Failover gateway must be different from primary gateway' + ] + ]; + } + } + return null; + } + + /** + * Add new entry + * @return array save result + */ + public function addItemAction() + { + $validationError = $this->validateGatewaySelection(); + if ($validationError !== null) { + return $validationError; + } + return $this->addBase('entry', 'entries.entry'); + } + + /** + * Update entry + * @param string $uuid item unique id + * @return array save result + */ + public function setItemAction($uuid) + { + $validationError = $this->validateGatewaySelection(); + if ($validationError !== null) { + return $validationError; + } + return $this->setBase('entry', 'entries.entry', $uuid); + } + + /** + * Delete entry + * @param string $uuid item unique id + * @return array delete result + */ + public function delItemAction($uuid) + { + return $this->delBase('entries.entry', $uuid); + } + + /** + * Toggle entry enabled status + * If enabling an orphaned entry, recreate it at Hetzner first + * @param string $uuid item unique id + * @param string $enabled desired state (0/1), leave empty to toggle + * @return array result + */ + public function toggleItemAction($uuid, $enabled = null) + { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Entry not found']; + } + + $currentEnabled = (string)$node->enabled; + $currentStatus = (string)$node->status; + $newEnabled = ($enabled !== null) ? $enabled : ($currentEnabled === '1' ? '0' : '1'); + + // Check if enabling an orphaned entry - need to recreate at Hetzner first + if ($newEnabled === '1' && $currentStatus === 'orphaned') { + $accountUuid = (string)$node->account; + $zoneId = (string)$node->zoneId; + $recordName = (string)$node->recordName; + $recordType = (string)$node->recordType; + $ttl = (string)$node->ttl ?: '300'; + $primaryGateway = (string)$node->primaryGateway; + + // Get account token + $accountNode = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + if ($accountNode === null) { + return ['status' => 'error', 'message' => 'Account not found - cannot recreate record']; + } + + $token = (string)$accountNode->apiToken; + $apiType = (string)$accountNode->apiType ?: 'cloud'; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token']; + } + + // Get gateway IP + $gwNode = $mdl->getNodeByReference('gateways.gateway.' . $primaryGateway); + if ($gwNode === null) { + return ['status' => 'error', 'message' => 'Primary gateway not found']; + } + + // Use backend to get current gateway IP and create record + $backend = new Backend(); + + // Get gateway status to find IP + $gwStatusResponse = $backend->configdRun('hclouddns gatewaystatus'); + $gwStatus = json_decode(trim($gwStatusResponse), true); + $gwIp = ''; + if ($gwStatus && isset($gwStatus['gateways'][$primaryGateway])) { + $gw = $gwStatus['gateways'][$primaryGateway]; + $gwIp = ($recordType === 'AAAA') ? ($gw['ipv6'] ?? '') : ($gw['ipv4'] ?? ''); + } + + if (empty($gwIp)) { + return ['status' => 'error', 'message' => 'Could not get IP from gateway - is it online?']; + } + + // Create record at Hetzner + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $response = $backend->configdpRun('hclouddns dns create', [ + $token, $zoneId, $recordName, $recordType, $gwIp, $ttl, $apiType + ]); + $result = json_decode(trim($response), true); + + if (!$result || $result['status'] !== 'ok') { + $errMsg = $result['message'] ?? 'Unknown error'; + return ['status' => 'error', 'message' => "Failed to recreate record at Hetzner: $errMsg"]; + } + + // Update entry status to active and enable it + $node->enabled = '1'; + $node->status = 'active'; + $node->currentIp = $gwIp; + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + + return [ + 'status' => 'ok', + 'changed' => true, + 'message' => "Record recreated at Hetzner with IP $gwIp" + ]; + } + + // Normal toggle for non-orphaned entries + return $this->toggleBase('entries.entry', $uuid, $enabled); + } + + /** + * Pause/resume entry (sets status to paused/active) + * @param string $uuid entry UUID + * @return array result + */ + public function pauseAction($uuid) + { + $result = ['status' => 'error', 'message' => 'Invalid entry']; + + if ($uuid !== null) { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + if ($node !== null) { + $currentStatus = (string)$node->status; + if ($currentStatus === 'paused') { + $node->status = 'active'; + $result = ['status' => 'ok', 'newStatus' => 'active']; + } else { + $node->status = 'paused'; + $result = ['status' => 'ok', 'newStatus' => 'paused']; + } + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + } + } + + return $result; + } + + /** + * Batch add entries from zone selection + * @return array result + */ + /** + * Check if an entry already exists + * @param object $mdl the model + * @param string $account account UUID + * @param string $zoneId zone ID + * @param string $recordName record name + * @param string $recordType record type (A/AAAA) + * @return bool true if entry exists + */ + private function entryExists($mdl, $account, $zoneId, $recordName, $recordType) + { + foreach ($mdl->entries->entry->iterateItems() as $existing) { + if ((string)$existing->account === $account && + (string)$existing->zoneId === $zoneId && + (string)$existing->recordName === $recordName && + (string)$existing->recordType === $recordType) { + return true; + } + } + return false; + } + + public function batchAddAction() + { + $result = ['status' => 'error', 'message' => 'Invalid request']; + + if ($this->request->isPost()) { + $entries = $this->request->getPost('entries'); + $primaryGateway = $this->request->getPost('primaryGateway'); + $failoverGateway = $this->request->getPost('failoverGateway'); + $ttl = $this->request->getPost('ttl', 'int', 300); + + if (is_array($entries) && !empty($primaryGateway)) { + // Validate failover differs from primary + if (!empty($failoverGateway) && $primaryGateway === $failoverGateway) { + return ['status' => 'error', 'message' => 'Failover gateway must be different from primary gateway']; + } + + $mdl = $this->getModel(); + $added = 0; + $skipped = 0; + + foreach ($entries as $entry) { + if (isset($entry['zoneId'], $entry['zoneName'], $entry['recordName'], $entry['recordType'])) { + $account = $entry['account'] ?? ''; + // Skip if entry already exists (duplicate protection) + if ($this->entryExists($mdl, $account, $entry['zoneId'], $entry['recordName'], $entry['recordType'])) { + $skipped++; + continue; + } + $node = $mdl->entries->entry->Add(); + $node->enabled = '1'; + $node->account = $account; + $node->zoneId = $entry['zoneId']; + $node->zoneName = $entry['zoneName']; + $node->recordId = $entry['recordId'] ?? ''; + $node->recordName = $entry['recordName']; + $node->recordType = $entry['recordType']; + $node->primaryGateway = $primaryGateway; + $node->failoverGateway = $failoverGateway ?? ''; + $node->ttl = $entry['ttl'] ?? $ttl; + $node->status = 'pending'; + $added++; + } + } + + if ($added > 0) { + $validationMessages = $mdl->performValidation(); + if ($validationMessages->count() == 0) { + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + $result = ['status' => 'ok', 'added' => $added, 'skipped' => $skipped]; + } else { + $errors = []; + foreach ($validationMessages as $msg) { + $errors[] = (string)$msg->getMessage(); + } + $result = ['status' => 'error', 'message' => 'Validation failed', 'errors' => $errors]; + } + } elseif ($skipped > 0) { + $result = ['status' => 'ok', 'added' => 0, 'skipped' => $skipped, 'message' => 'All selected entries already exist']; + } else { + $result = ['status' => 'error', 'message' => 'No valid entries provided']; + } + } + } + + return $result; + } + + /** + * Batch update entries (change gateway, pause, delete) + * @return array result + */ + public function batchUpdateAction() + { + $result = ['status' => 'error', 'message' => 'Invalid request']; + + if ($this->request->isPost()) { + $uuids = $this->request->getPost('uuids'); + $action = $this->request->getPost('action'); + + if (is_array($uuids) && !empty($action)) { + $mdl = $this->getModel(); + $processed = 0; + + foreach ($uuids as $uuid) { + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + if ($node !== null) { + switch ($action) { + case 'pause': + $node->status = 'paused'; + $processed++; + break; + case 'resume': + $node->status = 'active'; + $processed++; + break; + case 'delete': + $mdl->entries->entry->del($uuid); + $processed++; + break; + case 'setGateway': + $gateway = $this->request->getPost('gateway'); + if (!empty($gateway)) { + $node->primaryGateway = $gateway; + $processed++; + } + break; + case 'setFailover': + $failover = $this->request->getPost('failover'); + $primary = (string)$node->primaryGateway; + // Validate failover differs from primary + if (!empty($failover) && $failover === $primary) { + continue 2; // Skip this entry + } + $node->failoverGateway = $failover ?? ''; + $processed++; + break; + } + } + } + + if ($processed > 0) { + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + $result = ['status' => 'ok', 'processed' => $processed]; + } else { + $result = ['status' => 'error', 'message' => 'No entries processed']; + } + } + } + + return $result; + } + + /** + * Get Hetzner IP for an entry (reads from Hetzner API) + * @param string $uuid entry UUID + * @return array IP information + */ + public function getHetznerIpAction($uuid = null) + { + $result = ['status' => 'error', 'message' => 'Invalid entry']; + + if ($uuid !== null) { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + if ($node !== null) { + $backend = new Backend(); + $zoneId = (string)$node->zoneId; + $recordName = (string)$node->recordName; + $recordType = (string)$node->recordType; + + $response = $backend->configdpRun('hclouddns gethetznerip', [$zoneId, $recordName, $recordType]); + $data = json_decode(trim($response), true); + if ($data !== null) { + $result = $data; + } else { + $result = ['status' => 'error', 'message' => 'Backend error']; + } + } + } + + return $result; + } + + /** + * Refresh all entries status from Hetzner + * Marks entries not found at Hetzner as 'orphaned' and disables them + * @return array status + */ + public function refreshStatusAction() + { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns refreshstatus'); + $data = json_decode(trim($response), true); + + if ($data === null) { + return ['status' => 'error', 'message' => 'Could not refresh status']; + } + + // Process entries and mark orphaned ones + $mdl = $this->getModel(); + $orphanedCount = 0; + $syncedCount = 0; + + if (isset($data['entries']) && is_array($data['entries'])) { + foreach ($data['entries'] as $entryStatus) { + $uuid = $entryStatus['uuid'] ?? ''; + if (empty($uuid)) { + continue; + } + + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + if ($node === null) { + continue; + } + + $currentStatus = (string)$node->status; + + // If record not found at Hetzner and not already orphaned/paused + if ($entryStatus['status'] === 'not_found' && !in_array($currentStatus, ['orphaned', 'paused'])) { + $node->status = 'orphaned'; + $node->enabled = '0'; // Disable orphaned entries + $node->currentIp = ''; // Clear current IP since it doesn't exist at Hetzner + $orphanedCount++; + } + // If record found at Hetzner and currently orphaned, update to active + elseif ($entryStatus['status'] === 'found' && $currentStatus === 'orphaned') { + $node->status = 'active'; + $node->currentIp = $entryStatus['hetznerIp'] ?? ''; + $syncedCount++; + } + // Update current IP for found records + elseif ($entryStatus['status'] === 'found' && !empty($entryStatus['hetznerIp'])) { + $node->currentIp = $entryStatus['hetznerIp']; + $syncedCount++; + } + } + } + + // Save if changes were made + if ($orphanedCount > 0 || $syncedCount > 0) { + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + } + + // Also check errors for entries with missing accounts - mark them as orphaned too + $accountMissingCount = 0; + if (isset($data['errors']) && is_array($data['errors'])) { + foreach ($data['errors'] as $errorEntry) { + $uuid = $errorEntry['uuid'] ?? ''; + if (empty($uuid)) { + continue; + } + + // Check if the error is about missing account/token + $errorMsg = $errorEntry['error'] ?? ''; + if (strpos($errorMsg, 'No valid account') !== false || strpos($errorMsg, 'token') !== false) { + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + if ($node !== null) { + $currentStatus = (string)$node->status; + if (!in_array($currentStatus, ['orphaned', 'paused'])) { + $node->status = 'orphaned'; + $node->enabled = '0'; + $node->currentIp = ''; + $accountMissingCount++; + } + } + } + } + } + + // Save if changes were made + if ($accountMissingCount > 0) { + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + $orphanedCount += $accountMissingCount; + } + + $data['orphanedCount'] = $orphanedCount; + $data['syncedCount'] = $syncedCount; + $data['accountMissingCount'] = $accountMissingCount; + if ($orphanedCount > 0) { + $msg = "$orphanedCount entries marked as orphaned"; + if ($accountMissingCount > 0) { + $msg .= " ($accountMissingCount with missing account)"; + } + $data['message'] = $msg; + } + + return $data; + } + + /** + * Get entries with live status from runtime state + * @return array entries with current IP and status + */ + public function liveStatusAction() + { + $result = [ + 'status' => 'ok', + 'entries' => [], + 'gateways' => [] + ]; + + // Load runtime state + $stateFile = '/var/run/hclouddns_state.json'; + $state = []; + if (file_exists($stateFile)) { + $content = file_get_contents($stateFile); + $state = json_decode($content, true) ?? []; + } + + // Get entries from model + $mdl = $this->getModel(); + $entries = $mdl->entries->entry; + + foreach ($entries->iterateItems() as $uuid => $entry) { + $entryState = $state['entries'][$uuid] ?? []; + $gatewayUuid = (string)$entry->primaryGateway; + $activeGateway = $entryState['activeGateway'] ?? $gatewayUuid; + + // Get gateway name + $gatewayName = ''; + if (!empty($activeGateway)) { + $gw = $mdl->getNodeByReference('gateways.gateway.' . $activeGateway); + if ($gw !== null) { + $gatewayName = (string)$gw->name; + } + } + + $result['entries'][] = [ + 'uuid' => $uuid, + 'enabled' => (string)$entry->enabled, + 'zoneName' => (string)$entry->zoneName, + 'recordName' => (string)$entry->recordName, + 'recordType' => (string)$entry->recordType, + 'primaryGateway' => $gatewayUuid, + 'failoverGateway' => (string)$entry->failoverGateway, + 'ttl' => (string)$entry->ttl, + 'currentIp' => $entryState['hetznerIp'] ?? '', + 'status' => $entryState['status'] ?? (string)$entry->status, + 'activeGateway' => $activeGateway, + 'activeGatewayName' => $gatewayName, + 'lastUpdate' => $entryState['lastUpdate'] ?? 0 + ]; + } + + // Add gateway status + $gateways = $mdl->gateways->gateway; + foreach ($gateways->iterateItems() as $uuid => $gw) { + $gwState = $state['gateways'][$uuid] ?? []; + $result['gateways'][$uuid] = [ + 'uuid' => $uuid, + 'name' => (string)$gw->name, + 'interface' => (string)$gw->interface, + 'status' => $gwState['status'] ?? 'unknown', + 'ipv4' => $gwState['ipv4'] ?? null, + 'ipv6' => $gwState['ipv6'] ?? null, + 'simulated' => $gwState['simulated'] ?? false + ]; + } + + $result['lastUpdate'] = $state['lastUpdate'] ?? 0; + + return $result; + } + + /** + * Create dual-stack (A + AAAA) linked entries + * @return array result with created UUIDs + */ + public function createDualStackAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $data = $this->request->getPost('entry'); + if (!is_array($data)) { + return ['status' => 'error', 'message' => 'Invalid entry data']; + } + + // Required fields + $required = ['account', 'zoneId', 'zoneName', 'recordName', 'primaryGateway']; + foreach ($required as $field) { + if (empty($data[$field])) { + return ['status' => 'error', 'message' => "Missing required field: $field"]; + } + } + + // Check for IPv6 gateway + $ipv6Gateway = $data['ipv6Gateway'] ?? ''; + if (empty($ipv6Gateway)) { + return ['status' => 'error', 'message' => 'IPv6 gateway is required for dual-stack']; + } + + $mdl = $this->getModel(); + + // Create A record + $aEntry = $mdl->entries->entry->Add(); + $aUuid = $aEntry->getAttributes()['uuid']; + $aEntry->enabled = $data['enabled'] ?? '1'; + $aEntry->account = $data['account']; + $aEntry->zoneId = $data['zoneId']; + $aEntry->zoneName = $data['zoneName']; + $aEntry->recordName = $data['recordName']; + $aEntry->recordType = 'A'; + $aEntry->primaryGateway = $data['primaryGateway']; + $aEntry->failoverGateway = $data['failoverGateway'] ?? ''; + $aEntry->ttl = $data['ttl'] ?? '300'; + $aEntry->status = 'pending'; + + // Create AAAA record + $aaaaEntry = $mdl->entries->entry->Add(); + $aaaaUuid = $aaaaEntry->getAttributes()['uuid']; + $aaaaEntry->enabled = $data['enabled'] ?? '1'; + $aaaaEntry->account = $data['account']; + $aaaaEntry->zoneId = $data['zoneId']; + $aaaaEntry->zoneName = $data['zoneName']; + $aaaaEntry->recordName = $data['recordName']; + $aaaaEntry->recordType = 'AAAA'; + $aaaaEntry->primaryGateway = $ipv6Gateway; + $aaaaEntry->failoverGateway = $data['ipv6FailoverGateway'] ?? ''; + $aaaaEntry->ttl = $data['ttl'] ?? '300'; + $aaaaEntry->status = 'pending'; + + // Link them together + $aEntry->linkedEntry = $aaaaUuid; + $aaaaEntry->linkedEntry = $aUuid; + + // Validate + $valMsgs = $mdl->performValidation(); + if ($valMsgs->count() > 0) { + $errors = []; + foreach ($valMsgs as $msg) { + $errors[] = $msg->getField() . ': ' . $msg->getMessage(); + } + return ['status' => 'error', 'message' => 'Validation failed', 'errors' => $errors]; + } + + // Save + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + + return [ + 'status' => 'ok', + 'aUuid' => $aUuid, + 'aaaaUuid' => $aaaaUuid, + 'message' => 'Dual-stack entries created successfully' + ]; + } + + /** + * Get linked entry info + * @param string $uuid entry UUID + * @return array linked entry information + */ + public function getLinkedAction($uuid = null) + { + if (empty($uuid)) { + return ['status' => 'error', 'message' => 'UUID required']; + } + + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('entries.entry.' . $uuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Entry not found']; + } + + $linkedUuid = (string)$node->linkedEntry; + if (empty($linkedUuid)) { + return ['status' => 'ok', 'hasLinked' => false]; + } + + $linkedNode = $mdl->getNodeByReference('entries.entry.' . $linkedUuid); + if ($linkedNode === null) { + return ['status' => 'ok', 'hasLinked' => false, 'linkedBroken' => true]; + } + + return [ + 'status' => 'ok', + 'hasLinked' => true, + 'linkedUuid' => $linkedUuid, + 'linkedType' => (string)$linkedNode->recordType, + 'linkedEnabled' => (string)$linkedNode->enabled, + 'linkedStatus' => (string)$linkedNode->status + ]; + } + + /** + * Get existing entries for an account (for import duplicate detection) + * @return array list of existing entry keys (zoneId:recordName:recordType) + */ + public function getExistingForAccountAction() + { + $result = ['status' => 'ok', 'entries' => []]; + + if ($this->request->isPost()) { + $accountUuid = $this->request->getPost('account_uuid', 'string', ''); + + if (!empty($accountUuid)) { + $mdl = $this->getModel(); + foreach ($mdl->entries->entry->iterateItems() as $uuid => $entry) { + if ((string)$entry->account === $accountUuid) { + $result['entries'][] = [ + 'uuid' => $uuid, + 'zoneId' => (string)$entry->zoneId, + 'zoneName' => (string)$entry->zoneName, + 'recordName' => (string)$entry->recordName, + 'recordType' => (string)$entry->recordType + ]; + } + } + } + } + + return $result; + } + + /** + * Remove all orphaned entries + * @return array result with count of removed entries + */ + public function removeOrphanedAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $mdl = $this->getModel(); + $removed = []; + $uuidsToRemove = []; + + // First pass: collect orphaned entry UUIDs + foreach ($mdl->entries->entry->iterateItems() as $uuid => $entry) { + if ((string)$entry->status === 'orphaned') { + $uuidsToRemove[] = $uuid; + $removed[] = [ + 'uuid' => $uuid, + 'recordName' => (string)$entry->recordName, + 'zoneName' => (string)$entry->zoneName, + 'recordType' => (string)$entry->recordType + ]; + } + } + + if (empty($uuidsToRemove)) { + return [ + 'status' => 'ok', + 'message' => 'No orphaned entries found', + 'removedCount' => 0, + 'removed' => [] + ]; + } + + // Second pass: remove entries + foreach ($uuidsToRemove as $uuid) { + $mdl->entries->entry->del($uuid); + } + + // Save changes + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + + return [ + 'status' => 'ok', + 'message' => count($removed) . ' orphaned entries removed', + 'removedCount' => count($removed), + 'removed' => $removed + ]; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php new file mode 100644 index 0000000000..a99274da66 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php @@ -0,0 +1,174 @@ +searchBase('gateways.gateway', ['enabled', 'name', 'interface', 'priority', 'checkipMethod']); + } + + /** + * Get gateway by UUID + * @param string $uuid item unique id + * @return array gateway data + */ + public function getItemAction($uuid = null) + { + return $this->getBase('gateway', 'gateways.gateway', $uuid); + } + + /** + * Add new gateway + * @return array save result + */ + public function addItemAction() + { + return $this->addBase('gateway', 'gateways.gateway'); + } + + /** + * Update gateway + * @param string $uuid item unique id + * @return array save result + */ + public function setItemAction($uuid) + { + return $this->setBase('gateway', 'gateways.gateway', $uuid); + } + + /** + * Delete gateway + * @param string $uuid item unique id + * @return array delete result + */ + public function delItemAction($uuid) + { + return $this->delBase('gateways.gateway', $uuid); + } + + /** + * Toggle gateway enabled status + * @param string $uuid item unique id + * @param string $enabled desired state (0/1), leave empty to toggle + * @return array result + */ + public function toggleItemAction($uuid, $enabled = null) + { + return $this->toggleBase('gateways.gateway', $uuid, $enabled); + } + + /** + * Check health of a specific gateway + * @param string $uuid gateway UUID + * @return array health check result + */ + public function checkHealthAction($uuid = null) + { + $result = ['status' => 'error', 'message' => 'Invalid gateway']; + + if ($uuid !== null) { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('gateways.gateway.' . $uuid); + if ($node !== null) { + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns healthcheck', [$uuid]); + $data = json_decode(trim($response), true); + if ($data !== null) { + $result = $data; + } else { + $result = ['status' => 'error', 'message' => 'Backend error', 'raw' => $response]; + } + } + } + + return $result; + } + + /** + * Get current IP for a gateway + * @param string $uuid gateway UUID + * @return array IP information + */ + public function getIpAction($uuid = null) + { + $result = ['status' => 'error', 'message' => 'Invalid gateway']; + + if ($uuid !== null) { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('gateways.gateway.' . $uuid); + if ($node !== null) { + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns getip', [$uuid]); + $data = json_decode(trim($response), true); + if ($data !== null) { + $result = $data; + } else { + $result = ['status' => 'error', 'message' => 'Backend error', 'raw' => $response]; + } + } + } + + return $result; + } + + /** + * Get status of all gateways + * @return array status information + */ + public function statusAction() + { + $result = [ + 'status' => 'ok', + 'gateways' => [] + ]; + + // Load runtime state for simulation status + $stateFile = '/var/run/hclouddns_state.json'; + $state = []; + if (file_exists($stateFile)) { + $content = file_get_contents($stateFile); + $state = json_decode($content, true) ?? []; + } + + // Get model data + $mdl = $this->getModel(); + $gateways = $mdl->gateways->gateway; + + foreach ($gateways->iterateItems() as $uuid => $gw) { + $gwState = $state['gateways'][$uuid] ?? []; + + $result['gateways'][$uuid] = [ + 'uuid' => $uuid, + 'name' => (string)$gw->name, + 'interface' => (string)$gw->interface, + 'enabled' => (string)$gw->enabled, + 'status' => $gwState['status'] ?? 'unknown', + 'ipv4' => $gwState['ipv4'] ?? null, + 'ipv6' => $gwState['ipv6'] ?? null, + 'simulated' => $gwState['simulated'] ?? false, + 'lastCheck' => $gwState['lastCheck'] ?? 0 + ]; + } + + $result['lastUpdate'] = $state['lastUpdate'] ?? 0; + + return $result; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php new file mode 100644 index 0000000000..ecf39c1ba3 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php @@ -0,0 +1,519 @@ + 'error', 'valid' => false, 'message' => 'Invalid request']; + + if ($this->request->isPost()) { + $token = $this->request->getPost('token', 'string', ''); + + if (empty($token)) { + return ['status' => 'error', 'valid' => false, 'message' => 'No token provided']; + } + + // Sanitize token - only allow alphanumeric and common token characters + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns validate', [$token]); + $data = json_decode($response, true); + + if ($data !== null) { + $result = [ + 'status' => $data['valid'] ? 'ok' : 'error', + 'valid' => $data['valid'] ?? false, + 'message' => $data['message'] ?? 'Unknown error', + 'zone_count' => $data['zone_count'] ?? 0 + ]; + } + } + + return $result; + } + + /** + * List zones for token + * @return array + */ + public function listZonesAction() + { + $result = ['status' => 'error', 'zones' => []]; + + if ($this->request->isPost()) { + $token = $this->request->getPost('token', 'string', ''); + + if (empty($token)) { + return ['status' => 'error', 'message' => 'No token provided', 'zones' => []]; + } + + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns list zones', [$token]); + $data = json_decode($response, true); + + if ($data !== null && isset($data['zones'])) { + $result = [ + 'status' => 'ok', + 'zones' => $data['zones'] + ]; + } else { + $result = ['status' => 'error', 'message' => $data['message'] ?? 'Failed to list zones', 'zones' => []]; + } + } + + return $result; + } + + /** + * List zones for an existing account (by UUID) + * @return array + */ + public function listZonesForAccountAction() + { + $result = ['status' => 'error', 'zones' => []]; + + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required', 'zones' => []]; + } + + $uuid = $this->request->getPost('account_uuid', 'string', ''); + if (empty($uuid)) { + return ['status' => 'error', 'message' => 'Account UUID required', 'zones' => []]; + } + + // Load the model and get the account + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + $node = $mdl->getNodeByReference('accounts.account.' . $uuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Account not found', 'zones' => []]; + } + + $token = (string)$node->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token', 'zones' => []]; + } + + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns list zones', [$token]); + $data = json_decode($response, true); + + if ($data !== null && isset($data['zones'])) { + $result = [ + 'status' => 'ok', + 'zones' => $data['zones'], + 'accountUuid' => $uuid + ]; + } else { + $result = ['status' => 'error', 'message' => $data['message'] ?? 'Failed to list zones', 'zones' => []]; + } + + return $result; + } + + /** + * List records for zone using account UUID + * @return array + */ + public function listRecordsForAccountAction() + { + $result = ['status' => 'error', 'records' => []]; + + if ($this->request->isPost()) { + $accountUuid = $this->request->getPost('account_uuid', 'string', ''); + $zoneId = $this->request->getPost('zone_id', 'string', ''); + $allTypes = $this->request->getPost('all_types', 'string', '0'); + + if (empty($accountUuid) || empty($zoneId)) { + return ['status' => 'error', 'message' => 'Account UUID and zone_id required', 'records' => []]; + } + + // Load the model and get the account + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Account not found', 'records' => []]; + } + + $token = (string)$node->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token', 'records' => []]; + } + + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId); + + $backend = new Backend(); + // Use allrecords action if all_types is requested + $action = ($allTypes === '1') ? 'hclouddns list allrecords' : 'hclouddns list records'; + $response = $backend->configdpRun($action, [$token, $zoneId]); + $data = json_decode($response, true); + + if ($data !== null && isset($data['records'])) { + $result = [ + 'status' => 'ok', + 'records' => $data['records'] + ]; + } + } + + return $result; + } + + /** + * List records for zone + * @return array + */ + public function listRecordsAction() + { + $result = ['status' => 'error', 'records' => []]; + + if ($this->request->isPost()) { + $token = $this->request->getPost('token', 'string', ''); + $zoneId = $this->request->getPost('zone_id', 'string', ''); + + if (empty($token) || empty($zoneId)) { + return ['status' => 'error', 'message' => 'Token and zone_id required', 'records' => []]; + } + + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId); + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns list records', [$token, $zoneId]); + $data = json_decode($response, true); + + if ($data !== null && isset($data['records'])) { + $result = [ + 'status' => 'ok', + 'records' => $data['records'] + ]; + } + } + + return $result; + } + + /** + * Sanitize record value based on record type + * @param string $value + * @param string $recordType + * @return string + */ + private function sanitizeRecordValue($value, $recordType) + { + switch ($recordType) { + case 'A': + // IPv4 address + return preg_replace('/[^0-9.]/', '', $value); + case 'AAAA': + // IPv6 address + return preg_replace('/[^a-fA-F0-9:]/', '', $value); + case 'CNAME': + case 'NS': + case 'PTR': + // Hostname + return preg_replace('/[^a-zA-Z0-9._-]/', '', $value); + case 'MX': + // Priority + hostname (e.g., "10 mail.example.com") + return preg_replace('/[^a-zA-Z0-9._ -]/', '', $value); + case 'TXT': + case 'SPF': + // Allow most printable ASCII for TXT records (SPF, DKIM, DMARC, etc.) + // Remove only control characters and null bytes + return preg_replace('/[\x00-\x1F\x7F]/', '', $value); + case 'SRV': + // Priority weight port target (e.g., "10 100 443 server.example.com") + return preg_replace('/[^a-zA-Z0-9._ -]/', '', $value); + case 'CAA': + // Flags tag value (e.g., '0 issue "letsencrypt.org"') + return preg_replace('/[^a-zA-Z0-9._ "\'-]/', '', $value); + default: + // Generic sanitization + return preg_replace('/[^a-zA-Z0-9._:@" -]/', '', $value); + } + } + + /** + * Create a new DNS record at Hetzner + * @return array + */ + public function createRecordAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $accountUuid = $this->request->getPost('account_uuid', 'string', ''); + $zoneId = $this->request->getPost('zone_id', 'string', ''); + $recordName = $this->request->getPost('record_name', 'string', ''); + $recordType = $this->request->getPost('record_type', 'string', 'A'); + $value = $this->request->getPost('value', 'string', ''); + $ttl = $this->request->getPost('ttl', 'int', 300); + + if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($value)) { + return ['status' => 'error', 'message' => 'Missing required parameters']; + } + + // Load the model and get the account + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Account not found']; + } + + $token = (string)$node->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token']; + } + + // Sanitize inputs + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId); + $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName); + $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType)); + $value = $this->sanitizeRecordValue($value, $recordType); + $ttl = max(60, min(86400, intval($ttl))); + + // Get zone name for history + $zoneName = $this->request->getPost('zone_name', 'string', ''); + if (empty($zoneName)) { + $zoneName = $zoneId; + } + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns dns create', [ + $token, $zoneId, $recordName, $recordType, $value, $ttl + ]); + $data = json_decode(trim($response), true); + + if ($data !== null && isset($data['status']) && $data['status'] === 'ok') { + // Record history entry + HistoryController::addEntry( + 'create', + $accountUuid, + (string)$node->name, + $zoneId, + $zoneName, + $recordName, + $recordType, + '', + 0, + $value, + $ttl + ); + return $data; + } + + if ($data !== null) { + return $data; + } + + return ['status' => 'error', 'message' => 'Failed to create record']; + } + + /** + * Update an existing DNS record at Hetzner + * @return array + */ + public function updateRecordAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $accountUuid = $this->request->getPost('account_uuid', 'string', ''); + $zoneId = $this->request->getPost('zone_id', 'string', ''); + $recordName = $this->request->getPost('record_name', 'string', ''); + $recordType = $this->request->getPost('record_type', 'string', 'A'); + $value = $this->request->getPost('value', 'string', ''); + $ttl = $this->request->getPost('ttl', 'int', 300); + + if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($value)) { + return ['status' => 'error', 'message' => 'Missing required parameters']; + } + + // Load the model and get the account + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Account not found']; + } + + $token = (string)$node->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token']; + } + + // Sanitize inputs + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId); + $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName); + $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType)); + $value = $this->sanitizeRecordValue($value, $recordType); + $ttl = max(60, min(86400, intval($ttl))); + + // Get old values for history + $oldValue = $this->request->getPost('old_value', 'string', ''); + $oldTtl = $this->request->getPost('old_ttl', 'int', 0); + $zoneName = $this->request->getPost('zone_name', 'string', ''); + if (empty($zoneName)) { + $zoneName = $zoneId; + } + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns dns update', [ + $token, $zoneId, $recordName, $recordType, $value, $ttl + ]); + $data = json_decode(trim($response), true); + + if ($data !== null && isset($data['status']) && $data['status'] === 'ok') { + // Record history entry + HistoryController::addEntry( + 'update', + $accountUuid, + (string)$node->name, + $zoneId, + $zoneName, + $recordName, + $recordType, + $oldValue, + $oldTtl, + $value, + $ttl + ); + return $data; + } + + if ($data !== null) { + return $data; + } + + return ['status' => 'error', 'message' => 'Failed to update record']; + } + + /** + * Delete a DNS record at Hetzner + * @return array + */ + public function deleteRecordAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $accountUuid = $this->request->getPost('account_uuid', 'string', ''); + $zoneId = $this->request->getPost('zone_id', 'string', ''); + $recordName = $this->request->getPost('record_name', 'string', ''); + $recordType = $this->request->getPost('record_type', 'string', 'A'); + + if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($recordType)) { + return ['status' => 'error', 'message' => 'Missing required parameters']; + } + + // Load the model and get the account + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'Account not found']; + } + + $token = (string)$node->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token']; + } + + // Sanitize inputs + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId); + $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName); + $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType)); + + // Get old value and zone name for history + $oldValue = $this->request->getPost('old_value', 'string', ''); + $oldTtl = $this->request->getPost('old_ttl', 'int', 0); + $zoneName = $this->request->getPost('zone_name', 'string', ''); + if (empty($zoneName)) { + $zoneName = $zoneId; + } + + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns dns delete', [ + $token, $zoneId, $recordName, $recordType + ]); + $data = json_decode(trim($response), true); + + if ($data !== null && isset($data['status']) && $data['status'] === 'ok') { + // Record history entry + HistoryController::addEntry( + 'delete', + $accountUuid, + (string)$node->name, + $zoneId, + $zoneName, + $recordName, + $recordType, + $oldValue, + $oldTtl, + '', + 0 + ); + return $data; + } + + if ($data !== null) { + return $data; + } + + return ['status' => 'error', 'message' => 'Failed to delete record']; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php new file mode 100644 index 0000000000..0f9993e383 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php @@ -0,0 +1,303 @@ +getModel(); + $retentionDays = (int)$mdl->general->historyRetentionDays; + $cutoffTime = time() - ($retentionDays * 86400); + + $result = [ + 'rows' => [], + 'rowCount' => 0, + 'total' => 0, + 'current' => 1 + ]; + + foreach ($mdl->history->change->iterateItems() as $uuid => $change) { + $timestamp = (int)(string)$change->timestamp; + + // Skip entries older than retention period + if ($timestamp < $cutoffTime) { + continue; + } + + $result['rows'][] = [ + 'uuid' => $uuid, + 'timestamp' => $timestamp, + 'timestampFormatted' => date('Y-m-d H:i:s', $timestamp), + 'action' => (string)$change->action, + 'accountUuid' => (string)$change->accountUuid, + 'accountName' => (string)$change->accountName, + 'zoneId' => (string)$change->zoneId, + 'zoneName' => (string)$change->zoneName, + 'recordName' => (string)$change->recordName, + 'recordType' => (string)$change->recordType, + 'oldValue' => (string)$change->oldValue, + 'oldTtl' => (string)$change->oldTtl, + 'newValue' => (string)$change->newValue, + 'newTtl' => (string)$change->newTtl, + 'reverted' => (string)$change->reverted + ]; + } + + // Sort by timestamp descending (newest first) + usort($result['rows'], function ($a, $b) { + return $b['timestamp'] - $a['timestamp']; + }); + + $result['rowCount'] = count($result['rows']); + $result['total'] = count($result['rows']); + + return $result; + } + + /** + * Get a single history entry + * @param string $uuid + * @return array + */ + public function getItemAction($uuid) + { + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('history.change.' . $uuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'History entry not found']; + } + + return [ + 'status' => 'ok', + 'change' => [ + 'uuid' => $uuid, + 'timestamp' => (int)(string)$node->timestamp, + 'timestampFormatted' => date('Y-m-d H:i:s', (int)(string)$node->timestamp), + 'action' => (string)$node->action, + 'accountUuid' => (string)$node->accountUuid, + 'accountName' => (string)$node->accountName, + 'zoneId' => (string)$node->zoneId, + 'zoneName' => (string)$node->zoneName, + 'recordName' => (string)$node->recordName, + 'recordType' => (string)$node->recordType, + 'oldValue' => (string)$node->oldValue, + 'oldTtl' => (string)$node->oldTtl, + 'newValue' => (string)$node->newValue, + 'newTtl' => (string)$node->newTtl, + 'reverted' => (string)$node->reverted + ] + ]; + } + + /** + * Revert a history entry (undo the change) + * @param string $uuid + * @return array + */ + public function revertAction($uuid) + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $mdl = $this->getModel(); + $node = $mdl->getNodeByReference('history.change.' . $uuid); + + if ($node === null) { + return ['status' => 'error', 'message' => 'History entry not found']; + } + + if ((string)$node->reverted === '1') { + return ['status' => 'error', 'message' => 'This change has already been reverted']; + } + + $action = (string)$node->action; + $accountUuid = (string)$node->accountUuid; + $zoneId = (string)$node->zoneId; + $recordName = (string)$node->recordName; + $recordType = (string)$node->recordType; + $oldValue = (string)$node->oldValue; + $oldTtl = (string)$node->oldTtl; + $newValue = (string)$node->newValue; + $newTtl = (string)$node->newTtl; + + // Get the account's API token + $accountNode = $mdl->getNodeByReference('accounts.account.' . $accountUuid); + if ($accountNode === null) { + return ['status' => 'error', 'message' => 'Account not found - cannot revert']; + } + + $token = (string)$accountNode->apiToken; + if (empty($token)) { + return ['status' => 'error', 'message' => 'Account has no API token']; + } + + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token); + $backend = new Backend(); + $result = null; + + // Perform the reverse action + if ($action === 'create') { + // Revert create = delete the record + $response = $backend->configdpRun('hclouddns dns delete', [ + $token, $zoneId, $recordName, $recordType + ]); + $result = json_decode(trim($response), true); + } elseif ($action === 'delete') { + // Revert delete = recreate the record with old values + $ttl = !empty($oldTtl) ? $oldTtl : 300; + $response = $backend->configdpRun('hclouddns dns create', [ + $token, $zoneId, $recordName, $recordType, $oldValue, $ttl + ]); + $result = json_decode(trim($response), true); + } elseif ($action === 'update') { + // Revert update = update back to old values + $ttl = !empty($oldTtl) ? $oldTtl : 300; + $response = $backend->configdpRun('hclouddns dns update', [ + $token, $zoneId, $recordName, $recordType, $oldValue, $ttl + ]); + $result = json_decode(trim($response), true); + } + + if ($result !== null && isset($result['status']) && $result['status'] === 'ok') { + // Mark the history entry as reverted + $node->reverted = '1'; + $mdl->serializeToConfig(); + Config::getInstance()->save(); + + return [ + 'status' => 'ok', + 'message' => 'Change reverted successfully' + ]; + } + + return [ + 'status' => 'error', + 'message' => 'Failed to revert change: ' . ($result['message'] ?? 'Unknown error') + ]; + } + + /** + * Clean up old history entries + * @return array + */ + public function cleanupAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $mdl = $this->getModel(); + $retentionDays = (int)$mdl->general->historyRetentionDays; + $cutoffTime = time() - ($retentionDays * 86400); + + $deleted = 0; + $toDelete = []; + + foreach ($mdl->history->change->iterateItems() as $uuid => $change) { + $timestamp = (int)(string)$change->timestamp; + if ($timestamp < $cutoffTime) { + $toDelete[] = $uuid; + } + } + + foreach ($toDelete as $uuid) { + $mdl->history->change->del($uuid); + $deleted++; + } + + if ($deleted > 0) { + $mdl->serializeToConfig(); + Config::getInstance()->save(); + } + + return [ + 'status' => 'ok', + 'deleted' => $deleted, + 'message' => "Cleaned up $deleted old history entries" + ]; + } + + /** + * Add a history entry (internal use) + * @param string $action create|update|delete + * @param string $accountUuid + * @param string $accountName + * @param string $zoneId + * @param string $zoneName + * @param string $recordName + * @param string $recordType + * @param string $oldValue + * @param int $oldTtl + * @param string $newValue + * @param int $newTtl + * @return bool + */ + public static function addEntry($action, $accountUuid, $accountName, $zoneId, $zoneName, $recordName, $recordType, $oldValue = '', $oldTtl = 0, $newValue = '', $newTtl = 0) + { + $mdl = new \OPNsense\HCloudDNS\HCloudDNS(); + + $change = $mdl->history->change->Add(); + $change->timestamp = time(); + $change->action = $action; + $change->accountUuid = $accountUuid; + $change->accountName = $accountName; + $change->zoneId = $zoneId; + $change->zoneName = $zoneName; + $change->recordName = $recordName; + $change->recordType = $recordType; + $change->oldValue = $oldValue; + $change->oldTtl = $oldTtl; + $change->newValue = $newValue; + $change->newTtl = $newTtl; + $change->reverted = '0'; + + $mdl->serializeToConfig(); + Config::getInstance()->save(); + + return true; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php new file mode 100644 index 0000000000..9815e4816f --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php @@ -0,0 +1,241 @@ +configdRun('hclouddns status'); + $data = json_decode($response, true); + + if ($data === null) { + return ['status' => 'error', 'message' => 'Failed to get status']; + } + + return $data; + } + + /** + * Trigger manual update + * @return array + */ + public function updateAction() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns update'); + $data = json_decode($response, true); + + if ($data === null) { + return ['status' => 'error', 'message' => 'Update failed']; + } + + return $data; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Reconfigure service (apply settings) + * @return array + */ + public function reconfigureAction() + { + if ($this->request->isPost()) { + $mdl = new HCloudDNS(); + $backend = new Backend(); + + // Generate configuration if needed + $backend->configdRun('template reload OPNsense/HCloudDNS'); + + return ['status' => 'ok']; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Trigger manual update with v2 failover support + * @return array + */ + public function updateV2Action() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns updatev2'); + $data = json_decode($response, true); + + if ($data === null) { + return ['status' => 'error', 'message' => 'Update failed', 'raw' => $response]; + } + + return $data; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Get failover history + * @return array + */ + public function failoverHistoryAction() + { + $stateFile = '/var/run/hclouddns_state.json'; + + if (file_exists($stateFile)) { + $content = file_get_contents($stateFile); + $data = json_decode($content, true); + + if ($data !== null && isset($data['failoverHistory'])) { + return [ + 'status' => 'ok', + 'history' => $data['failoverHistory'], + 'lastUpdate' => $data['lastUpdate'] ?? 0 + ]; + } + } + + return ['status' => 'ok', 'history' => [], 'lastUpdate' => 0]; + } + + /** + * Simulate gateway failure + * @param string $uuid gateway UUID + * @return array + */ + public function simulateDownAction($uuid = null) + { + if ($this->request->isPost() && $uuid !== null) { + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns simulate down', [$uuid]); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + return ['status' => 'error', 'message' => 'Simulation failed']; + } + + return ['status' => 'error', 'message' => 'POST request with gateway UUID required']; + } + + /** + * Simulate gateway recovery + * @param string $uuid gateway UUID + * @return array + */ + public function simulateUpAction($uuid = null) + { + if ($this->request->isPost() && $uuid !== null) { + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns simulate up', [$uuid]); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + return ['status' => 'error', 'message' => 'Simulation failed']; + } + + return ['status' => 'error', 'message' => 'POST request with gateway UUID required']; + } + + /** + * Clear all simulations + * @return array + */ + public function simulateClearAction() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns simulate clear'); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + return ['status' => 'error', 'message' => 'Clear failed']; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Get simulation status + * @return array + */ + public function simulateStatusAction() + { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns simulate status'); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + + return ['status' => 'ok', 'simulation' => ['active' => false, 'simulatedDown' => []]]; + } + + /** + * Test notification channels + * @return array + */ + public function testNotifyAction() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns testnotify'); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + return ['status' => 'error', 'message' => 'Test notification failed']; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php new file mode 100644 index 0000000000..f91fdbbfbf --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php @@ -0,0 +1,364 @@ +getModel(); + $result['hclouddns'] = $mdl->getNodes(); + return $result; + } + + /** + * Set settings + * @return array + */ + public function setAction() + { + $result = ['status' => 'error', 'message' => 'Invalid request']; + if ($this->request->isPost()) { + $mdl = $this->getModel(); + $mdl->setNodes($this->request->getPost('hclouddns')); + $valMsgs = $mdl->performValidation(); + if ($valMsgs->count() == 0) { + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + $result = ['status' => 'ok']; + } else { + $result = ['status' => 'error', 'validations' => []]; + foreach ($valMsgs as $msg) { + $result['validations'][$msg->getField()] = $msg->getMessage(); + } + } + } + return $result; + } + + /** + * Get general settings + * @return array + */ + public function getGeneralAction() + { + return $this->getBase('general', 'general'); + } + + /** + * Set general settings + * @return array + */ + public function setGeneralAction() + { + return $this->setBase('general', 'general'); + } + + /** + * Export configuration as JSON + * @param string $include_tokens Pass '1' to include API tokens + * @return array + */ + public function exportAction($include_tokens = '0') + { + $mdl = $this->getModel(); + $includeTokens = $include_tokens === '1'; + + $export = [ + 'version' => '2.0.0', + 'exported' => date('c'), + 'general' => [], + 'notifications' => [], + 'gateways' => [], + 'accounts' => [], + 'entries' => [] + ]; + + // Export general settings + $general = $mdl->general; + $export['general'] = [ + 'enabled' => (string)$general->enabled, + 'checkInterval' => (string)$general->checkInterval, + 'forceInterval' => (string)$general->forceInterval, + 'verbose' => (string)$general->verbose, + 'failoverEnabled' => (string)$general->failoverEnabled, + 'failbackEnabled' => (string)$general->failbackEnabled, + 'failbackDelay' => (string)$general->failbackDelay, + 'cronEnabled' => (string)$general->cronEnabled, + 'cronInterval' => (string)$general->cronInterval, + 'historyRetentionDays' => (string)$general->historyRetentionDays + ]; + + // Export notification settings + $notifications = $mdl->notifications; + $export['notifications'] = [ + 'enabled' => (string)$notifications->enabled, + 'notifyOnUpdate' => (string)$notifications->notifyOnUpdate, + 'notifyOnFailover' => (string)$notifications->notifyOnFailover, + 'notifyOnFailback' => (string)$notifications->notifyOnFailback, + 'notifyOnError' => (string)$notifications->notifyOnError, + 'emailEnabled' => (string)$notifications->emailEnabled, + 'emailTo' => (string)$notifications->emailTo, + 'webhookEnabled' => (string)$notifications->webhookEnabled, + 'webhookUrl' => (string)$notifications->webhookUrl, + 'webhookMethod' => (string)$notifications->webhookMethod, + 'ntfyEnabled' => (string)$notifications->ntfyEnabled, + 'ntfyServer' => (string)$notifications->ntfyServer, + 'ntfyTopic' => (string)$notifications->ntfyTopic, + 'ntfyPriority' => (string)$notifications->ntfyPriority + ]; + + // Export gateways + foreach ($mdl->gateways->gateway->iterateItems() as $uuid => $gw) { + $export['gateways'][] = [ + 'uuid' => $uuid, + 'enabled' => (string)$gw->enabled, + 'name' => (string)$gw->name, + 'interface' => (string)$gw->interface, + 'priority' => (string)$gw->priority, + 'checkipMethod' => (string)$gw->checkipMethod, + 'healthCheckTarget' => (string)$gw->healthCheckTarget + ]; + } + + // Export accounts (token only if explicitly requested) + foreach ($mdl->accounts->account->iterateItems() as $uuid => $acc) { + $accData = [ + 'uuid' => $uuid, + 'enabled' => (string)$acc->enabled, + 'name' => (string)$acc->name, + 'description' => (string)$acc->description, + 'apiType' => (string)$acc->apiType + ]; + if ($includeTokens) { + $accData['apiToken'] = (string)$acc->apiToken; + } + $export['accounts'][] = $accData; + } + + // Export entries + foreach ($mdl->entries->entry->iterateItems() as $uuid => $entry) { + $export['entries'][] = [ + 'uuid' => $uuid, + 'enabled' => (string)$entry->enabled, + 'account' => (string)$entry->account, + 'zoneId' => (string)$entry->zoneId, + 'zoneName' => (string)$entry->zoneName, + 'recordId' => (string)$entry->recordId, + 'recordName' => (string)$entry->recordName, + 'recordType' => (string)$entry->recordType, + 'primaryGateway' => (string)$entry->primaryGateway, + 'failoverGateway' => (string)$entry->failoverGateway, + 'ttl' => (string)$entry->ttl + ]; + } + + return [ + 'status' => 'ok', + 'export' => $export + ]; + } + + /** + * Import configuration from JSON + * @return array + */ + public function importAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $importData = $this->request->getPost('import'); + if (empty($importData)) { + return ['status' => 'error', 'message' => 'No import data provided']; + } + + // Parse JSON if string + if (is_string($importData)) { + $importData = json_decode($importData, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return ['status' => 'error', 'message' => 'Invalid JSON: ' . json_last_error_msg()]; + } + } + + $mdl = $this->getModel(); + $imported = ['gateways' => 0, 'accounts' => 0, 'entries' => 0]; + $errors = []; + + // Import general settings + if (isset($importData['general'])) { + $gen = $importData['general']; + if (isset($gen['enabled'])) $mdl->general->enabled = $gen['enabled']; + if (isset($gen['checkInterval'])) $mdl->general->checkInterval = $gen['checkInterval']; + if (isset($gen['forceInterval'])) $mdl->general->forceInterval = $gen['forceInterval']; + if (isset($gen['verbose'])) $mdl->general->verbose = $gen['verbose']; + if (isset($gen['failoverEnabled'])) $mdl->general->failoverEnabled = $gen['failoverEnabled']; + if (isset($gen['failbackEnabled'])) $mdl->general->failbackEnabled = $gen['failbackEnabled']; + if (isset($gen['failbackDelay'])) $mdl->general->failbackDelay = $gen['failbackDelay']; + if (isset($gen['cronEnabled'])) $mdl->general->cronEnabled = $gen['cronEnabled']; + if (isset($gen['cronInterval'])) $mdl->general->cronInterval = $gen['cronInterval']; + if (isset($gen['historyRetentionDays'])) $mdl->general->historyRetentionDays = $gen['historyRetentionDays']; + } + + // Import notification settings + if (isset($importData['notifications'])) { + $notif = $importData['notifications']; + if (isset($notif['enabled'])) $mdl->notifications->enabled = $notif['enabled']; + if (isset($notif['notifyOnUpdate'])) $mdl->notifications->notifyOnUpdate = $notif['notifyOnUpdate']; + if (isset($notif['notifyOnFailover'])) $mdl->notifications->notifyOnFailover = $notif['notifyOnFailover']; + if (isset($notif['notifyOnFailback'])) $mdl->notifications->notifyOnFailback = $notif['notifyOnFailback']; + if (isset($notif['notifyOnError'])) $mdl->notifications->notifyOnError = $notif['notifyOnError']; + if (isset($notif['emailEnabled'])) $mdl->notifications->emailEnabled = $notif['emailEnabled']; + if (isset($notif['emailTo'])) $mdl->notifications->emailTo = $notif['emailTo']; + if (isset($notif['webhookEnabled'])) $mdl->notifications->webhookEnabled = $notif['webhookEnabled']; + if (isset($notif['webhookUrl'])) $mdl->notifications->webhookUrl = $notif['webhookUrl']; + if (isset($notif['webhookMethod'])) $mdl->notifications->webhookMethod = $notif['webhookMethod']; + if (isset($notif['ntfyEnabled'])) $mdl->notifications->ntfyEnabled = $notif['ntfyEnabled']; + if (isset($notif['ntfyServer'])) $mdl->notifications->ntfyServer = $notif['ntfyServer']; + if (isset($notif['ntfyTopic'])) $mdl->notifications->ntfyTopic = $notif['ntfyTopic']; + if (isset($notif['ntfyPriority'])) $mdl->notifications->ntfyPriority = $notif['ntfyPriority']; + } + + // Map old UUIDs to new UUIDs for reference updating + $gatewayMap = []; + $accountMap = []; + + // Import gateways + if (isset($importData['gateways']) && is_array($importData['gateways'])) { + foreach ($importData['gateways'] as $gwData) { + $gw = $mdl->gateways->gateway->Add(); + $newUuid = $gw->getAttributes()['uuid']; + if (isset($gwData['uuid'])) { + $gatewayMap[$gwData['uuid']] = $newUuid; + } + $gw->enabled = $gwData['enabled'] ?? '1'; + $gw->name = $gwData['name'] ?? ''; + $gw->interface = $gwData['interface'] ?? ''; + $gw->priority = $gwData['priority'] ?? '10'; + $gw->checkipMethod = $gwData['checkipMethod'] ?? 'web_ipify'; + $gw->healthCheckTarget = $gwData['healthCheckTarget'] ?? '8.8.8.8'; + $imported['gateways']++; + } + } + + // Import accounts + if (isset($importData['accounts']) && is_array($importData['accounts'])) { + foreach ($importData['accounts'] as $accData) { + // Skip accounts without tokens (they can't function) + if (empty($accData['apiToken'])) { + $errors[] = "Account '{$accData['name']}' skipped - no API token"; + continue; + } + $acc = $mdl->accounts->account->Add(); + $newUuid = $acc->getAttributes()['uuid']; + if (isset($accData['uuid'])) { + $accountMap[$accData['uuid']] = $newUuid; + } + $acc->enabled = $accData['enabled'] ?? '1'; + $acc->name = $accData['name'] ?? ''; + $acc->description = $accData['description'] ?? ''; + $acc->apiType = $accData['apiType'] ?? 'cloud'; + $acc->apiToken = $accData['apiToken']; + $imported['accounts']++; + } + } + + // Import entries (update references to new gateway/account UUIDs) + if (isset($importData['entries']) && is_array($importData['entries'])) { + foreach ($importData['entries'] as $entryData) { + // Map old UUIDs to new ones + $accountUuid = $entryData['account'] ?? ''; + $primaryGwUuid = $entryData['primaryGateway'] ?? ''; + $failoverGwUuid = $entryData['failoverGateway'] ?? ''; + + if (isset($accountMap[$accountUuid])) { + $accountUuid = $accountMap[$accountUuid]; + } + if (isset($gatewayMap[$primaryGwUuid])) { + $primaryGwUuid = $gatewayMap[$primaryGwUuid]; + } + if (!empty($failoverGwUuid) && isset($gatewayMap[$failoverGwUuid])) { + $failoverGwUuid = $gatewayMap[$failoverGwUuid]; + } + + $entry = $mdl->entries->entry->Add(); + $entry->enabled = $entryData['enabled'] ?? '1'; + $entry->account = $accountUuid; + $entry->zoneId = $entryData['zoneId'] ?? ''; + $entry->zoneName = $entryData['zoneName'] ?? ''; + $entry->recordId = $entryData['recordId'] ?? ''; + $entry->recordName = $entryData['recordName'] ?? ''; + $entry->recordType = $entryData['recordType'] ?? 'A'; + $entry->primaryGateway = $primaryGwUuid; + $entry->failoverGateway = $failoverGwUuid; + $entry->ttl = $entryData['ttl'] ?? '300'; + $entry->status = 'pending'; + $imported['entries']++; + } + } + + // Validate and save + $valMsgs = $mdl->performValidation(); + if ($valMsgs->count() > 0) { + foreach ($valMsgs as $msg) { + $errors[] = $msg->getField() . ': ' . $msg->getMessage(); + } + } + + $mdl->serializeToConfig(); + \OPNsense\Core\Config::getInstance()->save(); + + return [ + 'status' => 'ok', + 'imported' => $imported, + 'errors' => $errors, + 'message' => sprintf( + 'Imported %d gateways, %d accounts, %d entries', + $imported['gateways'], + $imported['accounts'], + $imported['entries'] + ) + ]; + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php new file mode 100644 index 0000000000..1dafd888f6 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php @@ -0,0 +1,46 @@ +view->pick('OPNsense/HCloudDNS/dns'); + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php new file mode 100644 index 0000000000..041c16d029 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php @@ -0,0 +1,103 @@ +view->pick('OPNsense/HCloudDNS/index'); + $this->view->generalForm = $this->getForm('general'); + $this->view->gatewayForm = $this->getForm('dialogGateway'); + $this->view->entryForm = $this->getForm('dialogEntry'); + $this->view->accountForm = $this->getForm('dialogAccount'); + $this->view->scheduledForm = $this->getForm('dialogScheduled'); + $this->view->failoverForm = $this->getForm('failover'); + } + + /** + * Gateways management page (standalone, optional) + */ + public function gatewaysAction() + { + $this->view->pick('OPNsense/HCloudDNS/gateways'); + $this->view->gatewayForm = $this->getForm('dialogGateway'); + } + + /** + * Zone selection page (standalone, optional) + */ + public function zonesAction() + { + $this->view->pick('OPNsense/HCloudDNS/zones'); + } + + /** + * DNS entries management page (standalone, optional) + */ + public function entriesAction() + { + $this->view->pick('OPNsense/HCloudDNS/entries'); + $this->view->entryForm = $this->getForm('dialogEntry'); + } + + /** + * Accounts management page (legacy) + */ + public function accountsAction() + { + $this->view->pick('OPNsense/HCloudDNS/accounts'); + $this->view->accountForm = $this->getForm('dialogAccount'); + } + + /** + * Status page (standalone, optional) + */ + public function statusAction() + { + $this->view->pick('OPNsense/HCloudDNS/status'); + } + + /** + * Full DNS Management page - manage all zones and record types + */ + public function dnsAction() + { + $this->view->pick('OPNsense/HCloudDNS/dns'); + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php new file mode 100644 index 0000000000..7477fa457b --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php @@ -0,0 +1,48 @@ +view->generalForm = $this->getForm('general'); + $this->view->accountForm = $this->getForm('dialogAccount'); + $this->view->pick('OPNsense/HCloudDNS/settings'); + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml new file mode 100644 index 0000000000..551a4e983f --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml @@ -0,0 +1,32 @@ +
+ + account.enabled + + checkbox + Enable this API token + + + account.name + + text + Short name for this token (e.g. "Production", "Project A") + + + account.description + + text + Optional description + + + account.apiType + + dropdown + Cloud API for new zones, Legacy API for zones not yet migrated + + + account.apiToken + + password + Hetzner API Token + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml new file mode 100644 index 0000000000..61fdf69bb2 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml @@ -0,0 +1,71 @@ +
+ + entry.enabled + + checkbox + Enable this DNS entry for dynamic updates + + + entry.account + + dropdown + API token/account to use for this entry + + + entry.zoneId + + dropdown + Select the DNS zone for this record + + + entry.zoneName + + hidden + + + entry.recordName + + text + DNS record name (@ for root, www, mail, etc.) + + + entry.recordType + + dropdown + A for IPv4, AAAA for IPv6 + + + entry.primaryGateway + + dropdown + Main gateway to use for this record's IP + + + entry.failoverGateway + + dropdown + Backup gateway when primary is down (optional) + + + entry.ttl + + text + Time to live in seconds (60-86400) + + + header + + + + entry.currentIp + + info + Currently configured IP at Hetzner + + + entry.status + + info + Current status of this entry + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml new file mode 100644 index 0000000000..2f41773e87 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml @@ -0,0 +1,38 @@ +
+ + gateway.enabled + + checkbox + Enable this gateway for DNS updates + + + gateway.name + + text + Friendly name for this gateway (e.g., "Glasfaser", "Kabel") + + + gateway.interface + + dropdown + WAN interface for this gateway + + + gateway.priority + + text + Gateway priority (1-100, lower = higher priority) + + + gateway.checkipMethod + + dropdown + How to determine the public IP for this gateway + + + gateway.healthCheckTarget + + text + IP or hostname to ping for health checks (default: 8.8.8.8) + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml new file mode 100644 index 0000000000..02343c8d21 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml @@ -0,0 +1,34 @@ +
+ + header + + + + hclouddns.general.cronEnabled + + checkbox + Enable periodic DNS updates via cron job. Disabled by default - automatic triggers (gateway events, IP changes) are usually sufficient. + + + hclouddns.general.cronInterval + + text + How often to run the update check. Default: 5 minutes. Range: 1-60 minutes. + + + header + + + + hclouddns.general.checkInterval + + text + Minimum time between IP checks during scheduled updates. Default: 300 (5 minutes). Range: 60-86400 + + + hclouddns.general.forceInterval + + text + Force DNS update even if IP unchanged. 0 = disabled. Default: 0. Range: 0-30 + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml new file mode 100644 index 0000000000..4baa3b1f4a --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml @@ -0,0 +1,24 @@ +
+ + header + + + + hclouddns.general.failoverEnabled + + checkbox + Automatically switch DNS to backup gateway when primary fails (detected by OPNsense dpinger) + + + hclouddns.general.failbackEnabled + + checkbox + Automatically switch back to primary gateway when it becomes available again + + + hclouddns.general.failbackDelay + + text + Wait time before failback after primary gateway becomes available. Default: 60. Range: 0-600 + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml new file mode 100644 index 0000000000..5e2fbdae4e --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml @@ -0,0 +1,20 @@ +
+ + hclouddns.general.enabled + + checkbox + Enable Hetzner Cloud Dynamic DNS Service + + + hclouddns.general.verbose + + checkbox + Write detailed log entries to syslog + + + hclouddns.general.historyRetentionDays + + text + Number of days to keep DNS change history for undo functionality (1-365, default: 7) + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml new file mode 100644 index 0000000000..f2aa89101f --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml @@ -0,0 +1,9 @@ + + + Services: Hetzner Cloud DDNS + + ui/hclouddns/* + api/hclouddns/* + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php new file mode 100644 index 0000000000..2ade4892f1 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php @@ -0,0 +1,39 @@ + + //OPNsense/HCloudDNS + Hetzner Cloud Dynamic DNS with Multi-Zone and Failover + 2.0.0 + + + + + 0 + Y + + + 300 + 60 + 86400 + Y + Check interval must be between 60 and 86400 seconds + + + 0 + 0 + 30 + Force interval must be between 0 and 30 days (0 = disabled) + + + 0 + + + + 0 + + + 1 + + + 60 + 0 + 600 + Failback delay must be between 0 and 600 seconds + + + + 0 + + + 5 + 1 + 60 + Cron interval must be between 1 and 60 minutes + + + + 7 + 1 + 365 + History retention must be between 1 and 365 days + + + + + + + + 1 + Y + + + Y + /^.{1,64}$/ + Gateway name is required (max 64 characters) + + + Y + Y + + /^(?!0).*$/ + + + + 10 + 1 + 100 + Y + Priority must be between 1 and 100 (lower = higher priority) + + + Y + web_ipify + + Interface IP + ipify.org + DynDNS + FreeDNS + ip4only.me + ip6only.me + + + + 8.8.8.8 + IP or hostname for health check + + + + + + + + + 1 + Y + + + + + OPNsense.HCloudDNS.HCloudDNS + accounts.account + name + + + Y + Account/Token is required + + + Y + Zone ID is required + + + Y + Zone name is required + + + N + + + Y + Record name is required (e.g. @ or www) + + + Y + A + + A (IPv4) + AAAA (IPv6) + + + + + + OPNsense.HCloudDNS.HCloudDNS + gateways.gateway + name + + + Y + Primary gateway is required + + + + + OPNsense.HCloudDNS.HCloudDNS + gateways.gateway + name + + + N + None (no failover) + + + 300 + 60 + 86400 + TTL must be between 60 and 86400 seconds + + + N + + + N + + + pending + + Pending + Active + Failover + Paused + Error + Orphaned + + + + + N + + + + + + + + + Y + + + Y + + Create + Update + Delete + + + + Y + + + N + + + Y + + + Y + + + Y + + + Y + + + N + + + N + + + N + + + N + + + 0 + + + + + + + + 0 + + + 1 + + + 1 + + + 1 + + + 1 + + + + 0 + + + N + Valid email address required + + + + 0 + + + N + Valid URL required + + + POST + + POST + GET + + + + + 0 + + + https://ntfy.sh + N + + + N + /^[a-zA-Z0-9_-]{1,64}$/ + Topic must be alphanumeric (max 64 characters) + + + default + + Min (1) + Low (2) + Default (3) + High (4) + Urgent (5) + + + + + + + + + 1 + Y + + + Y + /^.{1,64}$/ + Name is required (max 64 characters) + + + N + /^.{0,255}$/ + + + Y + cloud + + Hetzner Cloud API + Hetzner DNS API (deprecated) + + + + Y + /^.{10,}$/ + API token is required (minimum 10 characters) + + + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml new file mode 100644 index 0000000000..e1b57ebb0a --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt new file mode 100644 index 0000000000..683a63a6d0 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt @@ -0,0 +1,229 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Enabled') }}{{ lang._('Description') }}{{ lang._('Zone') }}{{ lang._('Records') }}{{ lang._('IPv4') }}{{ lang._('IPv6') }}{{ lang._('Commands') }}
+ + +
+
+
+ +{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'DialogAccount', 'label': lang._('Edit Account')]) }} diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt new file mode 100644 index 0000000000..864e027620 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt @@ -0,0 +1,961 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Hetzner Cloud DNS - Full DNS Zone Management +#} + + + + + + +
+
+ {{ lang._('Full DNS zone management for all your Hetzner DNS zones. Create, edit, and delete any DNS record type.') }} +
+ + +
+ + +
+ + + + + +
+
+ +

{{ lang._('Select an account to view DNS zones') }}

+
+
+ + + +
+ + + + + + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt new file mode 100644 index 0000000000..05531bb128 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt @@ -0,0 +1,358 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + + + +
+
+
+
+

{{ lang._('DNS Entries') }}

+

{{ lang._('Manage your dynamic DNS entries. Select multiple entries for batch operations.') }}

+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('On') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Current IP') }}{{ lang._('Gateway') }}{{ lang._('Status') }}{{ lang._('Commands') }}
+ + + + +
+
+
+ +{{ partial("layout_partials/base_dialog", ['fields': entryForm, 'id': 'DialogEntry', 'label': lang._('Edit Entry')]) }} diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt new file mode 100644 index 0000000000..1ca44b38ff --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt @@ -0,0 +1,155 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + +
+
+
+
+
+

{{ lang._('Gateways') }}

+

{{ lang._('Configure WAN interfaces for dynamic DNS updates. Each gateway can have its own IP detection method and health check settings.') }}

+
+
+
+ + + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Enabled') }}{{ lang._('Name') }}{{ lang._('Interface') }}{{ lang._('Priority') }}{{ lang._('IP Method') }}{{ lang._('Commands') }}
+ + + +
+
+
+ +{{ partial("layout_partials/base_dialog", ['fields': gatewayForm, 'id': 'DialogGateway', 'label': lang._('Edit Gateway')]) }} diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt new file mode 100644 index 0000000000..ed77db0e22 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt @@ -0,0 +1,366 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + + + +
+
+
+ +
+

{{ lang._('Hetzner Cloud Dynamic DNS') }}

+
+ +
+
+
+ {{ lang._('Service') }} + +
+
+ {{ lang._('API') }} + +
+
+ {{ lang._('Token') }} + +
+
+ {{ lang._('Failover') }} + +
+
+
+ + +
+
+

{{ lang._('Configuration Summary') }}

+
+
+
{{ lang._('Gateways') }}
+
-
+
+ + {{ lang._('Manage') }} + +
+
+
{{ lang._('DNS Entries') }}
+
-
+
+ + {{ lang._('Manage') }} + +
+
+
+
+ + +
+
+

{{ lang._('Settings') }}

+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }} +
+
+ + + + + +
+
+ + + + {{ lang._('Status Dashboard') }} + +
+
+
+
+ + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt new file mode 100644 index 0000000000..25dbb263ff --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt @@ -0,0 +1,1916 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Hetzner Cloud Dynamic DNS - Main Interface with Tabs +#} + + + + + + +
+ + +
+ +
+ +
+
+ {{ lang._('Service Status:') }} + {{ lang._('Loading...') }} + +
+
+ {{ lang._('Last refresh:') }} - + +
+
+ + +
+
+
+
0
+
{{ lang._('Gateways') }}
+
-
+
+
+
+
0
+
{{ lang._('Accounts') }}
+
-
+
+
+
+
0
+
{{ lang._('DNS Entries') }}
+
-
+
+
+ + +
+
+
+
+

0

+ {{ lang._('Active') }} +
+
+
+
+
+
+

0

+ {{ lang._('Failover') }} +
+
+
+
+
+
+

0

+ {{ lang._('Error') }} +
+
+
+
+
+
+

0

+ {{ lang._('Pending') }} +
+
+
+
+ + +
+

{{ lang._('Gateway Failure Simulation') }}

+

{{ lang._('Test failover behavior by simulating gateway failures. This only affects DNS updates, not actual traffic.') }}

+ +
+ {{ lang._('Loading gateways...') }} +
+ + + +
{{ lang._('DNS Entry Status') }}
+ + + + + + + + + + + + + + +
{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Current IP') }}{{ lang._('Active Gateway') }}{{ lang._('Status') }}
{{ lang._('Loading...') }}
+
+ +
+ +
+ + +
+ +
+
+

{{ lang._('Failover Settings') }}

+
+
+ {{ partial("layout_partials/base_form", ['fields': failoverForm, 'id': 'frm_failover_settings']) }} + +
+
+ +

{{ lang._('Configure network interfaces/gateways for IP detection. The gateway with lowest priority number is primary.') }}

+ + + + + + + + + + + + + + + + + +
ID{{ lang._('Enabled') }}{{ lang._('Name') }}{{ lang._('Interface') }}{{ lang._('Priority') }}{{ lang._('IP Detection') }}{{ lang._('Commands') }}
+ +
+ +
+ + +
+

{{ lang._('DNS records managed by this plugin. Records are updated when the gateway IP changes.') }}

+
+ {{ lang._('Adding entries:') }} {{ lang._('New entries are created at Hetzner DNS immediately with the current gateway IP.') }} +
+
+ {{ lang._('Deleting entries:') }} {{ lang._('Only removes from OPNsense management. DNS records at Hetzner remain unchanged.') }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ lang._('Enabled') }}{{ lang._('Account') }}{{ lang._('Zone') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Primary IP') }}{{ lang._('Failover IP') }}{{ lang._('Current IP') }}{{ lang._('Status') }}{{ lang._('Commands') }}
+ +
+ + + + +
+ + +
+

{{ lang._('DNS change history log. You can revert changes to restore previous DNS record values.') }}

+
+ {{ lang._('History retention is configured in Settings. Only changes within the retention period are shown.') }} +
+ + + + + + + + + + + + + + + + + +
{{ lang._('Time') }}{{ lang._('Action') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Old Value') }}{{ lang._('New Value') }}{{ lang._('Status') }}{{ lang._('Actions') }}
{{ lang._('Loading...') }}
+ +
+ + +
+ + +
+
+

{{ lang._('When do you need scheduled updates?') }}

+

{{ lang._('Normally, DNS updates are triggered automatically by:') }}

+
    +
  • {{ lang._('Gateway Monitoring') }} - {{ lang._('When OPNsense detects a gateway failure or recovery (via dpinger), DNS records are updated immediately (~1 second response time).') }}
  • +
  • {{ lang._('IP Changes') }} - {{ lang._('When an interface IP address changes, DNS records are updated automatically.') }}
  • +
+

{{ lang._('Scheduled updates are optional') }} {{ lang._('and useful for:') }}

+
    +
  • {{ lang._('Catching any missed events as a safety net') }}
  • +
  • {{ lang._('Environments where gateway monitoring is disabled') }}
  • +
  • {{ lang._('Periodic verification that DNS records are in sync') }}
  • +
+

{{ lang._('For most setups, leaving this disabled is recommended.') }}

+
+ + {{ partial("layout_partials/base_form", ['fields': scheduledForm, 'id': 'frm_scheduled_settings']) }} + +
+ +
+
+
+ + +{{ partial("layout_partials/base_dialog", ['fields': gatewayForm, 'id': 'dialogGateway', 'label': lang._('Gateway')]) }} + + +{{ partial("layout_partials/base_dialog", ['fields': entryForm, 'id': 'dialogEntry', 'label': lang._('DNS Entry')]) }} + + + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt new file mode 100644 index 0000000000..ece13736f3 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt @@ -0,0 +1,921 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Hetzner Cloud DNS - Settings (Accounts) +#} + + + + +
+
+

{{ lang._('General Settings') }}

+
+
+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }} + +
+
+ + +
+
+

{{ lang._('API Accounts') }}

+
+
+

{{ lang._('Manage API tokens for Hetzner DNS. Each token provides access to one or more zones.') }}

+ + + + + + + + + + + + + + + + +
ID{{ lang._('Enabled') }}{{ lang._('Name') }}{{ lang._('API Type') }}{{ lang._('Description') }}{{ lang._('Commands') }}
+ +
+ + +
+
+ + +
+
+

{{ lang._('Notifications') }}

+
+
+

{{ lang._('Get notified when DNS records change, failover events occur, or errors happen.') }}

+ +
+
+
+ +
+
+
+ + + +
+ +
+
+ + +
+
+

{{ lang._('Backup / Export') }}

+
+
+

{{ lang._('Export your configuration as JSON for backup or migration. Import to restore settings.') }}

+
+
+
+
{{ lang._('Export Configuration') }}
+

{{ lang._('Download current configuration as JSON file.') }}

+
+ +
+ +
+
+
+
+
{{ lang._('Import Configuration') }}
+

{{ lang._('Import configuration from a JSON backup file.') }}

+ +
+ +
+
+
+
+
+ + + + + +{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'dialogAccount', 'label': lang._('API Account')]) }} + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt new file mode 100644 index 0000000000..a04c20814d --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt @@ -0,0 +1,397 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + + + +
+
+
+
+
+

{{ lang._('Hetzner Cloud DDNS Status') }}

+
+
+ +
+
+ {{ lang._('Service') }}: +    + {{ lang._('Failover') }}: + + +
+
+ +
+ +
+
+

{{ lang._('Gateways') }}

+
+

+
+
+ + +
+
{{ lang._('Failover Simulation') }}
+

{{ lang._('Test failover by simulating gateway failures.') }}

+
+ {{ lang._('Status') }}: +
+ +
+
+ + +
+
+

{{ lang._('DNS Entries') }}

+ + + + + + + + + + + + + + + + + +
{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Current IP') }}{{ lang._('Primary') }}{{ lang._('Failover') }}{{ lang._('Status') }}
{{ lang._('Loading...') }}
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt new file mode 100644 index 0000000000..3c32b4a371 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt @@ -0,0 +1,393 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. +#} + + + + + +
+
+
+
+

{{ lang._('Zone Selection') }}

+

{{ lang._('Select DNS records from your Hetzner zones to manage with dynamic DNS.') }}

+
+ + +
+
+ +
+ + + + + +
+ +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ {{ lang._('No records selected') }} +
+
+ + +
+
+
+ {{ lang._('Enter your API token and click "Load Zones" to see available zones.') }} +
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py new file mode 100644 index 0000000000..39e34cc293 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py @@ -0,0 +1,77 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Create a new DNS record at Hetzner +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Expected args: token zone_id record_name record_type value ttl + if len(sys.argv) < 7: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: create_record.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + record_name = sys.argv[3].strip() + record_type = sys.argv[4].strip().upper() + value = sys.argv[5].strip() + ttl = int(sys.argv[6].strip()) if sys.argv[6].strip().isdigit() else 300 + + if not all([token, zone_id, record_name, value]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + # Support all common record types + supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA'] + if record_type not in supported_types: + print(json.dumps({ + 'status': 'error', + 'message': f'Unsupported record type: {record_type}. Supported: {", ".join(supported_types)}' + })) + sys.exit(1) + + api = HCloudAPI(token) + + # TXT records need to be quoted for Hetzner API + if record_type == 'TXT' and not value.startswith('"'): + value = f'"{value}"' + + try: + success, message = api.create_record(zone_id, record_name, record_type, value, ttl) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Record {record_name} ({record_type}) created successfully' + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to create record: {message}' + })) + sys.exit(1) + except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py new file mode 100644 index 0000000000..d6fe202449 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py @@ -0,0 +1,62 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Delete a DNS record at Hetzner +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Expected args: token zone_id record_name record_type + if len(sys.argv) < 5: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: delete_record.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + record_name = sys.argv[3].strip() + record_type = sys.argv[4].strip().upper() + + if not all([token, zone_id, record_name, record_type]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + api = HCloudAPI(token) + + try: + success, message = api.delete_record(zone_id, record_name, record_type) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Record {record_name} ({record_type}) deleted successfully' + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to delete record: {message}' + })) + sys.exit(1) + except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py new file mode 100755 index 0000000000..b8fe969854 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Gateway health check and IP detection for HCloudDNS +""" + +import json +import subprocess +import sys +import os +import socket +import urllib.request +import urllib.error +import ssl + +# State file for gateway status persistence +STATE_FILE = '/var/run/hclouddns_gateways.json' + +# IP check services +IP_SERVICES = { + 'web_ipify': { + 'ipv4': 'https://api.ipify.org', + 'ipv6': 'https://api6.ipify.org' + }, + 'web_dyndns': { + 'ipv4': 'http://checkip.dyndns.org', + 'ipv6': None + }, + 'web_freedns': { + 'ipv4': 'https://freedns.afraid.org/dynamic/check.php', + 'ipv6': None + }, + 'web_ip4only': { + 'ipv4': 'https://ip4only.me/api/', + 'ipv6': None + }, + 'web_ip6only': { + 'ipv4': None, + 'ipv6': 'https://ip6only.me/api/' + } +} + + +def load_state(): + """Load gateway state from file""" + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {'gateways': {}, 'lastCheck': 0} + + +def save_state(state): + """Save gateway state to file""" + try: + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + except IOError as e: + sys.stderr.write(f"Error saving state: {e}\n") + + +def get_interface_ip(interface, ipv6=False): + """Get IP address from interface using ifconfig""" + try: + result = subprocess.run( + ['ifconfig', interface], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + line = line.strip() + if ipv6 and line.startswith('inet6 ') and 'scopeid' not in line.lower(): + parts = line.split() + if len(parts) >= 2: + addr = parts[1].split('%')[0] + if not addr.startswith('fe80:'): + return addr + elif not ipv6 and line.startswith('inet '): + parts = line.split() + if len(parts) >= 2: + return parts[1] + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + return None + + +def get_web_ip(service, interface=None, source_ip=None, ipv6=False): + """Get public IP from web service, optionally binding to source IP""" + service_config = IP_SERVICES.get(service, {}) + url = service_config.get('ipv6' if ipv6 else 'ipv4') + + if not url: + return None + + try: + # Use curl if source_ip is specified (more reliable for source binding) + if source_ip: + cmd = ['curl', '-s', '--connect-timeout', '10', '--interface', source_ip, url] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if result.returncode == 0: + content = result.stdout.strip() + if 'dyndns' in service: + import re + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content) + if match: + return match.group(1) + elif 'ip4only' in service or 'ip6only' in service: + parts = content.split(',') + if len(parts) >= 2: + return parts[1].strip() + else: + if is_valid_ip(content): + return content + return None + + # Default: use urllib without source binding + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + request = urllib.request.Request(url, headers={'User-Agent': 'OPNsense-HCloudDNS/2.0'}) + + with urllib.request.urlopen(request, timeout=10, context=ctx) as response: + content = response.read().decode('utf-8').strip() + + if 'dyndns' in service: + import re + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content) + if match: + return match.group(1) + elif 'ip4only' in service or 'ip6only' in service: + parts = content.split(',') + if len(parts) >= 2: + return parts[1].strip() + else: + if is_valid_ip(content): + return content + except (urllib.error.URLError, socket.timeout, subprocess.TimeoutExpired, Exception) as e: + sys.stderr.write(f"Error getting IP from {service}: {e}\n") + + return None + + +def is_valid_ip(ip): + """Check if string is a valid IP address""" + try: + socket.inet_pton(socket.AF_INET, ip) + return True + except socket.error: + try: + socket.inet_pton(socket.AF_INET6, ip) + return True + except socket.error: + return False + + +def quick_ping_check(target='8.8.8.8', count=1, timeout=2): + """ + Quick ping check for gateway connectivity. + Used as a simple fallback health check. + + Args: + target: IP or hostname to ping + count: Number of pings + timeout: Timeout in seconds + + Returns: + bool: True if ping succeeded + """ + cmd = ['ping', '-c', str(count), '-W', str(timeout), target] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * count + 2) + return result.returncode == 0 + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return False + + +def resolve_interface_name(interface): + """Resolve OPNsense interface name to physical interface and get its IP""" + # Map common OPNsense names to physical interfaces + # First try to get from config.xml + try: + import xml.etree.ElementTree as ET + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + iface_node = root.find(f'.//interfaces/{interface}') + if iface_node is not None: + phys_if = iface_node.findtext('if') + if phys_if: + return phys_if + except Exception: + pass + return interface + + +def get_gateway_ip(uuid, gateway_config): + """Get current IP for a gateway""" + interface = gateway_config.get('interface') + checkip_method = gateway_config.get('checkipMethod', 'web_ipify') + + result = { + 'status': 'ok', + 'uuid': uuid, + 'ipv4': None, + 'ipv6': None + } + + # Resolve interface name and get local IP for source binding + phys_interface = resolve_interface_name(interface) + local_ip = get_interface_ip(phys_interface, ipv6=False) + + if checkip_method == 'if': + result['ipv4'] = local_ip + result['ipv6'] = get_interface_ip(phys_interface, ipv6=True) + else: + # Use local_ip as source for web requests + result['ipv4'] = get_web_ip(checkip_method, phys_interface, source_ip=local_ip, ipv6=False) + result['ipv6'] = get_web_ip(checkip_method, phys_interface, source_ip=None, ipv6=True) + + if not result['ipv4'] and not result['ipv6']: + result['status'] = 'error' + result['message'] = 'Could not determine IP address' + + return result + + +def main(): + """Main entry point for configd actions""" + if len(sys.argv) < 2: + print(json.dumps({'status': 'error', 'message': 'No action specified'})) + sys.exit(1) + + action = sys.argv[1] + + if action == 'healthcheck': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'})) + sys.exit(1) + + uuid = sys.argv[2] + gateway_config = {} + if len(sys.argv) > 3: + try: + gateway_config = json.loads(sys.argv[3]) + except json.JSONDecodeError: + pass + + # Simple ping-based health check (dpinger handles real gateway monitoring) + target = gateway_config.get('healthCheckTarget', '8.8.8.8') + is_healthy = quick_ping_check(target, count=1, timeout=2) + result = { + 'uuid': uuid, + 'status': 'up' if is_healthy else 'down' + } + print(json.dumps(result)) + + elif action == 'getip': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'})) + sys.exit(1) + + uuid = sys.argv[2] + gateway_config = {} + if len(sys.argv) > 3: + try: + gateway_config = json.loads(sys.argv[3]) + except json.JSONDecodeError: + pass + + result = get_gateway_ip(uuid, gateway_config) + print(json.dumps(result)) + + elif action == 'status': + # Read gateways from OPNsense config and check their status + result = {'gateways': {}, 'lastCheck': 0} + try: + import xml.etree.ElementTree as ET + import time + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + + gateways_node = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways_node is not None: + for gw in gateways_node.findall('gateway'): + uuid = gw.get('uuid') + if not uuid: + continue + + enabled = gw.findtext('enabled', '0') + if enabled != '1': + continue + + interface = gw.findtext('interface', '') + checkip_method = gw.findtext('checkipMethod', 'web_ipify') + health_target = gw.findtext('healthCheckTarget', '8.8.8.8') + + # Resolve interface and get IP + phys_if = resolve_interface_name(interface) + ipv4 = None + ipv6 = None + + if checkip_method == 'if': + ipv4 = get_interface_ip(phys_if, ipv6=False) + ipv6 = get_interface_ip(phys_if, ipv6=True) + else: + local_ip = get_interface_ip(phys_if, ipv6=False) + ipv4 = get_web_ip(checkip_method, phys_if, source_ip=local_ip, ipv6=False) + + # Quick health check (ping only for speed) + status = 'up' if quick_ping_check(health_target, count=1, timeout=2) else 'down' + + result['gateways'][uuid] = { + 'status': status, + 'ipv4': ipv4, + 'ipv6': ipv6 + } + + result['lastCheck'] = int(time.time()) + except Exception as e: + sys.stderr.write(f"Error getting gateway status: {e}\n") + + print(json.dumps(result)) + + else: + print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'})) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py new file mode 100755 index 0000000000..e6039de98e --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Get current IP from Hetzner DNS for a specific record +""" + +import json +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def get_hetzner_ip(zone_id, record_name, record_type): + """Get current IP for a record from Hetzner DNS""" + # Read API token from config + try: + import xml.etree.ElementTree as ET + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + token_node = root.find('.//OPNsense/HCloudDNS/apiToken') + if token_node is None or not token_node.text: + return {'status': 'error', 'message': 'No API token configured'} + token = token_node.text + except Exception as e: + return {'status': 'error', 'message': f'Config error: {str(e)}'} + + api = HCloudAPI(token) + + try: + records = api.list_records(zone_id) + for record in records: + if record.get('name') == record_name and record.get('type') == record_type: + return { + 'status': 'ok', + 'ip': record.get('value'), + 'recordId': record.get('id'), + 'ttl': record.get('ttl'), + 'modified': record.get('modified') + } + + return {'status': 'error', 'message': 'Record not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + +def main(): + if len(sys.argv) < 4: + print(json.dumps({'status': 'error', 'message': 'Usage: get_hetzner_ip.py '})) + sys.exit(1) + + zone_id = sys.argv[1] + record_name = sys.argv[2] + record_type = sys.argv[3] + + result = get_hetzner_ip(zone_id, record_name, record_type) + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py new file mode 100755 index 0000000000..b921b68b2e --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py @@ -0,0 +1,91 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + 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. + + Hetzner Cloud API wrapper for HCloudDNS OPNsense plugin + This is a compatibility wrapper - actual implementation is in lib/hetzner_api.py +""" +import os +import sys + +# Add lib directory to path +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib')) + +from hetzner_api import ( # noqa: E402 + HetznerCloudAPI, + HetznerLegacyAPI, + HetznerAPIError, + create_api +) + +# Re-export for backward compatibility +HCloudAPIError = HetznerAPIError + + +class HCloudAPI: + """ + Backward-compatible wrapper for Hetzner DNS API. + Delegates to HetznerCloudAPI or HetznerLegacyAPI based on api_type. + """ + + def __init__(self, token, api_type='cloud', verbose=False): + self._api = create_api(token, api_type, verbose) + self.api_type = api_type + self.verbose = verbose + + def validate_token(self): + return self._api.validate_token() + + def list_zones(self): + return self._api.list_zones() + + def get_zone_id(self, zone_name): + return self._api.get_zone_id(zone_name) + + def list_records(self, zone_id, record_types=None): + return self._api.list_records(zone_id, record_types) + + def get_record(self, zone_id, name, record_type): + return self._api.get_record(zone_id, name, record_type) + + def update_record(self, zone_id, name, record_type, value, ttl=300): + return self._api.update_record(zone_id, name, record_type, value, ttl) + + def create_record(self, zone_id, name, record_type, value, ttl=300): + return self._api.create_record(zone_id, name, record_type, value, ttl) + + def delete_record(self, zone_id, name, record_type): + return self._api.delete_record(zone_id, name, record_type) + + +# Export all for convenience +__all__ = [ + 'HCloudAPI', + 'HCloudAPIError', + 'HetznerCloudAPI', + 'HetznerLegacyAPI', + 'HetznerAPIError', + 'create_api' +] diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py new file mode 100644 index 0000000000..7d4ac3f8d1 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py @@ -0,0 +1,6 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Shared library for Hetzner DNS API access +""" diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py new file mode 100644 index 0000000000..3022025339 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py @@ -0,0 +1,632 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + 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. + + Shared Hetzner DNS API library - used by both ddclient providers and HCloudDNS +""" +import syslog +import requests + +TIMEOUT = 15 + + +class HetznerAPIError(Exception): + """Custom exception for Hetzner API errors""" + + def __init__(self, message, status_code=None, response_body=None): + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +class HetznerCloudAPI: + """ + Hetzner Cloud DNS API (api.hetzner.cloud) + Uses Bearer token authentication and rrsets endpoints + """ + + _api_base = "https://api.hetzner.cloud/v1" + + def __init__(self, token, verbose=False): + self.token = token + self.verbose = verbose + self.headers = { + 'User-Agent': 'OPNsense-HCloudDNS/2.0', + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + def _log(self, level, message): + """Log message to syslog""" + syslog.syslog(level, f"HCloudDNS: {message}") + + def _request(self, method, endpoint, params=None, json_data=None): + """Make API request with error handling""" + url = f"{self._api_base}{endpoint}" + + try: + response = requests.request( + method=method, + url=url, + headers=self.headers, + params=params, + json=json_data, + timeout=TIMEOUT + ) + + if self.verbose: + self._log(syslog.LOG_DEBUG, f"{method} {endpoint} -> {response.status_code}") + + return response + + except requests.exceptions.Timeout: + raise HetznerAPIError("API request timed out") + except requests.exceptions.ConnectionError: + raise HetznerAPIError("Failed to connect to Hetzner Cloud API") + except requests.exceptions.RequestException as e: + raise HetznerAPIError(f"API request failed: {str(e)}") + + def validate_token(self): + """ + Validate token by attempting to list zones. + Returns tuple (valid: bool, message: str, zone_count: int) + """ + try: + response = self._request('GET', '/zones') + + if response.status_code == 401: + return False, "Invalid API token", 0 + + if response.status_code == 403: + return False, "API token lacks required permissions", 0 + + if response.status_code != 200: + return False, f"API error: HTTP {response.status_code}", 0 + + data = response.json() + zones = data.get('zones', []) + zone_count = len(zones) + + return True, f"Token valid - {zone_count} zone(s) found", zone_count + + except HetznerAPIError as e: + return False, str(e), 0 + except Exception as e: + return False, f"Unexpected error: {str(e)}", 0 + + def list_zones(self): + """ + List all DNS zones accessible with this token. + Returns list of zone dicts with id, name, records_count + """ + try: + response = self._request('GET', '/zones') + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") + return [] + + data = response.json() + zones = data.get('zones', []) + + result = [] + for zone in zones: + result.append({ + 'id': zone.get('id', ''), + 'name': zone.get('name', ''), + 'records_count': zone.get('records_count', 0), + 'status': zone.get('status', 'unknown') + }) + + if self.verbose: + self._log(syslog.LOG_INFO, f"Found {len(result)} zones") + + return result + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to list zones: {str(e)}") + return [] + + def get_zone_id(self, zone_name): + """Get zone ID by zone name""" + try: + response = self._request('GET', '/zones', params={'name': zone_name}) + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to get zone: HTTP {response.status_code}") + return None + + data = response.json() + zones = data.get('zones', []) + + if not zones: + self._log(syslog.LOG_ERR, f"Zone '{zone_name}' not found") + return None + + zone_id = zones[0].get('id') + if self.verbose: + self._log(syslog.LOG_INFO, f"Found zone ID {zone_id} for {zone_name}") + + return zone_id + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to get zone: {str(e)}") + return None + + def list_records(self, zone_id, record_types=None): + """ + List DNS records for a zone. + Filters to A and AAAA records by default. + """ + if record_types is None: + record_types = ['A', 'AAAA'] + + try: + response = self._request('GET', f'/zones/{zone_id}/rrsets') + + if response.status_code == 404: + self._log(syslog.LOG_ERR, f"Zone {zone_id} not found") + return [] + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list records: HTTP {response.status_code}") + return [] + + data = response.json() + rrsets = data.get('rrsets', []) + + result = [] + for rrset in rrsets: + if rrset.get('type') in record_types: + records = rrset.get('records', []) + value = records[0].get('value', '') if records else '' + + result.append({ + 'name': rrset.get('name', ''), + 'type': rrset.get('type', ''), + 'value': value, + 'ttl': rrset.get('ttl', 300) + }) + + if self.verbose: + self._log(syslog.LOG_INFO, f"Found {len(result)} A/AAAA records in zone {zone_id}") + + return result + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to list records: {str(e)}") + return [] + + def get_record(self, zone_id, name, record_type): + """Get a specific DNS record by name and type.""" + try: + response = self._request('GET', f'/zones/{zone_id}/rrsets/{name}/{record_type}') + + if response.status_code == 404: + return None + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to get record: HTTP {response.status_code}") + return None + + data = response.json() + rrset = data.get('rrset', {}) + + records = rrset.get('records', []) + value = records[0].get('value', '') if records else '' + + return { + 'name': rrset.get('name', ''), + 'type': rrset.get('type', ''), + 'value': value, + 'ttl': rrset.get('ttl', 300) + } + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to get record: {str(e)}") + return None + + def update_record(self, zone_id, name, record_type, value, ttl=300): + """ + Update existing record with new value. + Returns tuple (success: bool, message: str) + + NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update. + Workaround: DELETE old record, then POST new record. + """ + try: + # Check if record exists + existing = self.get_record(zone_id, name, record_type) + + if not existing: + # Record doesn't exist, create it + return self.create_record(zone_id, name, record_type, value, ttl) + + # Check if value is same - no update needed + if existing.get('value') == str(value): + return True, "unchanged" + + # Workaround for Cloud API PUT bug: DELETE then POST + # DELETE the old record + delete_response = self._request( + 'DELETE', f'/zones/{zone_id}/rrsets/{name}/{record_type}' + ) + + if delete_response.status_code not in [200, 201, 204]: + error_msg = f"DELETE failed: HTTP {delete_response.status_code}" + self._log(syslog.LOG_ERR, f"Failed to update {name} {record_type}: {error_msg}") + return False, error_msg + + # POST new record + return self.create_record(zone_id, name, record_type, value, ttl) + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to update record: {str(e)}") + return False, str(e) + + def create_record(self, zone_id, name, record_type, value, ttl=300): + """ + Create new DNS record. + Returns tuple (success: bool, message: str) + """ + try: + url = f'/zones/{zone_id}/rrsets' + data = { + 'name': name, + 'type': record_type, + 'records': [{'value': str(value)}], + 'ttl': ttl + } + + response = self._request('POST', url, json_data=data) + + if response.status_code in [200, 201]: + if self.verbose: + self._log(syslog.LOG_INFO, f"Created {name} {record_type} -> {value}") + return True, f"Created {name} {record_type}" + + error_msg = f"HTTP {response.status_code}" + try: + error_data = response.json() + if 'error' in error_data: + error_msg = error_data['error'].get('message', error_msg) + except Exception: + pass + + self._log(syslog.LOG_ERR, f"Failed to create {name} {record_type}: {error_msg}") + return False, error_msg + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to create record: {str(e)}") + return False, str(e) + + def delete_record(self, zone_id, name, record_type): + """ + Delete a DNS record. + Returns tuple (success: bool, message: str) + """ + try: + response = self._request('DELETE', f'/zones/{zone_id}/rrsets/{name}/{record_type}') + + if response.status_code in [200, 201, 204]: + if self.verbose: + self._log(syslog.LOG_INFO, f"Deleted {name} {record_type}") + return True, f"Deleted {name} {record_type}" + + if response.status_code == 404: + return True, "Record not found (already deleted)" + + error_msg = f"HTTP {response.status_code}" + self._log(syslog.LOG_ERR, f"Failed to delete {name} {record_type}: {error_msg}") + return False, error_msg + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to delete record: {str(e)}") + return False, str(e) + + +class HetznerLegacyAPI: + """ + Hetzner DNS Console API (dns.hetzner.com) + Uses Auth-API-Token authentication and /records endpoints + Will be deprecated May 2026 + """ + + _api_base = "https://dns.hetzner.com/api/v1" + + def __init__(self, token, verbose=False): + self.token = token + self.verbose = verbose + self.headers = { + 'User-Agent': 'OPNsense-HCloudDNS/2.0', + 'Auth-API-Token': token, + 'Content-Type': 'application/json' + } + + def _log(self, level, message): + """Log message to syslog""" + syslog.syslog(level, f"HCloudDNS: {message}") + + def _request(self, method, endpoint, params=None, json_data=None): + """Make API request with error handling""" + url = f"{self._api_base}{endpoint}" + + try: + response = requests.request( + method=method, + url=url, + headers=self.headers, + params=params, + json=json_data, + timeout=TIMEOUT + ) + + if self.verbose: + self._log(syslog.LOG_DEBUG, f"{method} {endpoint} -> {response.status_code}") + + return response + + except requests.exceptions.Timeout: + raise HetznerAPIError("API request timed out") + except requests.exceptions.ConnectionError: + raise HetznerAPIError("Failed to connect to Hetzner DNS API") + except requests.exceptions.RequestException as e: + raise HetznerAPIError(f"API request failed: {str(e)}") + + def validate_token(self): + """ + Validate token by attempting to list zones. + Returns tuple (valid: bool, message: str, zone_count: int) + """ + try: + response = self._request('GET', '/zones') + + if response.status_code == 401: + return False, "Invalid API token", 0 + + if response.status_code == 403: + return False, "API token lacks required permissions", 0 + + if response.status_code != 200: + return False, f"API error: HTTP {response.status_code}", 0 + + data = response.json() + zones = data.get('zones', []) + zone_count = len(zones) + + return True, f"Token valid - {zone_count} zone(s) found", zone_count + + except HetznerAPIError as e: + return False, str(e), 0 + except Exception as e: + return False, f"Unexpected error: {str(e)}", 0 + + def list_zones(self): + """List all DNS zones accessible with this token.""" + try: + response = self._request('GET', '/zones') + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") + return [] + + data = response.json() + zones = data.get('zones', []) + + result = [] + for zone in zones: + result.append({ + 'id': zone.get('id', ''), + 'name': zone.get('name', ''), + 'records_count': zone.get('records_count', 0), + 'status': zone.get('status', 'unknown') + }) + + if self.verbose: + self._log(syslog.LOG_INFO, f"Found {len(result)} zones") + + return result + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to list zones: {str(e)}") + return [] + + def get_zone_id(self, zone_name): + """Get zone ID by zone name""" + try: + response = self._request('GET', '/zones') + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to get zones: HTTP {response.status_code}") + return None + + data = response.json() + zones = data.get('zones', []) + + for zone in zones: + if zone.get('name') == zone_name: + zone_id = zone.get('id') + if self.verbose: + self._log(syslog.LOG_INFO, f"Found zone ID {zone_id} for {zone_name}") + return zone_id + + self._log(syslog.LOG_ERR, f"Zone '{zone_name}' not found") + return None + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to get zone: {str(e)}") + return None + + def list_records(self, zone_id, record_types=None): + """List DNS records for a zone.""" + if record_types is None: + record_types = ['A', 'AAAA'] + + try: + response = self._request('GET', '/records', params={'zone_id': zone_id}) + + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list records: HTTP {response.status_code}") + return [] + + data = response.json() + records = data.get('records', []) + + result = [] + for record in records: + if record.get('type') in record_types: + result.append({ + 'id': record.get('id', ''), + 'name': record.get('name', ''), + 'type': record.get('type', ''), + 'value': record.get('value', ''), + 'ttl': record.get('ttl', 300) + }) + + if self.verbose: + self._log(syslog.LOG_INFO, f"Found {len(result)} A/AAAA records in zone {zone_id}") + + return result + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to list records: {str(e)}") + return [] + + def get_record(self, zone_id, name, record_type): + """Get a specific DNS record by name and type.""" + records = self.list_records(zone_id, [record_type]) + + for record in records: + if record.get('name') == name and record.get('type') == record_type: + return record + + return None + + def _get_record_id(self, zone_id, name, record_type): + """Get record ID by name and type""" + record = self.get_record(zone_id, name, record_type) + return record.get('id') if record else None + + def update_record(self, zone_id, name, record_type, value, ttl=300): + """ + Update or create a DNS record. + Returns tuple (success: bool, message: str) + """ + try: + record_id = self._get_record_id(zone_id, name, record_type) + + if record_id: + # Update existing record + url = f'/records/{record_id}' + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': name, + 'value': str(value), + 'ttl': ttl + } + + response = self._request('PUT', url, json_data=data) + + if response.status_code == 200: + if self.verbose: + self._log(syslog.LOG_INFO, f"Updated {name} {record_type} -> {value}") + return True, f"Updated {name} {record_type}" + + error_msg = f"HTTP {response.status_code}" + self._log(syslog.LOG_ERR, f"Failed to update {name} {record_type}: {error_msg}") + return False, error_msg + else: + # Create new record + return self.create_record(zone_id, name, record_type, value, ttl) + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to update record: {str(e)}") + return False, str(e) + + def create_record(self, zone_id, name, record_type, value, ttl=300): + """ + Create new DNS record. + Returns tuple (success: bool, message: str) + """ + try: + url = '/records' + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': name, + 'value': str(value), + 'ttl': ttl + } + + response = self._request('POST', url, json_data=data) + + if response.status_code in [200, 201]: + if self.verbose: + self._log(syslog.LOG_INFO, f"Created {name} {record_type} -> {value}") + return True, f"Created {name} {record_type}" + + error_msg = f"HTTP {response.status_code}" + self._log(syslog.LOG_ERR, f"Failed to create {name} {record_type}: {error_msg}") + return False, error_msg + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to create record: {str(e)}") + return False, str(e) + + def delete_record(self, zone_id, name, record_type): + """ + Delete a DNS record. + Returns tuple (success: bool, message: str) + """ + try: + record_id = self._get_record_id(zone_id, name, record_type) + + if not record_id: + return True, "Record not found (already deleted)" + + response = self._request('DELETE', f'/records/{record_id}') + + if response.status_code in [200, 204]: + if self.verbose: + self._log(syslog.LOG_INFO, f"Deleted {name} {record_type}") + return True, f"Deleted {name} {record_type}" + + error_msg = f"HTTP {response.status_code}" + self._log(syslog.LOG_ERR, f"Failed to delete {name} {record_type}: {error_msg}") + return False, error_msg + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to delete record: {str(e)}") + return False, str(e) + + +def create_api(token, api_type='cloud', verbose=False): + """ + Factory function to create the appropriate API instance. + api_type: 'cloud' for api.hetzner.cloud, 'dns' for dns.hetzner.com + """ + if api_type == 'dns': + return HetznerLegacyAPI(token, verbose) + return HetznerCloudAPI(token, verbose) diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py new file mode 100755 index 0000000000..8cee2d606a --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py @@ -0,0 +1,62 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + List DNS records for a zone +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + +# All supported record types +ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA'] + + +def main(): + if len(sys.argv) < 3: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: list_records.py [all]', + 'records': [] + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + # Optional third arg: 'all' to list all record types + list_all = len(sys.argv) > 3 and sys.argv[3].strip().lower() == 'all' + + if not token or not zone_id: + print(json.dumps({ + 'status': 'error', + 'message': 'Token and zone_id are required', + 'records': [] + })) + sys.exit(1) + + api = HCloudAPI(token) + + # List all record types or just A/AAAA + record_types = ALL_RECORD_TYPES if list_all else ['A', 'AAAA'] + records = api.list_records(zone_id, record_types) + + # Sort records: first by type priority, then by name + type_order = {t: i for i, t in enumerate(ALL_RECORD_TYPES)} + records.sort(key=lambda r: (type_order.get(r['type'], 99), r['name'])) + + result = { + 'status': 'ok' if records is not None else 'error', + 'message': f'Found {len(records)} record(s)' if records else 'No records found or API error', + 'records': records if records else [] + } + + print(json.dumps(result)) + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py new file mode 100755 index 0000000000..7f4cd95a0c --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py @@ -0,0 +1,49 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + List DNS zones for Hetzner Cloud API token +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + token = None + + if len(sys.argv) > 1: + token = sys.argv[1].strip() + else: + try: + token = sys.stdin.read().strip() + except Exception: + pass + + if not token: + print(json.dumps({ + 'status': 'error', + 'message': 'No API token provided', + 'zones': [] + })) + sys.exit(1) + + api = HCloudAPI(token) + zones = api.list_zones() + + result = { + 'status': 'ok' if zones else 'error', + 'message': f'Found {len(zones)} zone(s)' if zones else 'No zones found or API error', + 'zones': zones + } + + print(json.dumps(result)) + sys.exit(0 if zones else 1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py new file mode 100755 index 0000000000..a646eeb003 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Refresh status of all entries from Hetzner DNS API +""" + +import json +import sys +import os +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def refresh_status(): + """Refresh status of all configured entries from Hetzner""" + result = { + 'status': 'ok', + 'entries': [], + 'errors': [] + } + + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + + hcloud = root.find('.//OPNsense/HCloudDNS') + if hcloud is None: + return {'status': 'ok', 'entries': [], 'message': 'No configuration found'} + + # Load accounts (tokens) + accounts = {} + accounts_node = hcloud.find('accounts') + if accounts_node is not None: + for acc in accounts_node.findall('account'): + acc_uuid = acc.get('uuid', '') + if acc_uuid and acc.findtext('enabled', '1') == '1': + accounts[acc_uuid] = { + 'token': acc.findtext('apiToken', ''), + 'apiType': acc.findtext('apiType', 'cloud'), + 'name': acc.findtext('name', '') + } + + # Get all entries + entries_node = hcloud.find('entries') + if entries_node is None: + return {'status': 'ok', 'entries': [], 'message': 'No entries configured'} + + # Cache records by (account, zone_id) to minimize API calls + zone_records_cache = {} + api_cache = {} # Cache API instances per account + + for entry in entries_node.findall('entry'): + entry_uuid = entry.get('uuid', '') + account_uuid = entry.findtext('account', '') + zone_id = entry.findtext('zoneId', '') + zone_name = entry.findtext('zoneName', '') + record_name = entry.findtext('recordName', '') + record_type = entry.findtext('recordType', 'A') + current_status = entry.findtext('status', 'pending') + + if not zone_id or not record_name: + continue + + # Get account/token for this entry + account = accounts.get(account_uuid) + if not account or not account['token']: + result['errors'].append({ + 'uuid': entry_uuid, + 'error': f'No valid account/token for entry {record_name}.{zone_name}' + }) + continue + + # Get or create API instance for this account + if account_uuid not in api_cache: + api_cache[account_uuid] = HCloudAPI(account['token'], api_type=account['apiType']) + api = api_cache[account_uuid] + + # Cache key includes account to handle different tokens + cache_key = f"{account_uuid}:{zone_id}" + + # Get records for this zone (cached) + if cache_key not in zone_records_cache: + try: + zone_records_cache[cache_key] = api.list_records(zone_id) + except Exception as e: + result['errors'].append({ + 'uuid': entry_uuid, + 'error': f'Failed to get records for zone {zone_name}: {str(e)}' + }) + zone_records_cache[cache_key] = [] + + # Find matching record + hetzner_ip = None + record_id = None + for record in zone_records_cache[cache_key]: + if record.get('name') == record_name and record.get('type') == record_type: + hetzner_ip = record.get('value') + record_id = record.get('id') + break + + entry_status = { + 'uuid': entry_uuid, + 'zoneName': zone_name, + 'recordName': record_name, + 'recordType': record_type, + 'hetznerIp': hetzner_ip, + 'recordId': record_id, + 'configStatus': current_status + } + + if hetzner_ip: + entry_status['status'] = 'found' + else: + entry_status['status'] = 'not_found' + + result['entries'].append(entry_status) + + except ET.ParseError as e: + return {'status': 'error', 'message': f'Config parse error: {str(e)}'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + return result + + +def main(): + result = refresh_status() + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py new file mode 100755 index 0000000000..1c4a33216a --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Failover Simulator for HCloudDNS +Allows testing failover logic without actual gateway failures +""" + +import json +import sys +import os +import time + +STATE_FILE = '/var/run/hclouddns_state.json' +SIMULATION_FILE = '/var/run/hclouddns_simulation.json' + + +def load_state(): + """Load gateway state from file""" + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {'gateways': {}, 'entries': {}, 'failoverHistory': [], 'lastUpdate': 0} + + +def load_simulation(): + """Load simulation settings""" + if os.path.exists(SIMULATION_FILE): + try: + with open(SIMULATION_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {'active': False, 'simulatedDown': []} + + +def save_simulation(sim): + """Save simulation settings""" + try: + with open(SIMULATION_FILE, 'w') as f: + json.dump(sim, f, indent=2) + except IOError as e: + sys.stderr.write(f"Error saving simulation: {e}\n") + + +def simulate_gateway_down(gateway_uuid): + """Simulate a gateway going down""" + sim = load_simulation() + if gateway_uuid not in sim.get('simulatedDown', []): + sim.setdefault('simulatedDown', []).append(gateway_uuid) + sim['active'] = True + save_simulation(sim) + return {'status': 'ok', 'message': f'Gateway {gateway_uuid} simulated as DOWN', 'simulation': sim} + + +def simulate_gateway_up(gateway_uuid): + """Simulate a gateway coming back up""" + sim = load_simulation() + if gateway_uuid in sim.get('simulatedDown', []): + sim['simulatedDown'].remove(gateway_uuid) + if not sim['simulatedDown']: + sim['active'] = False + save_simulation(sim) + return {'status': 'ok', 'message': f'Gateway {gateway_uuid} simulated as UP', 'simulation': sim} + + +def clear_simulation(): + """Clear all simulations and reset gateway upSince for immediate failback""" + sim = {'active': False, 'simulatedDown': []} + save_simulation(sim) + + # Also update state file to allow immediate failback + # by setting upSince to a time in the past for all gateways + state = load_state() + past_time = int(time.time()) - 3600 # 1 hour ago + for uuid in state.get('gateways', {}): + state['gateways'][uuid]['upSince'] = past_time + state['gateways'][uuid]['status'] = 'up' + state['gateways'][uuid]['simulated'] = False + try: + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + except IOError: + pass + + return {'status': 'ok', 'message': 'Simulation cleared', 'simulation': sim} + + +def get_simulation_status(): + """Get current simulation status""" + sim = load_simulation() + state = load_state() + + result = { + 'status': 'ok', + 'simulation': sim, + 'gateways': {} + } + + for uuid, gw_state in state.get('gateways', {}).items(): + is_simulated_down = uuid in sim.get('simulatedDown', []) + result['gateways'][uuid] = { + 'realStatus': gw_state.get('status', 'unknown'), + 'simulatedDown': is_simulated_down, + 'effectiveStatus': 'down' if is_simulated_down else gw_state.get('status', 'unknown'), + 'ipv4': gw_state.get('ipv4'), + 'ipv6': gw_state.get('ipv6') + } + + return result + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({'status': 'error', 'message': 'Usage: simulate_failover.py [gateway_uuid]'})) + sys.exit(1) + + action = sys.argv[1] + + if action == 'down': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'})) + sys.exit(1) + result = simulate_gateway_down(sys.argv[2]) + + elif action == 'up': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'})) + sys.exit(1) + result = simulate_gateway_up(sys.argv[2]) + + elif action == 'clear': + result = clear_simulation() + + elif action == 'status': + result = get_simulation_status() + + else: + result = {'status': 'error', 'message': f'Unknown action: {action}'} + + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py new file mode 100755 index 0000000000..4a93a58d07 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py @@ -0,0 +1,124 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Get status of HCloudDNS accounts +""" +import sys +import json +import os +import time +from xml.etree import ElementTree + +STATE_PATH = '/var/cache/hclouddns' +CONFIG_PATH = '/conf/config.xml' + + +def get_config(): + """Read HCloudDNS configuration from OPNsense config.xml""" + try: + tree = ElementTree.parse(CONFIG_PATH) + root = tree.getroot() + + hcloud = root.find('.//OPNsense/HCloudDNS') + if hcloud is None: + return None + + config = { + 'general': {}, + 'accounts': [] + } + + general = hcloud.find('general') + if general is not None: + config['general'] = { + 'enabled': general.findtext('enabled', '0') == '1', + 'verbose': general.findtext('verbose', '0') == '1' + } + + accounts = hcloud.find('accounts') + if accounts is not None: + for account in accounts.findall('account'): + acc = { + 'uuid': account.get('uuid', ''), + 'enabled': account.findtext('enabled', '0') == '1', + 'description': account.findtext('description', ''), + 'zoneName': account.findtext('zoneName', ''), + 'records': account.findtext('records', '').split(','), + 'updateIPv4': account.findtext('updateIPv4', '1') == '1', + 'updateIPv6': account.findtext('updateIPv6', '1') == '1', + } + acc['records'] = [r.strip() for r in acc['records'] if r.strip()] + config['accounts'].append(acc) + + return config + + except Exception: + return None + + +def load_state(account_uuid): + """Load last known state for an account""" + state_file = os.path.join(STATE_PATH, f"{account_uuid}.json") + try: + if os.path.exists(state_file): + with open(state_file, 'r') as f: + return json.load(f) + except Exception: + pass + return {'ipv4': None, 'ipv6': None, 'last_update': 0} + + +def format_time_ago(timestamp): + """Format timestamp as human-readable time ago""" + if not timestamp: + return 'Never' + + diff = int(time.time()) - timestamp + + if diff < 60: + return f"{diff} seconds ago" + elif diff < 3600: + return f"{diff // 60} minutes ago" + elif diff < 86400: + return f"{diff // 3600} hours ago" + else: + return f"{diff // 86400} days ago" + + +def main(): + config = get_config() + + result = { + 'enabled': False, + 'accounts': [] + } + + if config: + result['enabled'] = config['general'].get('enabled', False) + + for account in config['accounts']: + state = load_state(account['uuid']) + + acc_status = { + 'uuid': account['uuid'], + 'description': account['description'], + 'enabled': account['enabled'], + 'zone': account['zoneName'], + 'records': account['records'], + 'current_ipv4': state.get('ipv4', 'Unknown'), + 'current_ipv6': state.get('ipv6', 'Unknown'), + 'last_update': state.get('last_update', 0), + 'last_update_formatted': format_time_ago(state.get('last_update', 0)), + 'update_ipv4': account['updateIPv4'], + 'update_ipv6': account['updateIPv6'] + } + result['accounts'].append(acc_status) + + print(json.dumps(result, indent=2)) + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py new file mode 100644 index 0000000000..fd043f2403 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py @@ -0,0 +1,184 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Test notification channels for HCloudDNS +""" +import json +import subprocess +import urllib.request +import urllib.error +from xml.etree import ElementTree + +CONFIG_PATH = '/conf/config.xml' + + +def get_notification_settings(): + """Read notification settings from OPNsense config.xml""" + try: + tree = ElementTree.parse(CONFIG_PATH) + root = tree.getroot() + + hcloud = root.find('.//OPNsense/HCloudDNS') + if hcloud is None: + return None + + notifications = hcloud.find('notifications') + if notifications is None: + return None + + return { + 'enabled': notifications.findtext('enabled', '0') == '1', + 'emailEnabled': notifications.findtext('emailEnabled', '0') == '1', + 'emailTo': notifications.findtext('emailTo', ''), + 'webhookEnabled': notifications.findtext('webhookEnabled', '0') == '1', + 'webhookUrl': notifications.findtext('webhookUrl', ''), + 'webhookMethod': notifications.findtext('webhookMethod', 'POST'), + 'ntfyEnabled': notifications.findtext('ntfyEnabled', '0') == '1', + 'ntfyServer': notifications.findtext('ntfyServer', 'https://ntfy.sh'), + 'ntfyTopic': notifications.findtext('ntfyTopic', ''), + 'ntfyPriority': notifications.findtext('ntfyPriority', 'default'), + } + except Exception: + return None + + +def send_email(to_address): + """Send test email using OPNsense mail system""" + try: + subject = "HCloudDNS Test Notification" + body = "This is a test notification from HCloudDNS plugin.\n\nIf you received this, email notifications are working correctly." + + # Use OPNsense's mail command + result = subprocess.run( + ['/usr/local/bin/mail', '-s', subject, to_address], + input=body.encode(), + capture_output=True, + timeout=30 + ) + + if result.returncode == 0: + return {'success': True, 'message': f'Sent to {to_address}'} + else: + return {'success': False, 'message': result.stderr.decode()[:100]} + except subprocess.TimeoutExpired: + return {'success': False, 'message': 'Timeout sending email'} + except Exception as e: + return {'success': False, 'message': str(e)[:100]} + + +def send_webhook(url, method): + """Send test webhook notification""" + try: + payload = { + 'event': 'test', + 'message': 'This is a test notification from HCloudDNS plugin', + 'timestamp': __import__('time').time(), + 'plugin': 'os-hclouddns' + } + + data = json.dumps(payload).encode('utf-8') + headers = {'Content-Type': 'application/json'} + + if method == 'GET': + # For GET, append as query params + import urllib.parse + params = urllib.parse.urlencode({'event': 'test', 'message': 'HCloudDNS test'}) + url = f"{url}?{params}" if '?' not in url else f"{url}&{params}" + req = urllib.request.Request(url, headers=headers, method='GET') + else: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + + with urllib.request.urlopen(req, timeout=10) as response: + return {'success': True, 'message': f'HTTP {response.status}'} + except urllib.error.HTTPError as e: + return {'success': False, 'message': f'HTTP {e.code}: {e.reason}'} + except urllib.error.URLError as e: + return {'success': False, 'message': str(e.reason)[:100]} + except Exception as e: + return {'success': False, 'message': str(e)[:100]} + + +def send_ntfy(server, topic, priority): + """Send test ntfy notification""" + try: + url = f"{server.rstrip('/')}/{topic}" + + priority_map = { + 'min': '1', + 'low': '2', + 'default': '3', + 'high': '4', + 'urgent': '5' + } + + headers = { + 'Title': 'HCloudDNS Test', + 'Priority': priority_map.get(priority, '3'), + 'Tags': 'test,hclouddns' + } + + message = "This is a test notification from HCloudDNS plugin." + req = urllib.request.Request(url, data=message.encode('utf-8'), headers=headers, method='POST') + + with urllib.request.urlopen(req, timeout=10): + return {'success': True, 'message': f'Sent to {topic}'} + except urllib.error.HTTPError as e: + return {'success': False, 'message': f'HTTP {e.code}: {e.reason}'} + except urllib.error.URLError as e: + return {'success': False, 'message': str(e.reason)[:100]} + except Exception as e: + return {'success': False, 'message': str(e)[:100]} + + +def main(): + settings = get_notification_settings() + + result = { + 'status': 'ok', + 'results': {} + } + + if not settings: + result['status'] = 'error' + result['message'] = 'Could not read notification settings' + print(json.dumps(result)) + return + + if not settings['enabled']: + result['status'] = 'error' + result['message'] = 'Notifications are disabled' + print(json.dumps(result)) + return + + # Test each enabled channel + channels_tested = 0 + + if settings['emailEnabled'] and settings['emailTo']: + result['results']['email'] = send_email(settings['emailTo']) + channels_tested += 1 + + if settings['webhookEnabled'] and settings['webhookUrl']: + result['results']['webhook'] = send_webhook(settings['webhookUrl'], settings['webhookMethod']) + channels_tested += 1 + + if settings['ntfyEnabled'] and settings['ntfyTopic']: + result['results']['ntfy'] = send_ntfy(settings['ntfyServer'], settings['ntfyTopic'], settings['ntfyPriority']) + channels_tested += 1 + + if channels_tested == 0: + result['status'] = 'error' + result['message'] = 'No notification channels configured' + else: + # Check if any succeeded + successes = sum(1 for r in result['results'].values() if r.get('success')) + if successes == 0: + result['status'] = 'error' + result['message'] = 'All notification tests failed' + + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py new file mode 100644 index 0000000000..db2a51c980 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py @@ -0,0 +1,78 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Update an existing DNS record at Hetzner +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Expected args: token zone_id record_name record_type value ttl + if len(sys.argv) < 7: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: update_record.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + record_name = sys.argv[3].strip() + record_type = sys.argv[4].strip().upper() + value = sys.argv[5].strip() + ttl = int(sys.argv[6].strip()) if sys.argv[6].strip().isdigit() else 300 + + if not all([token, zone_id, record_name, value]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + # Support all common record types + supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA'] + if record_type not in supported_types: + print(json.dumps({ + 'status': 'error', + 'message': f'Unsupported record type: {record_type}. Supported: {", ".join(supported_types)}' + })) + sys.exit(1) + + api = HCloudAPI(token) + + # TXT records need to be quoted for Hetzner API + if record_type == 'TXT' and not value.startswith('"'): + value = f'"{value}"' + + try: + success, message = api.update_record(zone_id, record_name, record_type, value, ttl) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Record {record_name} ({record_type}) updated successfully', + 'unchanged': message == 'unchanged' + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to update record: {message}' + })) + sys.exit(1) + except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py new file mode 100755 index 0000000000..ac82fd22ff --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py @@ -0,0 +1,304 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Update DNS records for HCloudDNS - reads config from OPNsense model +""" +import sys +import json +import os +import syslog +import subprocess +import re +from xml.etree import ElementTree + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + +CONFIG_PATH = '/conf/config.xml' +STATE_PATH = '/var/cache/hclouddns' + + +def get_config(): + """Read HCloudDNS configuration from OPNsense config.xml""" + try: + tree = ElementTree.parse(CONFIG_PATH) + root = tree.getroot() + + hcloud = root.find('.//OPNsense/HCloudDNS') + if hcloud is None: + return None + + config = { + 'general': {}, + 'accounts': [] + } + + # Parse general settings + general = hcloud.find('general') + if general is not None: + config['general'] = { + 'enabled': general.findtext('enabled', '0') == '1', + 'checkInterval': int(general.findtext('checkInterval', '300')), + 'forceInterval': int(general.findtext('forceInterval', '0')), + 'verbose': general.findtext('verbose', '0') == '1' + } + + # Parse accounts + accounts = hcloud.find('accounts') + if accounts is not None: + for account in accounts.findall('account'): + acc = { + 'uuid': account.get('uuid', ''), + 'enabled': account.findtext('enabled', '0') == '1', + 'description': account.findtext('description', ''), + 'apiToken': account.findtext('apiToken', ''), + 'zoneId': account.findtext('zoneId', ''), + 'zoneName': account.findtext('zoneName', ''), + 'records': account.findtext('records', '').split(','), + 'updateIPv4': account.findtext('updateIPv4', '1') == '1', + 'updateIPv6': account.findtext('updateIPv6', '1') == '1', + 'checkip': account.findtext('checkip', 'if'), + 'checkipInterface': account.findtext('checkipInterface', ''), + 'ttl': int(account.findtext('ttl', '300')) + } + # Filter empty records + acc['records'] = [r.strip() for r in acc['records'] if r.strip()] + config['accounts'].append(acc) + + return config + + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to read config: {str(e)}") + return None + + +def get_current_ip(method, interface=None, ip_version=4): + """Get current public IP address""" + if method == 'if' and interface: + # Get IP from interface + try: + family = 'inet6' if ip_version == 6 else 'inet' + cmd = f"ifconfig {interface} | grep '{family} ' | head -1" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + + if result.returncode == 0 and result.stdout: + # Parse IP from ifconfig output + line = result.stdout.strip() + if ip_version == 6: + # inet6 fe80::1%em0 prefixlen 64 scopeid 0x1 + match = re.search(r'inet6\s+([0-9a-fA-F:]+)', line) + if match: + ip = match.group(1) + # Skip link-local addresses + if not ip.startswith('fe80'): + return ip + else: + # inet 192.168.1.1 netmask 0xffffff00 broadcast 192.168.1.255 + match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line) + if match: + return match.group(1) + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to get IP from interface: {str(e)}") + + else: + # Use web service + services = { + 'web_ipify': ('https://api.ipify.org', 'https://api6.ipify.org'), + 'web_ip4only': ('https://ip4only.me/api/', None), + 'web_ip6only': (None, 'https://ip6only.me/api/'), + 'web_dyndns': ('http://checkip.dyndns.org', None), + 'web_freedns': ('https://freedns.afraid.org/dynamic/check.php', None), + 'web_he': ('http://checkip.dns.he.net', None), + } + + urls = services.get(method, ('https://api.ipify.org', 'https://api6.ipify.org')) + url = urls[1] if ip_version == 6 else urls[0] + + if url: + try: + import requests + response = requests.get(url, timeout=10) + if response.status_code == 200: + # Extract IP from response + text = response.text.strip() + if ip_version == 6: + match = re.search(r'([0-9a-fA-F:]+:[0-9a-fA-F:]+)', text) + else: + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text) + if match: + return match.group(1) + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to get IP from {url}: {str(e)}") + + return None + + +def load_state(account_uuid): + """Load last known state for an account""" + state_file = os.path.join(STATE_PATH, f"{account_uuid}.json") + try: + if os.path.exists(state_file): + with open(state_file, 'r') as f: + return json.load(f) + except Exception: + pass + return {'ipv4': None, 'ipv6': None, 'last_update': 0} + + +def save_state(account_uuid, state): + """Save state for an account""" + os.makedirs(STATE_PATH, exist_ok=True) + state_file = os.path.join(STATE_PATH, f"{account_uuid}.json") + try: + with open(state_file, 'w') as f: + json.dump(state, f) + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to save state: {str(e)}") + + +def update_account(account, verbose=False): + """Update DNS records for a single account""" + results = [] + + api = HCloudAPI(account['apiToken'], verbose=verbose) + + # Get current IPs + current_ipv4 = None + current_ipv6 = None + + if account['updateIPv4']: + current_ipv4 = get_current_ip(account['checkip'], account['checkipInterface'], 4) + if verbose and current_ipv4: + syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Current IPv4: {current_ipv4}") + + if account['updateIPv6']: + current_ipv6 = get_current_ip(account['checkip'], account['checkipInterface'], 6) + if verbose and current_ipv6: + syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Current IPv6: {current_ipv6}") + + if not current_ipv4 and not current_ipv6: + syslog.syslog(syslog.LOG_WARNING, f"HCloudDNS: [{account['description']}] No IP address detected") + return [{'status': 'error', 'message': 'No IP address detected'}] + + # Load previous state + state = load_state(account['uuid']) + + # Check if update needed + ipv4_changed = current_ipv4 and current_ipv4 != state.get('ipv4') + ipv6_changed = current_ipv6 and current_ipv6 != state.get('ipv6') + + if not ipv4_changed and not ipv6_changed: + if verbose: + syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: [{account['description']}] No IP change detected") + return [{'status': 'ok', 'message': 'No update needed'}] + + # Update each record + for record_spec in account['records']: + # record_spec format: "name:type" e.g. "www:A" or "@:AAAA" + if ':' in record_spec: + name, rtype = record_spec.split(':', 1) + else: + # Default: update both A and AAAA + name = record_spec + rtype = None + + # Determine which updates to perform + updates = [] + if rtype: + if rtype == 'A' and current_ipv4 and ipv4_changed: + updates.append(('A', current_ipv4)) + elif rtype == 'AAAA' and current_ipv6 and ipv6_changed: + updates.append(('AAAA', current_ipv6)) + else: + if current_ipv4 and ipv4_changed: + updates.append(('A', current_ipv4)) + if current_ipv6 and ipv6_changed: + updates.append(('AAAA', current_ipv6)) + + for record_type, ip in updates: + success, message = api.update_record( + account['zoneId'], + name, + record_type, + ip, + account['ttl'] + ) + + result = { + 'record': f"{name}.{account['zoneName']}", + 'type': record_type, + 'ip': ip, + 'status': 'ok' if success else 'error', + 'message': message + } + results.append(result) + + if success: + syslog.syslog( + syslog.LOG_NOTICE, + f"HCloudDNS: [{account['description']}] Updated {name} {record_type} -> {ip}" + ) + else: + syslog.syslog( + syslog.LOG_ERR, + f"HCloudDNS: [{account['description']}] Failed to update {name} {record_type}: {message}" + ) + + # Save state if any updates succeeded + if any(r['status'] == 'ok' for r in results): + if current_ipv4 and ipv4_changed: + state['ipv4'] = current_ipv4 + if current_ipv6 and ipv6_changed: + state['ipv6'] = current_ipv6 + import time + state['last_update'] = int(time.time()) + save_state(account['uuid'], state) + + return results + + +def main(): + syslog.openlog('HCloudDNS', syslog.LOG_PID, syslog.LOG_DAEMON) + + config = get_config() + if not config: + print(json.dumps({ + 'status': 'error', + 'message': 'Failed to read configuration' + })) + sys.exit(1) + + if not config['general'].get('enabled', False): + print(json.dumps({ + 'status': 'ok', + 'message': 'HCloudDNS is disabled' + })) + sys.exit(0) + + verbose = config['general'].get('verbose', False) + all_results = [] + + for account in config['accounts']: + if not account['enabled']: + continue + + if verbose: + syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Processing account [{account['description']}]") + + results = update_account(account, verbose) + all_results.append({ + 'account': account['description'], + 'results': results + }) + + print(json.dumps({ + 'status': 'ok', + 'accounts': all_results + })) + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py new file mode 100755 index 0000000000..81467825a3 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Update DNS records with multi-gateway failover support (v2) +""" + +import json +import sys +import os +import time +import xml.etree.ElementTree as ET +import syslog + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI +from gateway_health import get_gateway_ip + +STATE_FILE = '/var/run/hclouddns_state.json' +SIMULATION_FILE = '/var/run/hclouddns_simulation.json' + + +def load_simulation(): + """Load simulation settings""" + if os.path.exists(SIMULATION_FILE): + try: + with open(SIMULATION_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {'active': False, 'simulatedDown': []} + + +def log(message, priority=syslog.LOG_INFO): + """Log to syslog""" + syslog.openlog('hclouddns', syslog.LOG_PID, syslog.LOG_LOCAL4) + syslog.syslog(priority, message) + + +def load_config(): + """Load configuration from OPNsense config.xml""" + config = { + 'enabled': False, + 'checkInterval': 300, + 'failoverEnabled': False, + 'failbackEnabled': True, + 'failbackDelay': 60, + 'verbose': False, + 'accounts': {}, + 'gateways': {}, + 'entries': [] + } + + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + + hcloud = root.find('.//OPNsense/HCloudDNS') + if hcloud is None: + return config + + # General settings + general = hcloud.find('general') + if general is not None: + config['enabled'] = general.findtext('enabled', '0') == '1' + config['checkInterval'] = int(general.findtext('checkInterval', '300')) + config['verbose'] = general.findtext('verbose', '0') == '1' + config['failoverEnabled'] = general.findtext('failoverEnabled', '0') == '1' + config['failbackEnabled'] = general.findtext('failbackEnabled', '1') == '1' + config['failbackDelay'] = int(general.findtext('failbackDelay', '60')) + + # Accounts (API tokens) + accounts = hcloud.find('accounts') + if accounts is not None: + for acc in accounts.findall('account'): + uuid = acc.get('uuid', '') + if not uuid: + continue + config['accounts'][uuid] = { + 'uuid': uuid, + 'enabled': acc.findtext('enabled', '1') == '1', + 'name': acc.findtext('name', ''), + 'apiType': acc.findtext('apiType', 'cloud'), + 'apiToken': acc.findtext('apiToken', '') + } + + # Gateways + gateways = hcloud.find('gateways') + if gateways is not None: + for gw in gateways.findall('gateway'): + uuid = gw.get('uuid', '') + if not uuid: + continue + config['gateways'][uuid] = { + 'uuid': uuid, + 'enabled': gw.findtext('enabled', '1') == '1', + 'name': gw.findtext('name', ''), + 'interface': gw.findtext('interface', ''), + 'priority': int(gw.findtext('priority', '10')), + 'checkipMethod': gw.findtext('checkipMethod', 'web_ipify'), + 'healthCheckTarget': gw.findtext('healthCheckTarget', '8.8.8.8') + } + + # Entries + entries = hcloud.find('entries') + if entries is not None: + for entry in entries.findall('entry'): + uuid = entry.get('uuid', '') + if not uuid: + continue + config['entries'].append({ + 'uuid': uuid, + 'enabled': entry.findtext('enabled', '1') == '1', + 'account': entry.findtext('account', ''), + 'zoneId': entry.findtext('zoneId', ''), + 'zoneName': entry.findtext('zoneName', ''), + 'recordId': entry.findtext('recordId', ''), + 'recordName': entry.findtext('recordName', ''), + 'recordType': entry.findtext('recordType', 'A'), + 'primaryGateway': entry.findtext('primaryGateway', ''), + 'failoverGateway': entry.findtext('failoverGateway', ''), + 'ttl': int(entry.findtext('ttl', '300')), + 'currentIp': entry.findtext('currentIp', ''), + 'status': entry.findtext('status', 'pending') + }) + + except Exception as e: + log(f'Error loading config: {str(e)}', syslog.LOG_ERR) + + return config + + +def load_runtime_state(): + """Load runtime state from JSON file""" + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return { + 'gateways': {}, + 'entries': {}, + 'failoverHistory': [], + 'lastUpdate': 0 + } + + +def save_runtime_state(state): + """Save runtime state to JSON file""" + try: + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + except IOError as e: + log(f'Error saving state: {str(e)}', syslog.LOG_ERR) + + +def check_all_gateways(config, state): + """Check health and get IPs for all gateways""" + simulation = load_simulation() + + for uuid, gw in config['gateways'].items(): + if not gw['enabled']: + continue + + if uuid not in state['gateways']: + state['gateways'][uuid] = { + 'status': 'unknown', + 'ipv4': None, + 'ipv6': None, + 'lastCheck': 0, + 'failCount': 0, + 'upSince': None, + 'simulated': False + } + + gw_state = state['gateways'][uuid] + + # Get current IP + ip_result = get_gateway_ip(uuid, gw) + gw_state['ipv4'] = ip_result.get('ipv4') + gw_state['ipv6'] = ip_result.get('ipv6') + + # Check if this gateway is simulated as down + is_simulated_down = simulation.get('active', False) and uuid in simulation.get('simulatedDown', []) + gw_state['simulated'] = is_simulated_down + + if is_simulated_down: + # Override status to down for simulation + old_status = gw_state.get('status', 'unknown') + gw_state['status'] = 'down' + gw_state['failCount'] = gw_state.get('failCount', 0) + 1 + if old_status == 'up': + log(f"SIMULATION: Gateway '{gw['name']}' is DOWN (simulated)", syslog.LOG_WARNING) + continue + + # Determine status based on IP availability + # (dpinger handles real gateway health via syshook - this is a fallback check) + has_ip = gw_state['ipv4'] or gw_state['ipv6'] + new_status = 'up' if has_ip else 'down' + + old_status = gw_state.get('status', 'unknown') + gw_state['lastCheck'] = int(time.time()) + + if new_status == 'up': + if old_status != 'up': + gw_state['upSince'] = int(time.time()) + log(f"Gateway '{gw['name']}' is UP (IP: {gw_state['ipv4']})") + gw_state['failCount'] = 0 + else: + gw_state['failCount'] = gw_state.get('failCount', 0) + 1 + if old_status == 'up': + log(f"Gateway '{gw['name']}' is DOWN (failCount: {gw_state['failCount']})", syslog.LOG_WARNING) + + gw_state['status'] = new_status + + return state + + +def determine_active_gateway(entry, config, state): + """ + Determine which gateway should be active for an entry + + Returns: (gateway_uuid, gateway_config, reason) + """ + primary_uuid = entry['primaryGateway'] + failover_uuid = entry.get('failoverGateway', '') + + primary_gw = config['gateways'].get(primary_uuid) + failover_gw = config['gateways'].get(failover_uuid) if failover_uuid else None + + primary_state = state['gateways'].get(primary_uuid, {}) + failover_state = state['gateways'].get(failover_uuid, {}) if failover_uuid else {} + + # Gateway is "up" if health check passes OR if we have a valid IP + primary_has_ip = primary_state.get('ipv4') or primary_state.get('ipv6') + failover_has_ip = failover_state.get('ipv4') or failover_state.get('ipv6') + + primary_healthy = primary_state.get('status') == 'up' + failover_healthy = failover_state.get('status') == 'up' + + # Primary is usable if enabled and has IP + primary_usable = primary_gw and primary_gw['enabled'] and primary_has_ip + failover_usable = failover_gw and failover_gw['enabled'] and failover_has_ip + + entry_state = state['entries'].get(entry['uuid'], {}) + current_active = entry_state.get('activeGateway') + + # If failover is enabled, use health status for decisions + if config['failoverEnabled']: + # Primary is healthy and usable + if primary_healthy and primary_usable: + # Check if we need failback + if current_active == failover_uuid and config['failbackEnabled']: + up_since = primary_state.get('upSince', 0) + if up_since and (time.time() - up_since) >= config['failbackDelay']: + return primary_uuid, primary_gw, 'failback' + else: + return failover_uuid, failover_gw, 'failback_pending' + return primary_uuid, primary_gw, 'primary' + + # Primary is down but failover is healthy + if failover_healthy and failover_usable: + return failover_uuid, failover_gw, 'failover' + + # Both unhealthy - use whichever has IP, prefer primary + if primary_usable: + return primary_uuid, primary_gw, 'primary_degraded' + if failover_usable: + return failover_uuid, failover_gw, 'failover_degraded' + else: + # Failover disabled - just use primary if it has IP + if primary_usable: + return primary_uuid, primary_gw, 'primary' + + # Both down or no failover configured + if primary_gw: + return primary_uuid, primary_gw, 'primary_down' + + return None, None, 'no_gateway' + + +def update_dns_record(api, entry, target_ip, state): + """Update DNS record at Hetzner""" + zone_id = entry['zoneId'] + record_name = entry['recordName'] + record_type = entry['recordType'] + ttl = entry['ttl'] + + try: + # Check current value first + records = api.list_records(zone_id) + current_value = None + for rec in records: + if rec.get('name') == record_name and rec.get('type') == record_type: + current_value = rec.get('value') + break + + if current_value == target_ip: + return True, 'unchanged' + + # Use the rrsets API to update/create record + success, message = api.update_record(zone_id, record_name, record_type, target_ip, ttl) + + if success: + log(f"Updated {record_name}.{entry['zoneName']} {record_type} -> {target_ip}") + return True, 'updated' + else: + log(f"DNS update failed for {record_name}.{entry['zoneName']}: {message}", syslog.LOG_ERR) + return False, message + + except Exception as e: + log(f"DNS update failed for {record_name}.{entry['zoneName']}: {str(e)}", syslog.LOG_ERR) + return False, str(e) + + +def process_entries(config, state): + """Process all entries and update DNS as needed""" + results = { + 'processed': 0, + 'updated': 0, + 'errors': 0, + 'failovers': 0, + 'failbacks': 0, + 'skipped_no_account': 0 + } + + # Cache API instances per account + api_cache = {} + + for entry in config['entries']: + if not entry['enabled'] or entry['status'] == 'paused': + continue + + entry_uuid = entry['uuid'] + account_uuid = entry.get('account', '') + + # Get account for this entry + account = config['accounts'].get(account_uuid) + if not account or not account['enabled'] or not account['apiToken']: + log(f"No valid account for entry {entry['recordName']}.{entry['zoneName']}", syslog.LOG_WARNING) + results['skipped_no_account'] += 1 + continue + + # Get or create API instance for this account + if account_uuid not in api_cache: + api_cache[account_uuid] = HCloudAPI( + account['apiToken'], + api_type=account['apiType'], + verbose=config['verbose'] + ) + api = api_cache[account_uuid] + + if entry_uuid not in state['entries']: + state['entries'][entry_uuid] = { + 'hetznerIp': None, + 'lastUpdate': 0, + 'status': 'pending', + 'activeGateway': None + } + + entry_state = state['entries'][entry_uuid] + old_active_gw = entry_state.get('activeGateway') + + # Determine active gateway + active_uuid, active_gw, reason = determine_active_gateway(entry, config, state) + + if not active_gw: + entry_state['status'] = 'error' + results['errors'] += 1 + continue + + # Get target IP from gateway + gw_state = state['gateways'].get(active_uuid, {}) + if entry['recordType'] == 'AAAA': + target_ip = gw_state.get('ipv6') + else: + target_ip = gw_state.get('ipv4') + + if not target_ip: + log(f"No IP available for entry {entry['recordName']}.{entry['zoneName']}", syslog.LOG_WARNING) + entry_state['status'] = 'error' + results['errors'] += 1 + continue + + # Track failover/failback events + if old_active_gw and old_active_gw != active_uuid: + if reason == 'failover': + results['failovers'] += 1 + state['failoverHistory'].append({ + 'timestamp': int(time.time()), + 'entry': entry_uuid, + 'from': old_active_gw, + 'to': active_uuid, + 'reason': 'primary_down' + }) + log(f"FAILOVER: {entry['recordName']}.{entry['zoneName']} switching to failover gateway") + elif reason == 'failback': + results['failbacks'] += 1 + state['failoverHistory'].append({ + 'timestamp': int(time.time()), + 'entry': entry_uuid, + 'from': old_active_gw, + 'to': active_uuid, + 'reason': 'failback' + }) + log(f"FAILBACK: {entry['recordName']}.{entry['zoneName']} returning to primary gateway") + + entry_state['activeGateway'] = active_uuid + + # Check if update needed + current_hetzner_ip = entry_state.get('hetznerIp') + if current_hetzner_ip == target_ip: + entry_state['status'] = 'active' if reason in ['primary', 'failback'] else 'failover' + results['processed'] += 1 + continue + + # Update DNS + success, update_reason = update_dns_record(api, entry, target_ip, state) + + if success: + entry_state['hetznerIp'] = target_ip + entry_state['lastUpdate'] = int(time.time()) + entry_state['status'] = 'active' if reason in ['primary', 'failback'] else 'failover' + if update_reason in ['updated', 'created']: + results['updated'] += 1 + else: + entry_state['status'] = 'error' + results['errors'] += 1 + + results['processed'] += 1 + + # Trim failover history to last 100 entries + if len(state['failoverHistory']) > 100: + state['failoverHistory'] = state['failoverHistory'][-100:] + + return results + + +def main(): + result = { + 'status': 'ok', + 'message': '', + 'details': {} + } + + config = load_config() + + if not config['enabled']: + result['message'] = 'Service is disabled' + print(json.dumps(result)) + return + + if not config['accounts']: + result['status'] = 'error' + result['message'] = 'No accounts/tokens configured' + print(json.dumps(result)) + return + + if not config['gateways']: + result['status'] = 'error' + result['message'] = 'No gateways configured' + print(json.dumps(result)) + return + + if not config['entries']: + result['message'] = 'No entries configured' + print(json.dumps(result)) + return + + state = load_runtime_state() + + # Check all gateways + state = check_all_gateways(config, state) + + # Process entries (API instances created per-account inside) + update_results = process_entries(config, state) + + state['lastUpdate'] = int(time.time()) + save_runtime_state(state) + + result['details'] = update_results + result['message'] = f"Processed {update_results['processed']} entries, {update_results['updated']} updated" + + if update_results.get('skipped_no_account', 0) > 0: + result['message'] += f", {update_results['skipped_no_account']} skipped (no account)" + if update_results['failovers'] > 0: + result['message'] += f", {update_results['failovers']} failovers" + if update_results['failbacks'] > 0: + result['message'] += f", {update_results['failbacks']} failbacks" + if update_results['errors'] > 0: + result['status'] = 'warning' + result['message'] += f", {update_results['errors']} errors" + + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py new file mode 100755 index 0000000000..577115846a --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py @@ -0,0 +1,52 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Validate Hetzner Cloud API token for HCloudDNS plugin +""" +import sys +import json +import os + +# Add script directory to path for local imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Token passed as argument or via stdin + token = None + + if len(sys.argv) > 1: + token = sys.argv[1].strip() + else: + # Read from stdin (for security - avoids token in process list) + try: + token = sys.stdin.read().strip() + except Exception: + pass + + if not token: + print(json.dumps({ + 'valid': False, + 'message': 'No API token provided', + 'zone_count': 0 + })) + sys.exit(1) + + api = HCloudAPI(token) + valid, message, zone_count = api.validate_token() + + result = { + 'valid': valid, + 'message': message, + 'zone_count': zone_count + } + + print(json.dumps(result)) + sys.exit(0 if valid else 1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py new file mode 100644 index 0000000000..949420f3a8 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py @@ -0,0 +1,275 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + 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. + + Hetzner Cloud DNS API provider for OPNsense DynDNS + Uses the new Cloud API (api.hetzner.cloud) instead of the deprecated dns.hetzner.com API +""" +import syslog +import requests +from . import BaseAccount + + +class HetznerCloud(BaseAccount): + _priority = 65535 + + _services = { + 'hetznercloud': 'api.hetzner.cloud' + } + + _api_base = "https://api.hetzner.cloud/v1" + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + # This is dynamically loaded by AccountFactory and added to the service dropdown + return {'hetznercloud': 'Hetzner Cloud DNS'} + + @staticmethod + def match(account): + return account.get('service') in HetznerCloud._services + + def _get_headers(self): + return { + 'User-Agent': 'OPNsense-dyndns', + 'Authorization': 'Bearer ' + self.settings.get('password', ''), + 'Content-Type': 'application/json' + } + + def _get_zone_name(self): + """Get zone name from settings - try 'zone' field first, then 'username' as fallback""" + zone_name = self.settings.get('zone', '').strip() + if not zone_name: + zone_name = self.settings.get('username', '').strip() + return zone_name + + def _get_zone_id(self, headers): + """Get zone ID by zone name""" + zone_name = self._get_zone_name() + + url = f"{self._api_base}/zones" + params = {'name': zone_name} + + response = requests.get(url, headers=headers, params=params) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching zones: 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 [zones]: %s" % (self.description, response.text) + ) + return None + + zones = payload.get('zones', []) + if not zones: + syslog.syslog( + syslog.LOG_ERR, + "Account %s zone '%s' not found" % (self.description, zone_name) + ) + return None + + zone_id = zones[0].get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found zone ID %s for %s" % (self.description, zone_id, zone_name) + ) + + return zone_id + + def _get_record(self, headers, zone_id, record_name, record_type): + """Get existing record by name and type""" + url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" + + response = requests.get(url, headers=headers) + + if response.status_code == 404: + return None + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return None + + try: + payload = response.json() + return payload.get('rrset') + except requests.exceptions.JSONDecodeError: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error parsing JSON response [record]: %s" % (self.description, response.text) + ) + return None + + def _update_record(self, headers, zone_id, record_name, record_type, address): + """Update existing record with new address + + NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update. + Workaround: DELETE old record, then POST new record. + """ + # DELETE old record first + delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" + delete_response = requests.delete(delete_url, headers=headers) + + if delete_response.status_code not in [200, 201, 204]: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error deleting record for update: HTTP %d - %s" % ( + self.description, delete_response.status_code, delete_response.text + ) + ) + return False + + # CREATE new record + return self._create_record(headers, zone_id, record_name, record_type, address) + + def _create_record(self, headers, zone_id, record_name, record_type, address): + """Create new record""" + url = f"{self._api_base}/zones/{zone_id}/rrsets" + + data = { + 'name': record_name, + 'type': record_type, + 'records': [{'value': str(address)}], + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in [200, 201]: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error creating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s created %s %s with %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _extract_record_name(self, hostname, zone_name): + """Extract record name from hostname, handling FQDN format""" + # Remove trailing dot if present + hostname = hostname.rstrip('.') + + # Extract record name from FQDN if needed + if hostname.endswith('.' + zone_name): + record_name = hostname[:-len(zone_name) - 1] + elif hostname == zone_name: + record_name = '@' + else: + record_name = hostname + + # Handle root domain + if not record_name or record_name == '@': + record_name = '@' + + return record_name + + def execute(self): + if super().execute(): + record_type = "AAAA" if ':' in str(self.current_address) else "A" + headers = self._get_headers() + + # Get zone ID + zone_id = self._get_zone_id(headers) + if not zone_id: + return False + + zone_name = self._get_zone_name() + + # Get hostnames - can be comma-separated list + hostnames_raw = self.settings.get('hostnames', '') + hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()] + + if not hostnames: + syslog.syslog( + syslog.LOG_ERR, + "Account %s no hostnames configured" % self.description + ) + return False + + all_success = True + for hostname in hostnames: + record_name = self._extract_record_name(hostname, zone_name) + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updating %s (record: %s, type: %s) to %s" % ( + self.description, hostname, record_name, record_type, self.current_address + ) + ) + + # Check if record exists + existing = self._get_record(headers, zone_id, record_name, record_type) + + if existing: + success = self._update_record( + headers, zone_id, record_name, record_type, self.current_address + ) + else: + success = self._create_record( + headers, zone_id, record_name, record_type, self.current_address + ) + + if success: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new IP %s for %s" % ( + self.description, self.current_address, hostname + ) + ) + else: + all_success = False + + if all_success: + self.update_state(address=self.current_address) + return True + + return False diff --git a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py new file mode 100644 index 0000000000..08f5591e2e --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py @@ -0,0 +1,310 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + 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. + + Hetzner DNS Console (Legacy) API provider for OPNsense DynDNS + Uses the old API at dns.hetzner.com - will be shut down May 2026 + For zones not yet migrated to Hetzner Cloud Console +""" +import syslog +import requests +from . import BaseAccount + + +class HetznerLegacy(BaseAccount): + _priority = 65535 + + _services = { + 'hetzner': 'dns.hetzner.com' + } + + _api_base = "https://dns.hetzner.com/api/v1" + + def __init__(self, account: dict): + super().__init__(account) + + @staticmethod + def known_services(): + # Match the existing 'hetzner' service key from DynDNS.xml + return {'hetzner': 'Hetzner DNS Console'} + + @staticmethod + def match(account): + return account.get('service') in HetznerLegacy._services + + def _get_headers(self): + return { + 'User-Agent': 'OPNsense-dyndns', + 'Auth-API-Token': self.settings.get('password', ''), + 'Content-Type': 'application/json' + } + + def _get_zone_name(self): + """Get zone name from settings - try 'zone' field first, then 'username' as fallback""" + zone_name = self.settings.get('zone', '').strip() + if not zone_name: + # Fallback to username for backwards compatibility + zone_name = self.settings.get('username', '').strip() + return zone_name + + def _get_zone_id(self, headers): + """Get zone ID by zone name""" + zone_name = self._get_zone_name() + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s looking for zone '%s' (zone field: '%s', username field: '%s')" % ( + self.description, + zone_name, + self.settings.get('zone', ''), + self.settings.get('username', '') + ) + ) + + url = f"{self._api_base}/zones" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching zones: 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 [zones]: %s" % (self.description, response.text) + ) + return None + + zones = payload.get('zones', []) + for zone in zones: + if zone.get('name') == zone_name: + zone_id = zone.get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found zone ID %s for %s" % (self.description, zone_id, zone_name) + ) + return zone_id + + syslog.syslog( + syslog.LOG_ERR, + "Account %s zone '%s' not found" % (self.description, zone_name) + ) + return None + + def _get_record_id(self, headers, zone_id, record_name, record_type): + """Get record ID by name and type""" + url = f"{self._api_base}/records" + params = {'zone_id': zone_id} + + response = requests.get(url, headers=headers, params=params) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error fetching records: 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 [records]: %s" % (self.description, response.text) + ) + return None + + records = payload.get('records', []) + for record in records: + if record.get('name') == record_name and record.get('type') == record_type: + record_id = record.get('id') + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s found record ID %s for %s %s" % ( + self.description, record_id, record_name, record_type + ) + ) + return record_id + + return None + + def _update_record(self, headers, zone_id, record_id, record_name, record_type, address): + """Update existing record with new address""" + url = f"{self._api_base}/records/{record_id}" + + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': record_name, + 'value': str(address), + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.put(url, headers=headers, json=data) + + if response.status_code != 200: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error updating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updated %s %s to %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _create_record(self, headers, zone_id, record_name, record_type, address): + """Create new record""" + url = f"{self._api_base}/records" + + data = { + 'zone_id': zone_id, + 'type': record_type, + 'name': record_name, + 'value': str(address), + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in [200, 201]: + syslog.syslog( + syslog.LOG_ERR, + "Account %s error creating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text + ) + ) + return False + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s created %s %s with %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True + + def _extract_record_name(self, hostname, zone_name): + """Extract record name from hostname, handling FQDN format""" + # Remove trailing dot if present + hostname = hostname.rstrip('.') + + # Extract record name from FQDN if needed + if hostname.endswith('.' + zone_name): + record_name = hostname[:-len(zone_name) - 1] + elif hostname == zone_name: + record_name = '@' + else: + record_name = hostname + + # Handle root domain + if not record_name or record_name == '@': + record_name = '@' + + return record_name + + def execute(self): + if super().execute(): + record_type = "AAAA" if ':' in str(self.current_address) else "A" + headers = self._get_headers() + + # Get zone ID + zone_id = self._get_zone_id(headers) + if not zone_id: + return False + + zone_name = self._get_zone_name() + + # Get hostnames - can be comma-separated list + hostnames_raw = self.settings.get('hostnames', '') + hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()] + + if not hostnames: + syslog.syslog( + syslog.LOG_ERR, + "Account %s no hostnames configured" % self.description + ) + return False + + all_success = True + for hostname in hostnames: + record_name = self._extract_record_name(hostname, zone_name) + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updating %s (record: %s, type: %s) to %s" % ( + self.description, hostname, record_name, record_type, self.current_address + ) + ) + + # Check if record exists + record_id = self._get_record_id(headers, zone_id, record_name, record_type) + + if record_id: + success = self._update_record( + headers, zone_id, record_id, record_name, record_type, self.current_address + ) + else: + success = self._create_record( + headers, zone_id, record_name, record_type, self.current_address + ) + + if success: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s set new IP %s for %s" % ( + self.description, self.current_address, hostname + ) + ) + else: + all_success = False + + if all_success: + self.update_state(address=self.current_address) + return True + + return False diff --git a/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf b/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf new file mode 100644 index 0000000000..ae019e50ad --- /dev/null +++ b/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf @@ -0,0 +1,119 @@ +[validate] +command:/usr/local/opnsense/scripts/HCloudDNS/validate_token.py +parameters:%s +type:script_output +message:Validating Hetzner Cloud API token + +[list.zones] +command:/usr/local/opnsense/scripts/HCloudDNS/list_zones.py +parameters:%s +type:script_output +message:Listing Hetzner Cloud DNS zones + +[list.records] +command:/usr/local/opnsense/scripts/HCloudDNS/list_records.py +parameters:%s %s +type:script_output +message:Listing DNS records for zone + +[list.allrecords] +command:/usr/local/opnsense/scripts/HCloudDNS/list_records.py +parameters:%s %s all +type:script_output +message:Listing all DNS records for zone + +[update] +command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py +parameters: +type:script_output +message:Updating Hetzner Cloud DNS records + +[status] +command:/usr/local/opnsense/scripts/HCloudDNS/status.py +parameters: +type:script_output +message:Getting HCloudDNS status + +[healthcheck] +command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py healthcheck +parameters:%s %s +type:script_output +message:Checking gateway health + +[getip] +command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py getip +parameters:%s %s +type:script_output +message:Getting gateway IP address + +[gatewaystatus] +command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py status +parameters: +type:script_output +message:Getting all gateway status + +[gethetznerip] +command:/usr/local/opnsense/scripts/HCloudDNS/get_hetzner_ip.py +parameters:%s %s %s +type:script_output +message:Getting IP from Hetzner DNS + +[refreshstatus] +command:/usr/local/opnsense/scripts/HCloudDNS/refresh_status.py +parameters: +type:script_output +message:Refreshing entry status from Hetzner + +[updatev2] +command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py +parameters: +type:script_output +message:Updating DNS records with failover support + +[simulate.down] +command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py down +parameters:%s +type:script_output +message:Simulating gateway failure + +[simulate.up] +command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py up +parameters:%s +type:script_output +message:Simulating gateway recovery + +[simulate.clear] +command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py clear +parameters: +type:script_output +message:Clearing failover simulation + +[simulate.status] +command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py status +parameters: +type:script_output +message:Getting simulation status + +[dns.create] +command:/usr/local/opnsense/scripts/HCloudDNS/create_record.py +parameters:%s %s %s %s %s %s +type:script_output +message:Creating DNS record at Hetzner + +[dns.update] +command:/usr/local/opnsense/scripts/HCloudDNS/update_record.py +parameters:%s %s %s %s %s %s +type:script_output +message:Updating DNS record at Hetzner + +[dns.delete] +command:/usr/local/opnsense/scripts/HCloudDNS/delete_record.py +parameters:%s %s %s %s +type:script_output +message:Deleting DNS record at Hetzner + +[testnotify] +command:/usr/local/opnsense/scripts/HCloudDNS/test_notify.py +parameters: +type:script_output +message:Testing notification channels From 9d25ebc22952e816ed7a2c914fc4577125c282c9 Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Tue, 16 Dec 2025 00:03:53 +0100 Subject: [PATCH 04/11] New plugin for comprehensive Hetzner DNS management in OPNsense. ## Features - Multi-account support (multiple Hetzner API tokens) - Multi-zone DNS management - Dynamic DNS with automatic failover between WAN interfaces - IPv4 and IPv6 (Dual-Stack) support - Direct DNS management (view/edit/delete records) - Change history with undo functionality - Notifications (Email, Webhook, Ntfy) - Configuration backup/restore Supports both Hetzner Cloud API and legacy DNS Console API. --- .../HCloudDNS/Api/EntriesController.php | 8 +- .../HCloudDNS/Api/HistoryController.php | 35 +++ .../HCloudDNS/Api/SettingsController.php | 103 ++++++- .../OPNsense/HCloudDNS/HistoryController.php | 46 ++++ .../OPNsense/HCloudDNS/IndexController.php | 8 + .../app/models/OPNsense/HCloudDNS/ACL/ACL.xml | 9 +- .../models/OPNsense/HCloudDNS/HCloudDNS.xml | 6 +- .../models/OPNsense/HCloudDNS/Menu/Menu.xml | 1 + .../OPNsense/HCloudDNS/Migrations/M2_0_1.php | 43 +++ .../mvc/app/views/OPNsense/HCloudDNS/dns.volt | 163 ++++++++++- .../app/views/OPNsense/HCloudDNS/history.volt | 256 ++++++++++++++++++ .../app/views/OPNsense/HCloudDNS/index.volt | 149 ---------- .../views/OPNsense/HCloudDNS/settings.volt | 107 +++++--- .../scripts/HCloudDNS/lib/hetzner_api.py | 20 +- .../scripts/HCloudDNS/update_records_v2.py | 125 +++++++++ 15 files changed, 865 insertions(+), 214 deletions(-) create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php index 387768ec90..e5bd5f261c 100644 --- a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php @@ -279,9 +279,9 @@ public function batchAddAction() $failoverGateway = $this->request->getPost('failoverGateway'); $ttl = $this->request->getPost('ttl', 'int', 300); - if (is_array($entries) && !empty($primaryGateway)) { - // Validate failover differs from primary - if (!empty($failoverGateway) && $primaryGateway === $failoverGateway) { + if (is_array($entries) && count($entries) > 0) { + // Validate failover differs from primary (only if both are set) + if (!empty($primaryGateway) && !empty($failoverGateway) && $primaryGateway === $failoverGateway) { return ['status' => 'error', 'message' => 'Failover gateway must be different from primary gateway']; } @@ -305,7 +305,7 @@ public function batchAddAction() $node->recordId = $entry['recordId'] ?? ''; $node->recordName = $entry['recordName']; $node->recordType = $entry['recordType']; - $node->primaryGateway = $primaryGateway; + $node->primaryGateway = $primaryGateway ?? ''; $node->failoverGateway = $failoverGateway ?? ''; $node->ttl = $entry['ttl'] ?? $ttl; $node->status = 'pending'; diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php index 0f9993e383..b22323ad21 100644 --- a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php @@ -261,6 +261,41 @@ public function cleanupAction() ]; } + /** + * Clear all history entries + * @return array + */ + public function clearAllAction() + { + if (!$this->request->isPost()) { + return ['status' => 'error', 'message' => 'POST required']; + } + + $mdl = $this->getModel(); + $deleted = 0; + $toDelete = []; + + foreach ($mdl->history->change->iterateItems() as $uuid => $change) { + $toDelete[] = $uuid; + } + + foreach ($toDelete as $uuid) { + $mdl->history->change->del($uuid); + $deleted++; + } + + if ($deleted > 0) { + $mdl->serializeToConfig(); + Config::getInstance()->save(); + } + + return [ + 'status' => 'ok', + 'deleted' => $deleted, + 'message' => "Cleared all $deleted history entries" + ]; + } + /** * Add a history entry (internal use) * @param string $action create|update|delete diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php index f91fdbbfbf..22f600eb31 100644 --- a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php @@ -39,18 +39,95 @@ class SettingsController extends ApiMutableModelControllerBase protected static $internalModelClass = '\OPNsense\HCloudDNS\HCloudDNS'; protected static $internalModelName = 'hclouddns'; + /** + * Ensure notifications section exists in config with defaults + */ + private function ensureNotificationsExist() + { + $config = \OPNsense\Core\Config::getInstance()->object(); + + // Make sure HCloudDNS exists + if (!isset($config->OPNsense)) { + return; + } + if (!isset($config->OPNsense->HCloudDNS)) { + return; + } + + $hcloud = $config->OPNsense->HCloudDNS; + + // Add notifications section if missing + if (!isset($hcloud->notifications)) { + $hcloud->addChild('notifications'); + $hcloud->notifications->addChild('enabled', '0'); + $hcloud->notifications->addChild('notifyOnUpdate', '1'); + $hcloud->notifications->addChild('notifyOnFailover', '1'); + $hcloud->notifications->addChild('notifyOnFailback', '1'); + $hcloud->notifications->addChild('notifyOnError', '1'); + $hcloud->notifications->addChild('emailEnabled', '0'); + $hcloud->notifications->addChild('emailTo', ''); + $hcloud->notifications->addChild('webhookEnabled', '0'); + $hcloud->notifications->addChild('webhookUrl', ''); + $hcloud->notifications->addChild('webhookMethod', 'POST'); + $hcloud->notifications->addChild('ntfyEnabled', '0'); + $hcloud->notifications->addChild('ntfyServer', 'https://ntfy.sh'); + $hcloud->notifications->addChild('ntfyTopic', ''); + $hcloud->notifications->addChild('ntfyPriority', 'default'); + \OPNsense\Core\Config::getInstance()->save(); + } + } + /** * Get full settings including all dropdown options * @return array */ public function getAction() { + $this->ensureNotificationsExist(); $result = []; $mdl = $this->getModel(); $result['hclouddns'] = $mdl->getNodes(); return $result; } + /** + * Parse flat bracket-notation keys into nested array + * e.g. "hclouddns[notifications][enabled]" => ['hclouddns']['notifications']['enabled'] + */ + private function parseBracketNotation($flatData) + { + $result = []; + foreach ($flatData as $key => $value) { + // Parse keys like "hclouddns[notifications][enabled]" + if (preg_match('/^([^\[]+)(.*)$/', $key, $matches)) { + $baseKey = $matches[1]; + $rest = $matches[2]; + + if (empty($rest)) { + $result[$baseKey] = $value; + } else { + // Parse [notifications][enabled] etc. + preg_match_all('/\[([^\]]*)\]/', $rest, $subMatches); + $keys = $subMatches[1]; + + $current = &$result; + $current[$baseKey] = $current[$baseKey] ?? []; + $current = &$current[$baseKey]; + + foreach ($keys as $i => $subKey) { + if ($i === count($keys) - 1) { + $current[$subKey] = $value; + } else { + $current[$subKey] = $current[$subKey] ?? []; + $current = &$current[$subKey]; + } + } + } + } + } + return $result; + } + /** * Set settings * @return array @@ -59,13 +136,35 @@ public function setAction() { $result = ['status' => 'error', 'message' => 'Invalid request']; if ($this->request->isPost()) { + $this->ensureNotificationsExist(); $mdl = $this->getModel(); - $mdl->setNodes($this->request->getPost('hclouddns')); + + // Get raw POST data and parse bracket notation + $allPost = $this->request->getPost(); + $parsed = $this->parseBracketNotation($allPost); + $postData = $parsed['hclouddns'] ?? []; + + // Handle notifications separately + if (isset($postData['notifications'])) { + $notif = $postData['notifications']; + foreach ($notif as $key => $value) { + if (isset($mdl->notifications->$key)) { + $mdl->notifications->$key = $value; + } + } + unset($postData['notifications']); + } + + // Handle remaining settings + if (!empty($postData)) { + $mdl->setNodes($postData); + } + $valMsgs = $mdl->performValidation(); if ($valMsgs->count() == 0) { $mdl->serializeToConfig(); \OPNsense\Core\Config::getInstance()->save(); - $result = ['status' => 'ok']; + $result['status'] = 'ok'; } else { $result = ['status' => 'error', 'validations' => []]; foreach ($valMsgs as $msg) { diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php new file mode 100644 index 0000000000..f8af3646ae --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php @@ -0,0 +1,46 @@ +view->pick('OPNsense/HCloudDNS/history'); + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php index 041c16d029..97f07f659e 100644 --- a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php @@ -100,4 +100,12 @@ public function dnsAction() { $this->view->pick('OPNsense/HCloudDNS/dns'); } + + /** + * DNS Change History page - track all DNS modifications + */ + public function historyAction() + { + $this->view->pick('OPNsense/HCloudDNS/history'); + } } diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml index f2aa89101f..fa30e2c5d8 100644 --- a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml @@ -1,9 +1,16 @@ - Services: Hetzner Cloud DDNS + Services: Hetzner Cloud DNS ui/hclouddns/* api/hclouddns/* + + Services: Hetzner Cloud DNS: History + + ui/hclouddns/history + api/hclouddns/history/* + + diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml index d1e8e5c319..b8b0f7f483 100644 --- a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml @@ -1,7 +1,7 @@ //OPNsense/HCloudDNS Hetzner Cloud Dynamic DNS with Multi-Zone and Failover - 2.0.0 + 2.0.1 @@ -151,8 +151,8 @@ name - Y - Primary gateway is required + N + Default Gateway (auto-detect) diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml index e1b57ebb0a..dcf2de0b33 100644 --- a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml @@ -3,6 +3,7 @@ + diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php new file mode 100644 index 0000000000..88d7cddce5 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php @@ -0,0 +1,43 @@ +object(); + $hcloud = $config->OPNsense->HCloudDNS; + + if ($hcloud && !isset($hcloud->notifications)) { + // Add notifications section with defaults + $hcloud->addChild('notifications'); + $hcloud->notifications->addChild('enabled', '0'); + $hcloud->notifications->addChild('notifyOnUpdate', '1'); + $hcloud->notifications->addChild('notifyOnFailover', '1'); + $hcloud->notifications->addChild('notifyOnFailback', '1'); + $hcloud->notifications->addChild('notifyOnError', '1'); + $hcloud->notifications->addChild('emailEnabled', '0'); + $hcloud->notifications->addChild('emailTo', ''); + $hcloud->notifications->addChild('webhookEnabled', '0'); + $hcloud->notifications->addChild('webhookUrl', ''); + $hcloud->notifications->addChild('webhookMethod', 'POST'); + $hcloud->notifications->addChild('ntfyEnabled', '0'); + $hcloud->notifications->addChild('ntfyServer', 'https://ntfy.sh'); + $hcloud->notifications->addChild('ntfyTopic', ''); + $hcloud->notifications->addChild('ntfyPriority', 'default'); + } + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt index 864e027620..8b034f70b5 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt @@ -584,10 +584,42 @@ $(document).ready(function() { $('#recordOldValue').val(value); $('#recordOldTtl').val(ttl); - $('#recordType').val(type).trigger('change'); $('#recordName').val(name); - $('#recordValue').val(value); $('#recordTtl').val(ttl); + $('#recordType').val(type).trigger('change'); + + // For TXT records, auto-detect and populate the appropriate wizard + if (type === 'TXT') { + populateTxtWizard(value, name); + } else if (type === 'MX') { + // Parse MX value: "priority target" format + var mxParts = value.match(/^(\d+)\s+(.+)$/); + if (mxParts) { + $('#mxPriority').val(mxParts[1]); + $('#recordValue').val(mxParts[2]); + } else { + $('#recordValue').val(value); + } + } else if (type === 'SRV') { + // Parse SRV value: "priority weight port target" format + var srvParts = value.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); + if (srvParts) { + $('#srvPriority').val(srvParts[1]); + $('#srvWeight').val(srvParts[2]); + $('#srvPort').val(srvParts[3]); + $('#srvTarget').val(srvParts[4]); + } + } else if (type === 'CAA') { + // Parse CAA value: "flags tag value" format + var caaParts = value.match(/^(\d+)\s+(\w+)\s+"?([^"]+)"?$/); + if (caaParts) { + $('#caaFlags').val(caaParts[1]); + $('#caaTag').val(caaParts[2]); + $('#caaValue').val(caaParts[3]); + } + } else { + $('#recordValue').val(value); + } $('#recordModal').modal('show'); }); @@ -649,6 +681,133 @@ $(document).ready(function() { $('#txtType').val('custom').trigger('change'); } + // TXT Record Auto-Detection Functions + function detectTxtType(value) { + if (!value) return 'custom'; + value = value.trim(); + // Strip leading/trailing quotes (TXT records often come quoted from API) + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (value.toLowerCase().startsWith('v=spf1')) return 'spf'; + if (value.toLowerCase().startsWith('v=dkim1')) return 'dkim'; + if (value.toLowerCase().startsWith('v=dmarc1')) return 'dmarc'; + if (value.toLowerCase().startsWith('google-site-verification=')) return 'google-site'; + if (value.toUpperCase().startsWith('MS=')) return 'ms-site'; + return 'custom'; + } + + function stripQuotes(val) { + if (!val) return val; + val = val.trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + return val.slice(1, -1); + } + return val; + } + + function parseSPF(value) { + // Reset SPF wizard + $('#spfIncludeMx').prop('checked', false); + $('#spfIncludeA').prop('checked', false); + $('#spfIncludes').val(''); + $('#spfIps').val(''); + $('#spfPolicy').val('~all'); + + value = stripQuotes(value); + var includes = []; + var ips = []; + var parts = value.split(/\s+/); + + $.each(parts, function(i, part) { + part = part.toLowerCase(); + if (part === 'mx') { + $('#spfIncludeMx').prop('checked', true); + } else if (part === 'a') { + $('#spfIncludeA').prop('checked', true); + } else if (part.startsWith('include:')) { + includes.push(part.substring(8)); + } else if (part.startsWith('ip4:')) { + ips.push(part.substring(4)); + } else if (part.startsWith('ip6:')) { + ips.push(part.substring(4)); + } else if (part === '-all' || part === '~all' || part === '?all' || part === '+all') { + $('#spfPolicy').val(part); + } + }); + + $('#spfIncludes').val(includes.join('\n')); + $('#spfIps').val(ips.join('\n')); + updateSpfPreview(); + } + + function parseDKIM(value, recordName) { + // Extract selector from record name (format: selector._domainkey) + var selector = ''; + if (recordName && recordName.includes('._domainkey')) { + selector = recordName.split('._domainkey')[0]; + } + $('#dkimSelector').val(selector); + + value = stripQuotes(value); + // Extract public key from value + var keyMatch = value.match(/p=([^;\s]+)/i); + if (keyMatch) { + $('#dkimKey').val(keyMatch[1]); + } else { + $('#dkimKey').val(''); + } + updateDkimPreview(); + } + + function parseDMARC(value) { + // Reset DMARC wizard + $('#dmarcPolicy').val('none'); + $('#dmarcRua').val(''); + $('#dmarcPct').val('100'); + + value = stripQuotes(value); + + // Parse policy + var policyMatch = value.match(/p=([^;\s]+)/i); + if (policyMatch) { + $('#dmarcPolicy').val(policyMatch[1].toLowerCase()); + } + + // Parse rua (report email) + var ruaMatch = value.match(/rua=mailto:([^;\s]+)/i); + if (ruaMatch) { + $('#dmarcRua').val(ruaMatch[1]); + } + + // Parse pct (percentage) + var pctMatch = value.match(/pct=(\d+)/i); + if (pctMatch) { + $('#dmarcPct').val(pctMatch[1]); + } + updateDmarcPreview(); + } + + function populateTxtWizard(value, recordName) { + var txtType = detectTxtType(value); + $('#txtType').val(txtType); + + // Trigger the change to show the appropriate wizard + $('#txtType').trigger('change'); + + // Now populate the wizard fields + if (txtType === 'spf') { + parseSPF(value); + } else if (txtType === 'dkim') { + parseDKIM(value, recordName); + } else if (txtType === 'dmarc') { + parseDMARC(value); + } else { + // For custom, google-site, ms-site - just put value in the standard input + $('#recordValue').val(value); + } + } + // Record type change - show/hide relevant fields $('#recordType').on('change', function() { var type = $(this).val(); diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt new file mode 100644 index 0000000000..1542363f58 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt @@ -0,0 +1,256 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Hetzner Cloud DNS - Change History +#} + + + +
+
+

{{ lang._('DNS Change History') }}

+
+
+

+ {{ lang._('Complete log of all DNS changes - both automatic updates (DynDNS, failover) and manual changes (DNS Management). You can revert changes to restore previous values.') }} +

+ +
+ + {{ lang._('History retention is configured in Settings (current:') }} ... {{ lang._('days).') }} +
+ + +
+
+
-
+
{{ lang._('Total') }}
+
+
+
-
+
{{ lang._('Creates') }}
+
+
+
-
+
{{ lang._('Updates') }}
+
+
+
-
+
{{ lang._('Deletes') }}
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('Time') }}{{ lang._('Action') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Old Value') }}{{ lang._('New Value') }}{{ lang._('Account') }}{{ lang._('Status') }}{{ lang._('Actions') }}
{{ lang._('Loading...') }}
+ +
+ + + +
+
+ + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt index 25dbb263ff..9f7815126d 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt @@ -51,7 +51,6 @@
  • {{ lang._('Overview') }}
  • {{ lang._('Gateways') }}
  • {{ lang._('DNS Entries') }}
  • -
  • {{ lang._('History') }}
  • {{ lang._('Scheduled') }}
  • @@ -252,36 +251,6 @@ - -
    -

    {{ lang._('DNS change history log. You can revert changes to restore previous DNS record values.') }}

    -
    - {{ lang._('History retention is configured in Settings. Only changes within the retention period are shown.') }} -
    - - - - - - - - - - - - - - - - - -
    {{ lang._('Time') }}{{ lang._('Action') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Old Value') }}{{ lang._('New Value') }}{{ lang._('Status') }}{{ lang._('Actions') }}
    {{ lang._('Loading...') }}
    - -
    - - -
    -
    @@ -1775,121 +1744,6 @@ $(document).ready(function() { }); }); - // ==================== HISTORY TAB ==================== - function loadHistory() { - var $tbody = $('#historyTable tbody'); - $tbody.html(' Loading...'); - - ajaxCall('/api/hclouddns/history/searchItem', {}, function(data) { - $tbody.empty(); - - if (!data || !data.rows || data.rows.length === 0) { - $tbody.html('No history entries found.'); - return; - } - - $.each(data.rows, function(i, row) { - var actionClass = {create: 'success', update: 'info', delete: 'danger'}[row.action] || 'default'; - var actionIcon = {create: 'plus', update: 'pencil', delete: 'trash'}[row.action] || 'circle'; - var revertedClass = row.reverted === '1' ? 'text-muted' : ''; - var revertedBadge = row.reverted === '1' ? 'Reverted' : 'Active'; - - var revertBtn = ''; - if (row.reverted !== '1') { - revertBtn = ''; - } else { - revertBtn = '-'; - } - - var recordFqdn = row.recordName + '.' + row.zoneName; - var oldVal = row.oldValue || '-'; - var newVal = row.newValue || '-'; - - // Add TTL info if available - if (row.oldTtl && row.oldValue) oldVal += ' (TTL: ' + row.oldTtl + ')'; - if (row.newTtl && row.newValue) newVal += ' (TTL: ' + row.newTtl + ')'; - - $tbody.append( - '' + - '' + row.timestampFormatted + '' + - ' ' + row.action + '' + - '' + recordFqdn + '' + - '' + row.recordType + '' + - '' + oldVal + '' + - '' + newVal + '' + - '' + revertedBadge + '' + - '' + revertBtn + '' + - '' - ); - }); - }); - } - - // Revert history entry - $(document).on('click', '.revert-btn', function() { - var $btn = $(this); - var uuid = $btn.data('uuid'); - - BootstrapDialog.confirm({ - title: 'Revert Change', - message: 'Are you sure you want to revert this DNS change? This will restore the previous value at Hetzner.', - type: BootstrapDialog.TYPE_WARNING, - btnOKLabel: 'Revert', - btnOKClass: 'btn-warning', - callback: function(result) { - if (result) { - $btn.prop('disabled', true).html(''); - ajaxCall('/api/hclouddns/history/revert/' + uuid, {_: ''}, function(data) { - if (data && data.status === 'ok') { - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_SUCCESS, - title: 'Change Reverted', - message: data.message || 'The DNS change has been reverted successfully.' - }); - loadHistory(); - } else { - $btn.prop('disabled', false).html(''); - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_DANGER, - title: 'Revert Failed', - message: data.message || 'Failed to revert the change.' - }); - } - }); - } - } - }); - }); - - // Refresh history button - $('#refreshHistoryBtn').click(function() { - loadHistory(); - }); - - // Cleanup old history entries - $('#cleanupHistoryBtn').click(function() { - var $btn = $(this).prop('disabled', true).html(' Cleaning...'); - - ajaxCall('/api/hclouddns/history/cleanup', {_: ''}, function(data) { - $btn.prop('disabled', false).html(' Cleanup Old Entries'); - - if (data && data.status === 'ok') { - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_SUCCESS, - title: 'Cleanup Complete', - message: data.message || (data.deleted + ' old entries removed.') - }); - loadHistory(); - } else { - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_DANGER, - title: 'Cleanup Failed', - message: data.message || 'Failed to cleanup history.' - }); - } - }); - }); - // Tab switch handlers $('a[data-toggle="tab"]').on('shown.bs.tab', function(e) { var target = $(e.target).attr('href'); @@ -1903,9 +1757,6 @@ $(document).ready(function() { loadDashboard(); loadSimulationStatus(); } - else if (target === '#history') { - loadHistory(); - } }); // Initial cache load diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt index ece13736f3..b0c4389097 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt @@ -6,22 +6,21 @@ #} @@ -226,14 +225,15 @@
    - - + + +
    @@ -264,7 +290,7 @@
    - +
    @@ -467,6 +493,48 @@ $(document).ready(function() { var currentAccountUuid = ''; var zonesData = {}; var isEditMode = false; // Track whether we're editing an existing record + var defaultDynDnsTtl = '60'; // Default TTL for DynDNS entries (loaded from settings) + var zoneGroups = []; // Available group names + var zoneAssignments = {}; // zone_id -> group_name mapping + var collapsedGroups = {}; // Track which groups are collapsed + + // Load collapsed state from localStorage + try { + var saved = localStorage.getItem('hclouddns_collapsedGroups'); + if (saved) collapsedGroups = JSON.parse(saved); + } catch(e) {} + + function saveCollapsedState() { + try { + localStorage.setItem('hclouddns_collapsedGroups', JSON.stringify(collapsedGroups)); + } catch(e) {} + } + + // Load zone groups from settings + function loadZoneGroups(callback) { + ajaxCall('/api/hclouddns/settings/getZoneGroups', {}, function(data) { + if (data && data.status === 'ok') { + zoneGroups = data.groups || []; + zoneAssignments = data.assignments || {}; + } + if (callback) callback(); + }); + } + + // Load default TTL from settings + ajaxCall('/api/hclouddns/settings/get', {}, function(data) { + if (data && data.hclouddns && data.hclouddns.general && data.hclouddns.general.defaultTtl) { + // Find the selected TTL option (API returns object with selected: 1/0 for each option) + var ttlOptions = data.hclouddns.general.defaultTtl; + for (var key in ttlOptions) { + if (ttlOptions[key].selected == 1) { + // Remove underscore prefix (e.g. "_60" -> "60") + defaultDynDnsTtl = key.charAt(0) === '_' ? key.substring(1) : key; + break; + } + } + } + }); // Load accounts and check if any exist ajaxCall('/api/hclouddns/accounts/searchItem', {}, function(data) { @@ -513,6 +581,7 @@ $(document).ready(function() { } else { $('#refreshZonesBtn').prop('disabled', true); $('#historyBtn').prop('disabled', true); + $('.zone-search-container').hide(); $('#zonesContainer').html('

    {{ lang._("Select an account to view DNS zones") }}

    '); } }); @@ -528,37 +597,127 @@ $(document).ready(function() { }); } + function buildGroupSelector(zoneId, currentGroup) { + var html = ''; + html += ''; + return html; + } + + function renderZonePanel(zone) { + var currentGroup = zoneAssignments[zone.id] || ''; + return '
    ' + + '
    ' + + '
    ' + zone.name + ' ()
    ' + + '
    ' + + '
    ' + buildGroupSelector(zone.id, currentGroup) + '
    ' + + ' ' + + '' + + '
    ' + + '
    ' + + '
    Loading...
    ' + + '
    '; + } + + function renderZonesGrouped(zones) { + // Sort zones alphabetically + zones.sort(function(a, b) { return a.name.localeCompare(b.name); }); + + // Group zones by their assigned group + var grouped = {}; + var ungrouped = []; + $.each(zones, function(i, zone) { + var group = zoneAssignments[zone.id]; + if (group) { + if (!grouped[group]) grouped[group] = []; + grouped[group].push(zone); + } else { + ungrouped.push(zone); + } + }); + + var html = ''; + + // Sort groups alphabetically + var sortedGroups = zoneGroups.slice().sort(function(a, b) { return a.localeCompare(b); }); + + // Render grouped zones first + $.each(sortedGroups, function(i, groupName) { + if (grouped[groupName] && grouped[groupName].length > 0) { + var isCollapsed = collapsedGroups[groupName] === true; + var folderIcon = isCollapsed ? 'fa-folder' : 'fa-folder-open'; + html += '
    ' + + '
    ' + + '
    ' + groupName + '
    ' + + '' + grouped[groupName].length + ' {{ lang._("zones") }}' + + '
    ' + + '
    '; + $.each(grouped[groupName], function(j, zone) { + html += renderZonePanel(zone); + }); + html += '
    '; + } + }); + + // Render ungrouped zones + if (ungrouped.length > 0) { + if (zoneGroups.length > 0) { + var isUngroupedCollapsed = collapsedGroups['__ungrouped__'] === true; + var ungroupedIcon = isUngroupedCollapsed ? 'fa-folder' : 'fa-folder-o'; + html += '
    ' + + '
    ' + + '
    {{ lang._("Ungrouped") }}
    ' + + '' + ungrouped.length + ' {{ lang._("zones") }}' + + '
    ' + + '
    '; + } + $.each(ungrouped, function(j, zone) { + html += renderZonePanel(zone); + }); + if (zoneGroups.length > 0) { + html += '
    '; + } + } + + return html; + } + function loadZones() { $('#zonesContainer').html('

    {{ lang._("Loading zones...") }}

    '); - // Load DynDNS entries first, then load zones - loadDynDnsEntries(function() { - ajaxCall('/api/hclouddns/hetzner/listZonesForAccount', {account_uuid: currentAccountUuid}, function(data) { - if (data && data.status === 'ok' && data.zones) { - zonesData = {}; - var html = ''; - $.each(data.zones, function(i, zone) { - zonesData[zone.id] = zone; - html += '
    ' + - '
    ' + - '
    ' + zone.name + ' ()
    ' + - '
    ' + - ' ' + - '' + - '
    ' + - '
    ' + - '
    Loading...
    ' + - '
    '; - }); - $('#zonesContainer').html(html || '
    {{ lang._("No zones found for this account.") }}
    '); + // Load zone groups, then DynDNS entries, then zones + loadZoneGroups(function() { + loadDynDnsEntries(function() { + ajaxCall('/api/hclouddns/hetzner/listZonesForAccount', {account_uuid: currentAccountUuid}, function(data) { + if (data && data.status === 'ok' && data.zones) { + zonesData = {}; + $.each(data.zones, function(i, zone) { + zonesData[zone.id] = zone; + }); + var html = renderZonesGrouped(data.zones); + $('#zonesContainer').html(html || '
    {{ lang._("No zones found for this account.") }}
    '); + + // Show zone search if there are zones + if (data.zones.length > 0) { + $('.zone-search-container').show(); + } else { + $('.zone-search-container').hide(); + } - // Load record counts for all zones - $.each(data.zones, function(i, zone) { - loadRecordCount(zone.id); + // Load record counts for all zones + $.each(data.zones, function(i, zone) { + loadRecordCount(zone.id); + }); + } else { + $('#zonesContainer').html('
    {{ lang._("Failed to load zones:") }} ' + (data.message || 'Unknown error') + '
    '); + } }); - } else { - $('#zonesContainer').html('
    {{ lang._("Failed to load zones:") }} ' + (data.message || 'Unknown error') + '
    '); - } }); }); } @@ -786,6 +945,10 @@ $(document).ready(function() { } if (typeRecords.length === 0) return; + + // Sort records alphabetically by name + typeRecords.sort(function(a, b) { return a.name.localeCompare(b.name); }); + totalShown += typeRecords.length; var typeClass = 'record-type-' + type; @@ -956,6 +1119,112 @@ $(document).ready(function() { renderRecordsGrouped(zoneId, records, filterType, searchText); }); + // Zone search/filter + $('#zoneSearchInput').on('keyup', function() { + var searchText = $(this).val().toLowerCase(); + $('.zone-panel').each(function() { + var zoneName = $(this).data('zone-name').toLowerCase(); + if (searchText === '' || zoneName.indexOf(searchText) !== -1) { + $(this).show(); + } else { + $(this).hide(); + } + }); + // Hide empty group sections + $('.zone-group-section').each(function() { + var visibleZones = $(this).find('.zone-panel:visible').length; + if (visibleZones === 0) { + $(this).hide(); + } else { + $(this).show(); + } + }); + }); + + // Zone group header collapse/expand + $(document).on('click', '.zone-group-header', function() { + var $section = $(this).closest('.zone-group-section'); + var $body = $section.find('.zone-group-body'); + var $icon = $(this).find('.zone-group-title i'); + var groupName = $section.data('group') || '__ungrouped__'; + if ($body.hasClass('collapsed')) { + $body.removeClass('collapsed'); + $(this).removeClass('collapsed'); + $icon.removeClass('fa-folder').addClass('fa-folder-open'); + collapsedGroups[groupName] = false; + } else { + $body.addClass('collapsed'); + $(this).addClass('collapsed'); + $icon.removeClass('fa-folder-open').addClass('fa-folder'); + collapsedGroups[groupName] = true; + } + saveCollapsedState(); + }); + + // Zone group selector change + $(document).on('change', '.zone-group-selector', function(e) { + e.stopPropagation(); + var $select = $(this); + var zoneId = $select.data('zone-id'); + var groupName = $select.val(); + + if (groupName === '__new__') { + // Show new group input + $select.hide(); + $select.siblings('.new-group-input').addClass('show').focus(); + return; + } + + // Save the group assignment + ajaxCall('/api/hclouddns/settings/setZoneGroup', {zone_id: zoneId, group_name: groupName}, function(data) { + if (data && data.status === 'ok') { + zoneGroups = data.groups || []; + zoneAssignments = data.assignments || {}; + // Reload zones to re-render with new grouping + loadZones(); + } + }, 'POST'); + }); + + // New group input handler + $(document).on('keypress', '.new-group-input', function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + var $input = $(this); + var zoneId = $input.data('zone-id'); + var groupName = $input.val().trim(); + + if (groupName) { + ajaxCall('/api/hclouddns/settings/setZoneGroup', {zone_id: zoneId, group_name: groupName}, function(data) { + if (data && data.status === 'ok') { + zoneGroups = data.groups || []; + zoneAssignments = data.assignments || {}; + loadZones(); + } + }, 'POST'); + } else { + // Cancel - show select again + $input.removeClass('show').val(''); + $input.siblings('.zone-group-selector').show().val(''); + } + } else if (e.which === 27) { // Escape key + var $input = $(this); + $input.removeClass('show').val(''); + $input.siblings('.zone-group-selector').show().val(''); + } + }); + + // Cancel new group input on blur + $(document).on('blur', '.new-group-input', function() { + var $input = $(this); + setTimeout(function() { + if ($input.hasClass('show') && !$input.val().trim()) { + $input.removeClass('show').val(''); + $input.siblings('.zone-group-selector').show().val(''); + } + }, 200); + }); + // Type group expand/collapse $(document).on('click', '.record-type-header', function(e) { if ($(e.target).closest('button').length) return; // Ignore button clicks @@ -988,84 +1257,148 @@ $(document).ready(function() { $('#recordModal').modal('show'); }); - // Edit record + // Edit record - fetch fresh data from API $(document).on('click', '.edit-record-btn', function(e) { e.stopPropagation(); var $row = $(this).closest('tr'); + var recordId = $row.data('record-id'); var zoneId = $row.data('zone-id'); var $zonePanel = $row.closest('.zone-panel'); var zoneName = $zonePanel.data('zone-name'); - isEditMode = true; // We're editing an existing record - $('#recordModalTitle').text('{{ lang._("Edit DNS Record") }} - ' + zoneName); - $('#recordZoneId').val(zoneId); - $('#recordZone').val(zoneId); - $('#recordZoneDisplay').val(zoneName); - $('#deleteRecordBtn').show(); - - // Get record data from row - var type = $row.find('.record-type-badge').text(); - var name = $row.find('td:eq(1)').text(); - var value = $row.find('.record-value').attr('title'); - var ttl = $row.find('td:eq(3)').text(); - - // Store old values for history - $('#recordOldValue').val(value); - $('#recordOldTtl').val(ttl); - - $('#recordName').val(name); - $('#recordTtl').val(ttl); - $('#recordType').val(type).trigger('change'); - - // For TXT records, auto-detect and populate the appropriate wizard - if (type === 'TXT') { - populateTxtWizard(value, name); - } else if (type === 'MX') { - // Parse MX value: "priority target" format - var mxParts = value.match(/^(\d+)\s+(.+)$/); - if (mxParts) { - $('#mxPriority').val(mxParts[1]); - $('#recordValue').val(mxParts[2]); - } else { - $('#recordValue').val(value); + // Show loading state + var $btn = $(this); + $btn.prop('disabled', true).html(''); + + // Fetch fresh record data from API + ajaxCall('/api/hclouddns/hetzner/listRecordsForAccount', { + account_uuid: currentAccountUuid, + zone_id: zoneId, + all_types: '1' + }, function(data) { + $btn.prop('disabled', false).html(''); + + if (!data || data.status !== 'ok' || !data.records) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: '{{ lang._("Failed to load record data.") }}'}); + return; } - } else if (type === 'SRV') { - // Parse SRV value: "priority weight port target" format - var srvParts = value.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); - if (srvParts) { - $('#srvPriority').val(srvParts[1]); - $('#srvWeight').val(srvParts[2]); - $('#srvPort').val(srvParts[3]); - $('#srvTarget').val(srvParts[4]); + + // Find record by ID + var record = null; + for (var i = 0; i < data.records.length; i++) { + if (data.records[i].id == recordId) { + record = data.records[i]; + break; + } } - } else if (type === 'CAA') { - // Parse CAA value: "flags tag value" format - var caaParts = value.match(/^(\d+)\s+(\w+)\s+"?([^"]+)"?$/); - if (caaParts) { - $('#caaFlags').val(caaParts[1]); - $('#caaTag').val(caaParts[2]); - $('#caaValue').val(caaParts[3]); + + if (!record) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_WARNING, message: '{{ lang._("Record not found. It may have been deleted.") }}'}); + loadRecords(zoneId); + return; } - } else { - $('#recordValue').val(value); - } - $('#recordModal').modal('show'); + var type = record.type; + var name = record.name; + var value = record.value; + var ttl = record.ttl || 300; + + isEditMode = true; + $('#recordModalTitle').text('{{ lang._("Edit DNS Record") }} - ' + zoneName); + $('#recordZoneId').val(zoneId); + $('#recordZone').val(zoneId); + $('#recordZoneDisplay').val(zoneName); + $('#deleteRecordBtn').show(); + + // Store old values for history + $('#recordOldValue').val(value); + $('#recordOldTtl').val(ttl); + + $('#recordName').val(name); + $('#recordTtl').val(ttl); + $('#recordType').val(type).trigger('change'); + + // For TXT records, auto-detect and populate the appropriate wizard + if (type === 'TXT') { + populateTxtWizard(value, name); + } else if (type === 'MX') { + var mxParts = value.match(/^(\d+)\s+(.+)$/); + if (mxParts) { + $('#mxPriority').val(mxParts[1]); + $('#recordValue').val(mxParts[2]); + } else { + $('#recordValue').val(value); + } + } else if (type === 'SRV') { + var srvParts = value.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); + if (srvParts) { + $('#srvPriority').val(srvParts[1]); + $('#srvWeight').val(srvParts[2]); + $('#srvPort').val(srvParts[3]); + $('#srvTarget').val(srvParts[4]); + } + } else if (type === 'CAA') { + var caaParts = value.match(/^(\d+)\s+(\w+)\s+"?([^"]+)"?$/); + if (caaParts) { + $('#caaFlags').val(caaParts[1]); + $('#caaTag').val(caaParts[2]); + $('#caaValue').val(caaParts[3]); + } + } else { + $('#recordValue').val(value); + } + + $('#recordModal').modal('show'); + }); }); - // Delete record button in table + // Delete record button in table - fetch fresh data from API $(document).on('click', '.delete-record-btn', function(e) { e.stopPropagation(); var $row = $(this).closest('tr'); + var recordId = $row.data('record-id'); var zoneId = $row.data('zone-id'); var $zonePanel = $row.closest('.zone-panel'); var zoneName = $zonePanel.data('zone-name'); - var name = $row.find('td:eq(1)').text(); - var type = $row.find('.record-type-badge').text(); - var value = $row.find('.record-value').attr('title'); - var ttl = $row.find('td:eq(3)').text(); - BootstrapDialog.confirm({ + // Show loading state + var $btn = $(this); + $btn.prop('disabled', true).html(''); + + // Fetch fresh record data from API + ajaxCall('/api/hclouddns/hetzner/listRecordsForAccount', { + account_uuid: currentAccountUuid, + zone_id: zoneId, + all_types: '1' + }, function(data) { + $btn.prop('disabled', false).html(''); + + if (!data || data.status !== 'ok' || !data.records) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: '{{ lang._("Failed to load record data.") }}'}); + return; + } + + // Find record by ID + var record = null; + for (var i = 0; i < data.records.length; i++) { + if (data.records[i].id == recordId) { + record = data.records[i]; + break; + } + } + + if (!record) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_WARNING, message: '{{ lang._("Record not found. It may have been deleted.") }}'}); + loadRecords(zoneId); + return; + } + + var name = record.name; + var type = record.type; + var value = record.value; + var ttl = record.ttl || 300; + + BootstrapDialog.confirm({ title: '{{ lang._("Confirm Delete") }}', message: '{{ lang._("Delete record") }} ' + name + ' (' + type + ')?', type: BootstrapDialog.TYPE_DANGER, @@ -1098,7 +1431,8 @@ $(document).ready(function() { }); } } - }); + }); + }); // close ajaxCall }); // Create DynDNS Entry from A/AAAA record @@ -1110,15 +1444,14 @@ $(document).ready(function() { var zoneName = $zonePanel.data('zone-name'); var recordName = $(this).data('record-name'); var recordType = $(this).data('record-type'); - var ttl = $(this).data('ttl') || 60; - // Populate modal fields + // Populate modal fields - use default DynDNS TTL from settings $('#dynDnsAccountUuid').val(currentAccountUuid); $('#dynDnsZoneId').val(zoneId); $('#dynDnsZoneName').val(zoneName); $('#dynDnsRecordName').val(recordName); $('#dynDnsRecordType').val(recordType); - $('#dynDnsTtl').val(ttl); + $('#dynDnsTtl').val(defaultDynDnsTtl); $('#dynDnsZoneDisplay').text(zoneName); $('#dynDnsRecordDisplay').text(recordName); $('#dynDnsTypeDisplay').text(recordType); diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt index b5e117a676..f5d73a6467 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt @@ -498,10 +498,14 @@ $(document).ready(function() { // Load default TTL setting if (cfg.general && cfg.general.defaultTtl) { - // Remove underscore prefix if present (e.g. "_60" -> "60") - defaultTtl = cfg.general.defaultTtl.selected || cfg.general.defaultTtl; - if (typeof defaultTtl === 'string' && defaultTtl.charAt(0) === '_') { - defaultTtl = defaultTtl.substring(1); + // Find the selected TTL option (API returns object with selected: 1/0 for each option) + var ttlOptions = cfg.general.defaultTtl; + for (var key in ttlOptions) { + if (ttlOptions[key].selected == 1) { + // Remove underscore prefix (e.g. "_60" -> "60") + defaultTtl = key.charAt(0) === '_' ? key.substring(1) : key; + break; + } } // Set the value in the inline TTL selector $('#defaultTtlSelect').val(defaultTtl).selectpicker('refresh'); @@ -1899,21 +1903,27 @@ $(document).ready(function() { ajaxCall('/api/hclouddns/accounts/searchItem', {}, function(data) { var $select = $('#importAccountSelect'); $select.find('option:not(:first)').remove(); + var enabledAccounts = []; if (data && data.rows) { $.each(data.rows, function(i, acc) { if (acc.enabled === '1') { + enabledAccounts.push(acc); $select.append(''); } }); } + // Auto-select if only one account + if (enabledAccounts.length === 1) { + $select.val(enabledAccounts[0].uuid); + importAccountUuid = enabledAccounts[0].uuid; + $('#loadZonesBtn').prop('disabled', false); + } $select.selectpicker('refresh'); }); } - // Load accounts when section is expanded - $('#importSection').on('show.bs.collapse', function() { - loadImportAccounts(); - }); + // Load import accounts on page load + loadImportAccounts(); // Enable/disable load button based on account selection $('#importAccountSelect').on('change', function() { diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py index a49f4a9c2e..b32f70e8a1 100644 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py @@ -25,6 +25,7 @@ Shared Hetzner DNS API library - used by both ddclient providers and HCloudDNS """ +import hashlib import syslog import requests @@ -231,10 +232,14 @@ def list_records(self, zone_id, record_types=None): # Create one entry per record value (important for MX, NS, etc.) for record in records: + value = record.get('value', '') + # Generate synthetic ID from name+type+value + record_id = hashlib.md5(f"{rrset_name}:{rrset_type}:{value}".encode()).hexdigest()[:12] result.append({ + 'id': record_id, 'name': rrset_name, 'type': rrset_type, - 'value': record.get('value', ''), + 'value': value, 'ttl': rrset_ttl }) From 47ece7e71d7307bd26a88b93b08bd561790966ef Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Wed, 17 Dec 2025 06:46:45 +0100 Subject: [PATCH 10/11] Add pagination to list_zones() for accounts with >25 zones --- .../scripts/HCloudDNS/lib/hetzner_api.py | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py index b32f70e8a1..93ec4342a6 100644 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py @@ -120,19 +120,33 @@ def list_zones(self): """ List all DNS zones accessible with this token. Returns list of zone dicts with id, name, records_count + Uses pagination to fetch all zones (default limit is 25). """ try: - response = self._request('GET', '/zones') + all_zones = [] + page = 1 + per_page = 100 - if response.status_code != 200: - self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") - return [] + while True: + response = self._request('GET', '/zones', params={'page': page, 'per_page': per_page}) - data = response.json() - zones = data.get('zones', []) + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") + return [] + + data = response.json() + zones = data.get('zones', []) + all_zones.extend(zones) + + # Check if there are more pages + meta = data.get('meta', {}).get('pagination', {}) + total_entries = meta.get('total_entries', len(zones)) + if len(all_zones) >= total_entries or len(zones) < per_page: + break + page += 1 result = [] - for zone in zones: + for zone in all_zones: result.append({ 'id': zone.get('id', ''), 'name': zone.get('name', ''), @@ -457,19 +471,34 @@ def validate_token(self): return False, f"Unexpected error: {str(e)}", 0 def list_zones(self): - """List all DNS zones accessible with this token.""" + """List all DNS zones accessible with this token. + Uses pagination to fetch all zones (default limit is 25). + """ try: - response = self._request('GET', '/zones') + all_zones = [] + page = 1 + per_page = 100 - if response.status_code != 200: - self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") - return [] + while True: + response = self._request('GET', '/zones', params={'page': page, 'per_page': per_page}) - data = response.json() - zones = data.get('zones', []) + if response.status_code != 200: + self._log(syslog.LOG_ERR, f"Failed to list zones: HTTP {response.status_code}") + return [] + + data = response.json() + zones = data.get('zones', []) + all_zones.extend(zones) + + # Check if there are more pages + meta = data.get('meta', {}).get('pagination', {}) + total_entries = meta.get('total_entries', len(zones)) + if len(all_zones) >= total_entries or len(zones) < per_page: + break + page += 1 result = [] - for zone in zones: + for zone in all_zones: result.append({ 'id': zone.get('id', ''), 'name': zone.get('name', ''), @@ -489,7 +518,7 @@ def list_zones(self): def get_zone_id(self, zone_name): """Get zone ID by zone name""" try: - response = self._request('GET', '/zones') + response = self._request('GET', '/zones', params={'name': zone_name}) if response.status_code != 200: self._log(syslog.LOG_ERR, f"Failed to get zones: HTTP {response.status_code}") From e36cc15e1271a6e0936d6c8e46983b804c24b007 Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Fri, 19 Dec 2025 12:58:53 +0100 Subject: [PATCH 11/11] Hetzner Cloud API fixes and performance improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Changes (based on Hetzner feedback): - Migrate to proper rrset-actions endpoints for record updates - Use POST /zones/{zone_id}/rrsets/{name}/{type}/actions/set_records - Add async action polling - wait for success/error status before continuing Performance: - Switch from sequential to parallel DNS update processing (ThreadPoolExecutor) - Deduplicate entries by (zone_id, record_name, record_type) before processing - Thread-safe state access with locks Notifications: - Single batch notification per update run instead of per-entry - Clean title format with gateway names: "HCloudDNS: Failover WAN_Primary → WAN_Backup" "HCloudDNS: Failback WAN_Backup → WAN_Primary" "HCloudDNS: DynIP Update on WAN_Primary" - Records listed once in body (no duplication) - Grouped by domain with proper spacing --- .../scripts/HCloudDNS/lib/hetzner_api.py | 124 ++++- .../scripts/HCloudDNS/update_records_v2.py | 470 ++++++++++++++---- .../ddclient/lib/account/hetzner_cloud.py | 108 +++- 3 files changed, 582 insertions(+), 120 deletions(-) diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py index 93ec4342a6..62b613685d 100644 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py @@ -27,9 +27,12 @@ """ import hashlib import syslog +import time import requests TIMEOUT = 15 +ACTION_POLL_INTERVAL = 0.5 # seconds between action status polls +ACTION_MAX_WAIT = 30 # maximum seconds to wait for action class HetznerAPIError(Exception): @@ -88,6 +91,42 @@ def _request(self, method, endpoint, params=None, json_data=None): except requests.exceptions.RequestException as e: raise HetznerAPIError(f"API request failed: {str(e)}") + def _wait_for_action(self, action_id): + """ + Wait for an async action to complete. + Returns tuple (success: bool, message: str) + """ + start_time = time.time() + + while time.time() - start_time < ACTION_MAX_WAIT: + try: + response = self._request('GET', f'/actions/{action_id}') + + if response.status_code != 200: + return False, f"Failed to get action status: HTTP {response.status_code}" + + data = response.json() + action = data.get('action', {}) + status = action.get('status', '') + + if status == 'success': + return True, "Action completed successfully" + elif status == 'error': + error = action.get('error', {}) + error_msg = error.get('message', 'Unknown error') + return False, f"Action failed: {error_msg}" + elif status in ['running', 'pending']: + time.sleep(ACTION_POLL_INTERVAL) + continue + else: + # Unknown status, assume success for backward compatibility + return True, f"Action status: {status}" + + except HetznerAPIError as e: + return False, f"Error waiting for action: {str(e)}" + + return False, f"Action timed out after {ACTION_MAX_WAIT} seconds" + def validate_token(self): """ Validate token by attempting to list zones. @@ -225,7 +264,6 @@ def list_records(self, zone_id, record_types=None): # Check if there are more pages meta = data.get('meta', {}).get('pagination', {}) - total_entries = meta.get('total_entries', len(rrsets)) last_page = meta.get('last_page', 1) if self.verbose: @@ -297,11 +335,11 @@ def get_record(self, zone_id, name, record_type): def update_record(self, zone_id, name, record_type, value, ttl=300): """ - Update existing record with new value. + Update existing record with new value using rrset-actions endpoint. Returns tuple (success: bool, message: str) - NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update. - Workaround: DELETE old record, then POST new record. + Uses the set_records action endpoint which properly updates RRsets. + Actions are async and will be waited upon for completion. """ try: # Check if record exists @@ -315,19 +353,43 @@ def update_record(self, zone_id, name, record_type, value, ttl=300): if existing.get('value') == str(value) and existing.get('ttl') == ttl: return True, "unchanged" - # Workaround for Cloud API PUT bug: DELETE then POST - # DELETE the old record - delete_response = self._request( - 'DELETE', f'/zones/{zone_id}/rrsets/{name}/{record_type}' - ) + # Use set_records action to update the RRset + url = f'/zones/{zone_id}/rrsets/{name}/{record_type}/actions/set_records' + data = { + 'records': [{'value': str(value)}], + 'ttl': ttl + } - if delete_response.status_code not in [200, 201, 204]: - error_msg = f"DELETE failed: HTTP {delete_response.status_code}" - self._log(syslog.LOG_ERR, f"Failed to update {name} {record_type}: {error_msg}") - return False, error_msg + response = self._request('POST', url, json_data=data) - # POST new record - return self.create_record(zone_id, name, record_type, value, ttl) + if response.status_code in [200, 201]: + # Check if there's an action to wait for + response_data = response.json() + action = response_data.get('action', {}) + action_id = action.get('id') + + if action_id and action.get('status') in ['running', 'pending']: + # Wait for action to complete + success, msg = self._wait_for_action(action_id) + if not success: + self._log(syslog.LOG_ERR, f"Action failed for {name} {record_type}: {msg}") + return False, msg + + if self.verbose: + self._log(syslog.LOG_INFO, f"Updated {name} {record_type} -> {value}") + return True, f"Updated {name} {record_type}" + + # Handle error response + error_msg = f"HTTP {response.status_code}" + try: + error_data = response.json() + if 'error' in error_data: + error_msg = error_data['error'].get('message', error_msg) + except Exception: + pass + + self._log(syslog.LOG_ERR, f"Failed to update {name} {record_type}: {error_msg}") + return False, error_msg except HetznerAPIError as e: self._log(syslog.LOG_ERR, f"Failed to update record: {str(e)}") @@ -337,6 +399,8 @@ def create_record(self, zone_id, name, record_type, value, ttl=300): """ Create new DNS record. Returns tuple (success: bool, message: str) + + Note: CREATE operations may return actions that should be awaited. """ try: url = f'/zones/{zone_id}/rrsets' @@ -350,6 +414,20 @@ def create_record(self, zone_id, name, record_type, value, ttl=300): response = self._request('POST', url, json_data=data) if response.status_code in [200, 201]: + # Check if there's an action to wait for + try: + response_data = response.json() + action = response_data.get('action', {}) + action_id = action.get('id') + + if action_id and action.get('status') in ['running', 'pending']: + success, msg = self._wait_for_action(action_id) + if not success: + self._log(syslog.LOG_ERR, f"Create action failed for {name} {record_type}: {msg}") + return False, msg + except Exception: + pass # No action in response, that's fine + if self.verbose: self._log(syslog.LOG_INFO, f"Created {name} {record_type} -> {value}") return True, f"Created {name} {record_type}" @@ -373,11 +451,27 @@ def delete_record(self, zone_id, name, record_type): """ Delete a DNS record. Returns tuple (success: bool, message: str) + + Note: DELETE operations may also return actions that should be awaited. """ try: response = self._request('DELETE', f'/zones/{zone_id}/rrsets/{name}/{record_type}') if response.status_code in [200, 201, 204]: + # Check if there's an action to wait for + try: + response_data = response.json() + action = response_data.get('action', {}) + action_id = action.get('id') + + if action_id and action.get('status') in ['running', 'pending']: + success, msg = self._wait_for_action(action_id) + if not success: + self._log(syslog.LOG_ERR, f"Delete action failed for {name} {record_type}: {msg}") + return False, msg + except Exception: + pass # No action in response, that's fine + if self.verbose: self._log(syslog.LOG_INFO, f"Deleted {name} {record_type}") return True, f"Deleted {name} {record_type}" diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py index 4a805473ef..979ff58af3 100755 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py @@ -242,7 +242,7 @@ def send_webhook(settings, event_type, data): def send_notification(config, event_type, entry, old_ip=None, new_ip=None, error_msg=None): - """Send notifications for DNS events based on configuration""" + """Send notifications for DNS events based on configuration (single event)""" notifications = config.get('notifications', {}) if not notifications.get('enabled'): @@ -301,6 +301,138 @@ def send_notification(config, event_type, entry, old_ip=None, new_ip=None, error }) +def _get_base_domain(record): + """Extract base domain from FQDN (e.g., 'www.example.com' -> 'example.com')""" + parts = record.split('.') + if len(parts) >= 2: + return '.'.join(parts[-2:]) + return record + + +def _group_by_domain(items, key='record'): + """Group items by their base domain""" + from collections import OrderedDict + grouped = OrderedDict() + for item in items: + domain = _get_base_domain(item[key]) + if domain not in grouped: + grouped[domain] = [] + grouped[domain].append(item) + return grouped + + +def send_batch_notification(config, batch_results): + """ + Send a single batch notification summarizing all DNS changes. + + Title format: + - Failover: "HCloudDNS: Failover WAN_Primary → WAN_Backup" + - Failback: "HCloudDNS: Failback WAN_Backup → WAN_Primary" + - DynIP: "HCloudDNS: DynIP Update on WAN_Primary" + - Error: "HCloudDNS: Error" + + Body: List of affected records (no duplication) + """ + notifications = config.get('notifications', {}) + + if not notifications.get('enabled'): + return + + updates = batch_results.get('updates', []) + failovers = batch_results.get('failovers', []) + failbacks = batch_results.get('failbacks', []) + errors = batch_results.get('errors', []) + + # Determine notification type - only ONE type per notification (priority order) + # Failover/Failback already contains the updates, so don't show both + title = None + tags = 'hclouddns' + records_to_show = [] + + if failovers and notifications.get('notifyOnFailover'): + # Failover notification + first_fo = failovers[0] + from_gw = first_fo.get('from_gateway', '?') + to_gw = first_fo.get('to_gateway', '?') + title = f"HCloudDNS: Failover {from_gw} → {to_gw}" + tags = 'warning,hclouddns' + records_to_show = failovers + + elif failbacks and notifications.get('notifyOnFailback'): + # Failback notification + first_fb = failbacks[0] + from_gw = first_fb.get('from_gateway', '?') + to_gw = first_fb.get('to_gateway', '?') + title = f"HCloudDNS: Failback {from_gw} → {to_gw}" + tags = 'white_check_mark,hclouddns' + records_to_show = failbacks + + elif updates and notifications.get('notifyOnUpdate'): + # Regular DynIP update - get gateway name from first update + gateway_name = updates[0].get('gateway', 'Gateway') + title = f"HCloudDNS: DynIP Update on {gateway_name}" + tags = 'arrows_counterclockwise,hclouddns' + records_to_show = updates + + elif errors and notifications.get('notifyOnError'): + # Error notification + title = f"HCloudDNS: {len(errors)} Error(s)" + tags = 'x,hclouddns' + + if not title: + return # Nothing to notify + + # Build message body + lines = [] + + if records_to_show: + grouped = _group_by_domain(records_to_show[:15]) + first_domain = True + + for domain, domain_records in grouped.items(): + if not first_domain: + lines.append("") # Empty line between domains + first_domain = False + + for r in domain_records: + lines.append(f"{r['record']}") + lines.append(f" → {r['new_ip']}") + + if len(records_to_show) > 15: + lines.append("") + lines.append(f"... +{len(records_to_show) - 15} more") + + if errors and notifications.get('notifyOnError'): + if lines: + lines.append("") + lines.append("---") + lines.append("") + + grouped = _group_by_domain(errors[:10]) + first_domain = True + + for domain, domain_errors in grouped.items(): + if not first_domain: + lines.append("") + first_domain = False + + for e in domain_errors: + lines.append(f"{e['record']}") + lines.append(f" ✗ {e['error']}") + + message = "\n".join(lines) + + # Send batch notification + send_ntfy(notifications, title, message, tags) + send_webhook(notifications, 'batch_update', { + 'updates': len(updates), + 'failovers': len(failovers), + 'failbacks': len(failbacks), + 'errors': len(errors), + 'details': batch_results + }) + + def load_config(): """Load configuration from OPNsense config.xml""" config = { @@ -604,27 +736,197 @@ def update_dns_record(api, entry, target_ip, state): return False, str(e) +def _process_single_entry(entry, account, api, config, state, state_lock): + """ + Process a single DNS entry. Thread-safe worker function. + Returns a dict with the result of processing this entry. + """ + entry_uuid = entry['uuid'] + record_fqdn = f"{entry['recordName']}.{entry['zoneName']}" + + result = { + 'processed': True, + 'updated': False, + 'error': None, + 'failover_event': None, + 'update_event': None, + 'failover_history': None + } + + # Thread-safe state access + with state_lock: + if entry_uuid not in state['entries']: + state['entries'][entry_uuid] = { + 'hetznerIp': None, + 'lastUpdate': 0, + 'status': 'pending', + 'activeGateway': None + } + entry_state = state['entries'][entry_uuid] + old_active_gw = entry_state.get('activeGateway') + current_hetzner_ip = entry_state.get('hetznerIp') + + # Determine active gateway (reads from state, thread-safe) + active_uuid, active_gw, reason = determine_active_gateway(entry, config, state) + + if not active_gw: + with state_lock: + state['entries'][entry_uuid]['status'] = 'error' + result['error'] = { + 'record': record_fqdn, + 'type': entry['recordType'], + 'error': 'No gateway available' + } + return result + + # Get target IP from gateway + with state_lock: + gw_state = state['gateways'].get(active_uuid, {}) + if entry['recordType'] == 'AAAA': + target_ip = gw_state.get('ipv6') + else: + target_ip = gw_state.get('ipv4') + + if not target_ip: + log(f"No IP available for entry {record_fqdn}", syslog.LOG_WARNING) + with state_lock: + state['entries'][entry_uuid]['status'] = 'error' + result['error'] = { + 'record': record_fqdn, + 'type': entry['recordType'], + 'error': 'No IP available from gateway' + } + return result + + # Track failover/failback events + if old_active_gw and old_active_gw != active_uuid: + # Get gateway names for notification + old_gw_config = config['gateways'].get(old_active_gw, {}) + old_gw_name = old_gw_config.get('name', old_active_gw[:8]) + new_gw_name = active_gw.get('name', active_uuid[:8]) + + if reason == 'failover': + log(f"FAILOVER: {record_fqdn} switching from {old_gw_name} to {new_gw_name}") + result['failover_event'] = 'failover' + result['failover_history'] = { + 'timestamp': int(time.time()), + 'entry': entry_uuid, + 'from': old_active_gw, + 'to': active_uuid, + 'reason': 'primary_down' + } + result['from_gateway'] = old_gw_name + result['to_gateway'] = new_gw_name + elif reason == 'failback': + log(f"FAILBACK: {record_fqdn} returning from {old_gw_name} to {new_gw_name}") + result['failover_event'] = 'failback' + result['failover_history'] = { + 'timestamp': int(time.time()), + 'entry': entry_uuid, + 'from': old_active_gw, + 'to': active_uuid, + 'reason': 'failback' + } + result['from_gateway'] = old_gw_name + result['to_gateway'] = new_gw_name + + # Update DNS (this is the slow network call - runs in parallel) + success, update_reason = update_dns_record(api, entry, target_ip, state) + + # Update state with results (thread-safe) + with state_lock: + entry_state = state['entries'][entry_uuid] + entry_state['activeGateway'] = active_uuid + + if success: + entry_state['hetznerIp'] = target_ip + entry_state['lastUpdate'] = int(time.time()) + entry_state['status'] = 'active' if reason in ['primary', 'failback'] else 'failover' + + if update_reason in ['updated', 'created']: + result['updated'] = True + # Add history entry for tracking IP changes + action = 'create' if update_reason == 'created' else 'update' + add_history_entry(entry, account, current_hetzner_ip, target_ip, action) + result['update_event'] = { + 'record': record_fqdn, + 'type': entry['recordType'], + 'old_ip': current_hetzner_ip, + 'new_ip': target_ip, + 'gateway': active_gw.get('name', 'Gateway') + } + + # Add failover/failback notification data + if result['failover_event']: + result['failover_notification'] = { + 'record': record_fqdn, + 'type': entry['recordType'], + 'old_ip': current_hetzner_ip, + 'new_ip': target_ip, + 'from_gateway': result.get('from_gateway'), + 'to_gateway': result.get('to_gateway') + } + else: + entry_state['status'] = 'error' + result['error'] = { + 'record': record_fqdn, + 'type': entry['recordType'], + 'error': update_reason + } + + return result + + def process_entries(config, state): - """Process all entries and update DNS as needed""" + """ + Process all entries and update DNS as needed. + Uses parallel processing with deduplication: + 1. First deduplicate entries (same zone/record/type processed only once) + 2. Process all unique entries in parallel using ThreadPoolExecutor + 3. Collect all changes for batch notification at the end + """ + import threading + from concurrent.futures import ThreadPoolExecutor, as_completed + results = { 'processed': 0, 'updated': 0, 'errors': 0, 'failovers': 0, 'failbacks': 0, - 'skipped_no_account': 0 + 'skipped_no_account': 0, + 'skipped_duplicate': 0 + } + + # Batch notification data - collect all events for single notification + batch_events = { + 'updates': [], + 'failovers': [], + 'failbacks': [], + 'errors': [] } - # Cache API instances per account + # Lock for thread-safe state access + state_lock = threading.Lock() + + # Phase 1: Deduplicate and prepare entries + # Key: (zone_id, record_name, record_type) to catch config duplicates + unique_entries = {} api_cache = {} for entry in config['entries']: if not entry['enabled'] or entry['status'] == 'paused': continue - entry_uuid = entry['uuid'] account_uuid = entry.get('account', '') + # Create unique key for this record to prevent duplicates + record_key = (entry['zoneId'], entry['recordName'], entry['recordType']) + if record_key in unique_entries: + log(f"Skipping duplicate entry {entry['recordName']}.{entry['zoneName']} {entry['recordType']}") + results['skipped_duplicate'] += 1 + continue + # Get account for this entry account = config['accounts'].get(account_uuid) if not account or not account['enabled'] or not account['apiToken']: @@ -639,99 +941,81 @@ def process_entries(config, state): api_type=account['apiType'], verbose=config['verbose'] ) - api = api_cache[account_uuid] - - if entry_uuid not in state['entries']: - state['entries'][entry_uuid] = { - 'hetznerIp': None, - 'lastUpdate': 0, - 'status': 'pending', - 'activeGateway': None - } - entry_state = state['entries'][entry_uuid] - old_active_gw = entry_state.get('activeGateway') - - # Determine active gateway - active_uuid, active_gw, reason = determine_active_gateway(entry, config, state) - - if not active_gw: - entry_state['status'] = 'error' - results['errors'] += 1 - continue - - # Get target IP from gateway - gw_state = state['gateways'].get(active_uuid, {}) - if entry['recordType'] == 'AAAA': - target_ip = gw_state.get('ipv6') - else: - target_ip = gw_state.get('ipv4') + # Store entry with its dependencies for parallel processing + unique_entries[record_key] = { + 'entry': entry, + 'account': account, + 'api': api_cache[account_uuid] + } - if not target_ip: - log(f"No IP available for entry {entry['recordName']}.{entry['zoneName']}", syslog.LOG_WARNING) - entry_state['status'] = 'error' - results['errors'] += 1 - continue + # Phase 2: Process all unique entries in parallel + max_workers = min(10, len(unique_entries)) if unique_entries else 1 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit all tasks + future_to_entry = { + executor.submit( + _process_single_entry, + data['entry'], + data['account'], + data['api'], + config, + state, + state_lock + ): record_key + for record_key, data in unique_entries.items() + } - # Track failover/failback events and send notifications - failover_event = None - if old_active_gw and old_active_gw != active_uuid: - if reason == 'failover': - results['failovers'] += 1 - state['failoverHistory'].append({ - 'timestamp': int(time.time()), - 'entry': entry_uuid, - 'from': old_active_gw, - 'to': active_uuid, - 'reason': 'primary_down' - }) - log(f"FAILOVER: {entry['recordName']}.{entry['zoneName']} switching to failover gateway") - failover_event = 'failover' - elif reason == 'failback': - results['failbacks'] += 1 - state['failoverHistory'].append({ - 'timestamp': int(time.time()), - 'entry': entry_uuid, - 'from': old_active_gw, - 'to': active_uuid, - 'reason': 'failback' + # Collect results as they complete + for future in as_completed(future_to_entry): + record_key = future_to_entry[future] + try: + result = future.result() + + results['processed'] += 1 + + if result.get('updated'): + results['updated'] += 1 + if result.get('update_event'): + batch_events['updates'].append(result['update_event']) + + if result.get('error'): + results['errors'] += 1 + batch_events['errors'].append(result['error']) + + if result.get('failover_event') == 'failover': + results['failovers'] += 1 + if result.get('failover_notification'): + batch_events['failovers'].append(result['failover_notification']) + if result.get('failover_history'): + with state_lock: + state['failoverHistory'].append(result['failover_history']) + + elif result.get('failover_event') == 'failback': + results['failbacks'] += 1 + if result.get('failover_notification'): + batch_events['failbacks'].append(result['failover_notification']) + if result.get('failover_history'): + with state_lock: + state['failoverHistory'].append(result['failover_history']) + + except Exception as e: + log(f"Error processing entry {record_key}: {str(e)}", syslog.LOG_ERR) + results['errors'] += 1 + batch_events['errors'].append({ + 'record': f"{record_key[1]}.unknown", + 'type': record_key[2], + 'error': str(e) }) - log(f"FAILBACK: {entry['recordName']}.{entry['zoneName']} returning to primary gateway") - failover_event = 'failback' - - entry_state['activeGateway'] = active_uuid - - # Get current Hetzner IP for history tracking - current_hetzner_ip = entry_state.get('hetznerIp') - - # Update DNS (update_dns_record checks both IP and TTL before deciding to update) - success, update_reason = update_dns_record(api, entry, target_ip, state) - - if success: - entry_state['hetznerIp'] = target_ip - entry_state['lastUpdate'] = int(time.time()) - entry_state['status'] = 'active' if reason in ['primary', 'failback'] else 'failover' - if update_reason in ['updated', 'created']: - results['updated'] += 1 - # Add history entry for tracking IP changes - action = 'create' if update_reason == 'created' else 'update' - add_history_entry(entry, account, current_hetzner_ip, target_ip, action) - # Send notification for update - send_notification(config, 'update', entry, current_hetzner_ip, target_ip) - # Send failover/failback notification if applicable - if failover_event: - send_notification(config, failover_event, entry, current_hetzner_ip, target_ip) - else: - entry_state['status'] = 'error' - results['errors'] += 1 - # Send error notification - send_notification(config, 'error', entry, error_msg=update_reason) - - results['processed'] += 1 # Trim failover history to last 100 entries - if len(state['failoverHistory']) > 100: - state['failoverHistory'] = state['failoverHistory'][-100:] + with state_lock: + if len(state['failoverHistory']) > 100: + state['failoverHistory'] = state['failoverHistory'][-100:] + + # Send single batch notification with all changes + send_batch_notification(config, batch_events) return results @@ -787,6 +1071,8 @@ def main(): if update_results.get('skipped_no_account', 0) > 0: result['message'] += f", {update_results['skipped_no_account']} skipped (no account)" + if update_results.get('skipped_duplicate', 0) > 0: + result['message'] += f", {update_results['skipped_duplicate']} skipped (duplicate)" if update_results['failovers'] > 0: result['message'] += f", {update_results['failovers']} failovers" if update_results['failbacks'] > 0: diff --git a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py index 949420f3a8..d6f3646aad 100644 --- a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py +++ b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py @@ -24,12 +24,16 @@ POSSIBILITY OF SUCH DAMAGE. Hetzner Cloud DNS API provider for OPNsense DynDNS - Uses the new Cloud API (api.hetzner.cloud) instead of the deprecated dns.hetzner.com API + Uses the new Cloud API (api.hetzner.cloud) with proper rrset-actions endpoints """ import syslog +import time import requests from . import BaseAccount +ACTION_POLL_INTERVAL = 0.5 # seconds between action status polls +ACTION_MAX_WAIT = 30 # maximum seconds to wait for action + class HetznerCloud(BaseAccount): _priority = 65535 @@ -59,6 +63,36 @@ def _get_headers(self): 'Content-Type': 'application/json' } + def _wait_for_action(self, headers, action_id): + """Wait for an async action to complete.""" + start_time = time.time() + + while time.time() - start_time < ACTION_MAX_WAIT: + url = f"{self._api_base}/actions/{action_id}" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + return False + + try: + data = response.json() + action = data.get('action', {}) + status = action.get('status', '') + + if status == 'success': + return True + elif status == 'error': + return False + elif status in ['running', 'pending']: + time.sleep(ACTION_POLL_INTERVAL) + continue + else: + return True # Unknown status, assume success + except Exception: + return False + + return False # Timeout + def _get_zone_name(self): """Get zone name from settings - try 'zone' field first, then 'username' as fallback""" zone_name = self.settings.get('zone', '').strip() @@ -139,29 +173,59 @@ def _get_record(self, headers, zone_id, record_name, record_type): return None def _update_record(self, headers, zone_id, record_name, record_type, address): - """Update existing record with new address + """Update existing record with new address using set_records action. - NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update. - Workaround: DELETE old record, then POST new record. + Uses the proper rrset-actions endpoint which correctly updates RRsets. + Actions are async and will be waited upon for completion. """ - # DELETE old record first - delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}" - delete_response = requests.delete(delete_url, headers=headers) + url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}/actions/set_records" - if delete_response.status_code not in [200, 201, 204]: + data = { + 'records': [{'value': str(address)}], + 'ttl': int(self.settings.get('ttl', 300)) + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code not in [200, 201]: syslog.syslog( syslog.LOG_ERR, - "Account %s error deleting record for update: HTTP %d - %s" % ( - self.description, delete_response.status_code, delete_response.text + "Account %s error updating record: HTTP %d - %s" % ( + self.description, response.status_code, response.text ) ) return False - # CREATE new record - return self._create_record(headers, zone_id, record_name, record_type, address) + # Check if there's an action to wait for + try: + response_data = response.json() + action = response_data.get('action', {}) + action_id = action.get('id') + + if action_id and action.get('status') in ['running', 'pending']: + if not self._wait_for_action(headers, action_id): + syslog.syslog( + syslog.LOG_ERR, + "Account %s update action failed or timed out for %s %s" % ( + self.description, record_name, record_type + ) + ) + return False + except Exception: + pass # No action in response, that's fine + + if self.is_verbose: + syslog.syslog( + syslog.LOG_NOTICE, + "Account %s updated %s %s with %s" % ( + self.description, record_name, record_type, address + ) + ) + + return True def _create_record(self, headers, zone_id, record_name, record_type, address): - """Create new record""" + """Create new record with async action handling.""" url = f"{self._api_base}/zones/{zone_id}/rrsets" data = { @@ -182,6 +246,24 @@ def _create_record(self, headers, zone_id, record_name, record_type, address): ) return False + # Check if there's an action to wait for + try: + response_data = response.json() + action = response_data.get('action', {}) + action_id = action.get('id') + + if action_id and action.get('status') in ['running', 'pending']: + if not self._wait_for_action(headers, action_id): + syslog.syslog( + syslog.LOG_ERR, + "Account %s create action failed or timed out for %s %s" % ( + self.description, record_name, record_type + ) + ) + return False + except Exception: + pass # No action in response, that's fine + if self.is_verbose: syslog.syslog( syslog.LOG_NOTICE,