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/14] 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 000000000..68074597e --- /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/14] 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 68074597e..aa07e2b44 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 @@ class Hetzner(BaseAccount): 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}" + """Update existing record with new address - data = { - 'records': [{'value': str(address)}], - 'ttl': int(self.settings.get('ttl', 300)) - } + 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) - response = requests.put(url, headers=headers, json=data) - - 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/14] 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 000000000..30dc09360 --- /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 000000000..3a34c9e81 --- /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 000000000..f14463d2d --- /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 000000000..f13564995 --- /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 000000000..2f10e0094 --- /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 000000000..101b36531 --- /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 000000000..387768ec9 --- /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 000000000..a99274da6 --- /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 000000000..ecf39c1ba --- /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 000000000..0f9993e38 --- /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 000000000..9815e4816 --- /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 000000000..f91fdbbfb --- /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 000000000..1dafd888f --- /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 000000000..041c16d02 --- /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 000000000..7477fa457 --- /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 000000000..551a4e983 --- /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 000000000..61fdf69bb --- /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 000000000..2f41773e8 --- /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 000000000..02343c8d2 --- /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 000000000..4baa3b1f4 --- /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 000000000..5e2fbdae4 --- /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 000000000..f2aa89101 --- /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 000000000..2ade4892f --- /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 000000000..e1b57ebb0 --- /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 000000000..683a63a6d --- /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 000000000..864e02762 --- /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 000000000..05531bb12 --- /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 000000000..1ca44b38f --- /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 000000000..ed77db0e2 --- /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 000000000..25dbb263f --- /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 000000000..ece13736f --- /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 000000000..a04c20814 --- /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 000000000..3c32b4a37 --- /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 000000000..39e34cc29 --- /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 000000000..d6fe20244 --- /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 000000000..b8fe96985 --- /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 000000000..e6039de98 --- /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 000000000..b921b68b2 --- /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 000000000..7d4ac3f8d --- /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 000000000..302202533 --- /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 000000000..8cee2d606 --- /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 000000000..7f4cd95a0 --- /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 000000000..a646eeb00 --- /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 000000000..1c4a33216 --- /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 000000000..4a93a58d0 --- /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 000000000..fd043f240 --- /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 000000000..db2a51c98 --- /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 000000000..ac82fd22f --- /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 000000000..81467825a --- /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 000000000..577115846 --- /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 000000000..949420f3a --- /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 000000000..08f5591e2 --- /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 000000000..ae019e50a --- /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/14] 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 | 18 +- .../scripts/HCloudDNS/update_records_v2.py | 125 +++++++++ 15 files changed, 864 insertions(+), 213 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 387768ec9..e5bd5f261 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 @@ class EntriesController extends ApiMutableModelControllerBase $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 @@ class EntriesController extends ApiMutableModelControllerBase $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 0f9993e38..b22323ad2 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 @@ class HistoryController extends ApiMutableModelControllerBase ]; } + /** + * 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 f91fdbbfb..22f600eb3 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 @@ class SettingsController extends ApiMutableModelControllerBase { $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 000000000..f8af3646a --- /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 041c16d02..97f07f659 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 @@ class IndexController extends BaseIndexController { $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 f2aa89101..fa30e2c5d 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 d1e8e5c31..b8b0f7f48 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 e1b57ebb0..dcf2de0b3 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 000000000..88d7cddce --- /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 864e02762..8b034f70b 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 000000000..1542363f5 --- /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 25dbb263f..9f7815126 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 ece13736f..b0c438909 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.") }}
    '); - // Load record counts for all zones - $.each(data.zones, function(i, zone) { - loadRecordCount(zone.id); + // 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); + }); + } 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(); + // Show loading state + var $btn = $(this); + $btn.prop('disabled', true).html(''); - // 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(); + // 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(''); - // Store old values for history - $('#recordOldValue').val(value); - $('#recordOldTtl').val(ttl); + if (!data || data.status !== 'ok' || !data.records) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: '{{ lang._("Failed to load record data.") }}'}); + return; + } - $('#recordName').val(name); - $('#recordTtl').val(ttl); - $('#recordType').val(type).trigger('change'); + // 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; + } + } - // 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]); + if (!record) { + BootstrapDialog.alert({type: BootstrapDialog.TYPE_WARNING, message: '{{ lang._("Record not found. It may have been deleted.") }}'}); + loadRecords(zoneId); + return; + } + + 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); } - } 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'); + $('#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 b5e117a67..f5d73a646 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 a49f4a9c2..b32f70e8a 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 @@ class HetznerCloudAPI: # 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/14] 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 b32f70e8a..93ec4342a 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 @@ class HetznerCloudAPI: """ 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 @@ class HetznerLegacyAPI: 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 @@ class HetznerLegacyAPI: 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/14] 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 | 462 ++++++++++++++---- .../ddclient/lib/account/hetzner_cloud.py | 108 +++- 3 files changed, 578 insertions(+), 116 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 93ec4342a..62b613685 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 @@ class HetznerCloudAPI: 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 @@ class HetznerCloudAPI: # 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 @@ class HetznerCloudAPI: 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 @@ class HetznerCloudAPI: 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 @@ class HetznerCloudAPI: """ 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 @@ class HetznerCloudAPI: 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 @@ class HetznerCloudAPI: """ 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 4a805473e..979ff58af 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 } - # Cache API instances per account + # Batch notification data - collect all events for single notification + batch_events = { + 'updates': [], + 'failovers': [], + 'failbacks': [], + 'errors': [] + } + + # 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 - } + # Store entry with its dependencies for parallel processing + unique_entries[record_key] = { + 'entry': entry, + 'account': account, + 'api': api_cache[account_uuid] + } - entry_state = state['entries'][entry_uuid] - old_active_gw = entry_state.get('activeGateway') + # Phase 2: Process all unique entries in parallel + max_workers = min(10, len(unique_entries)) if unique_entries else 1 - # Determine active gateway - active_uuid, active_gw, reason = determine_active_gateway(entry, config, state) + 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() + } - if not active_gw: - entry_state['status'] = 'error' - results['errors'] += 1 - continue + # Collect results as they complete + for future in as_completed(future_to_entry): + record_key = future_to_entry[future] + try: + result = future.result() - # 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') + results['processed'] += 1 - 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 + if result.get('updated'): + results['updated'] += 1 + if result.get('update_event'): + batch_events['updates'].append(result['update_event']) - # 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' + 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"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' - }) - 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 949420f3a..d6f3646aa 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 @@ class HetznerCloud(BaseAccount): '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 @@ class HetznerCloud(BaseAccount): 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 @@ class HetznerCloud(BaseAccount): ) 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, From 04429c441a364d648e7cf98e5a9a33dd450bb1a1 Mon Sep 17 00:00:00 2001 From: "Arcan Consulting - Michael J. Arcan" Date: Mon, 16 Feb 2026 12:11:28 +0100 Subject: [PATCH 12/14] Update hclouddns: 11 added, 20 modified, 1 deleted - Fix Log File tab URL to use core module path - Fix TTL updates: use dedicated change_ttl endpoint per Hetzner API spec (PR #5091 feedback), split update_record into set_records + change_ttl - Fix Implement forceInterval for periodic forced DNS updates - Fix Remove insecure SSL context (CERT_NONE) from gateway_health.py - Fix atomic file writes with 0600 permissions via write_state_file() - Fix service showing as disabled in OPNsense service overview by adding nocheck flag and proper start/stop/restart configd actions - Notification channels side by side: Ntfy | Email | Webhook (col-md-4) - Align Save/Test buttons at bottom of all wells using flexbox - Align Export/Import buttons at bottom of backup wells - Add deploy.sh - Add install.sh - Add OPNsense standard service buttons (Start/Stop/Restart) via updateServiceControlUI with proper state tracking via flag file - Add syslog-ng filter template matching message("HCloudDNS:") for log capture - Add HistoryController::addEntry() for JSONL history writes - Add SMTP fields to settings - Add email notifications via system mail - Add HMAC-SHA256 webhook signatures (X-HCloudDNS-Signature header) - Add dry-run/preview mode with UI dialog - Add read_history.py and manage_history.py configd scripts - Add M2_1_0.php migration to export existing history - Add hetzner_api_v2.py with TokenBucket rate limiter and 429 retry - Add clickable error link in status bar that jumps to DNS Entries tab - Add CARP-aware mode: DNS updates only run on CARP master node, backup node skips all operations with fail-open for standalone systems - Add CARP VHID filter: monitor all interfaces (default) or a specific VHID to determine master/backup status - Add CARP syshook (20-hclouddns) to trigger DNS update on failover - Add model migration M2_0_3 for new carpAware/carpVhid fields - Update hcloud_api.py wrapper with v2 routing and update_ttl() - Update export/import, deploy script, and package manifest - Move DNS change history from config.xml to JSONL file backend - Delete unused v1 update_records.py script - Delete HetznerCloudAPI (v1) class, use HetznerCloudAPIv2 with rate limiting - Rewrite HistoryController.php to use configd instead of model - Clean up old hcloudddns (double-D) plugin artifacts in deploy.sh to prevent crashes from the Dec 2025 rename --- net/hclouddns/deploy.sh | 168 ++++++ net/hclouddns/install.sh | 51 ++ net/hclouddns/pkg-plist | 2 + .../src/etc/inc/plugins.inc.d/hclouddns.inc | 3 + .../src/etc/rc.syshook.d/carp/20-hclouddns | 51 ++ .../HCloudDNS/Api/HistoryController.php | 294 ++++----- .../HCloudDNS/Api/ServiceController.php | 108 +++- .../HCloudDNS/Api/SettingsController.php | 26 +- .../OPNsense/HCloudDNS/IndexController.php | 1 + .../HCloudDNS/forms/dyndnsSettings.xml | 18 + .../OPNsense/HCloudDNS/forms/general.xml | 6 + .../models/OPNsense/HCloudDNS/HCloudDNS.xml | 94 ++- .../models/OPNsense/HCloudDNS/Menu/Menu.xml | 2 +- .../OPNsense/HCloudDNS/Migrations/M2_0_3.php | 39 ++ .../OPNsense/HCloudDNS/Migrations/M2_1_0.php | 110 ++++ .../mvc/app/views/OPNsense/HCloudDNS/dns.volt | 2 + .../app/views/OPNsense/HCloudDNS/history.volt | 1 + .../app/views/OPNsense/HCloudDNS/index.volt | 99 ++- .../views/OPNsense/HCloudDNS/settings.volt | 335 +++++++---- .../scripts/HCloudDNS/gateway_health.py | 32 +- .../opnsense/scripts/HCloudDNS/hcloud_api.py | 32 +- .../scripts/HCloudDNS/lib/hetzner_api.py | 470 +-------------- .../scripts/HCloudDNS/lib/hetzner_api_v2.py | 568 ++++++++++++++++++ .../scripts/HCloudDNS/manage_history.py | 150 +++++ .../scripts/HCloudDNS/read_history.py | 63 ++ .../scripts/HCloudDNS/service_control.py | 44 ++ .../scripts/HCloudDNS/simulate_failover.py | 9 +- .../opnsense/scripts/HCloudDNS/test_notify.py | 109 +++- .../scripts/HCloudDNS/update_records.py | 319 ---------- .../scripts/HCloudDNS/update_records_v2.py | 380 +++++++++--- .../conf/actions.d/actions_hclouddns.conf | 50 +- .../OPNsense/Syslog/local/hclouddns.conf | 6 + 32 files changed, 2340 insertions(+), 1302 deletions(-) create mode 100755 net/hclouddns/deploy.sh create mode 100755 net/hclouddns/install.sh create mode 100755 net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns create mode 100644 net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dyndnsSettings.xml create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_3.php create mode 100644 net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_1_0.php create mode 100644 net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api_v2.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/manage_history.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py create mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/service_control.py delete mode 100755 net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py create mode 100644 net/hclouddns/src/opnsense/service/templates/OPNsense/Syslog/local/hclouddns.conf diff --git a/net/hclouddns/deploy.sh b/net/hclouddns/deploy.sh new file mode 100755 index 000000000..01439c019 --- /dev/null +++ b/net/hclouddns/deploy.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# +# Deploy os-hcloud-ddns to OPNsense for testing +# + +set -e + +# Konfiguration +OPNSENSE_IP="${1:-}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC_DIR="${SCRIPT_DIR}/src" + +# Farben +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +if [ -z "$OPNSENSE_IP" ]; then + echo -e "${YELLOW}Usage: $0 ${NC}" + echo "" + echo "Example:" + echo " $0 192.168.1.1" + echo " $0 opnsense.local" + exit 1 +fi + +# Determine SSH/SCP method: key-based or password +SSH_CMD="ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5" +SCP_CMD="scp -o StrictHostKeyChecking=no -q" + +if ssh -o ConnectTimeout=5 -o BatchMode=yes root@${OPNSENSE_IP} "true" 2>/dev/null; then + AUTH_METHOD="key" +else + # No key auth available - use sshpass + if ! command -v sshpass &>/dev/null; then + echo -e "${RED}ERROR: No SSH key configured and 'sshpass' not installed.${NC}" + echo "Install sshpass or configure SSH key authentication." + exit 1 + fi + # Use SSHPASS env var if already set, otherwise prompt + if [ -z "$SSHPASS" ]; then + read -s -p "root@${OPNSENSE_IP} password: " SSHPASS + echo "" + export SSHPASS + fi + SSH_CMD="sshpass -e ${SSH_CMD}" + SCP_CMD="sshpass -e ${SCP_CMD}" + AUTH_METHOD="password" +fi + +echo -e "${GREEN}=== Deploying os-hcloud-ddns to ${OPNSENSE_IP} (${AUTH_METHOD} auth) ===${NC}" +echo "" + +# Test SSH connection +echo -e "${YELLOW}[1/5] Testing SSH connection...${NC}" +if ! ${SSH_CMD} root@${OPNSENSE_IP} "echo 'SSH OK'" 2>/dev/null; then + echo -e "${RED}ERROR: Cannot connect to root@${OPNSENSE_IP}${NC}" + echo "Make sure:" + echo " 1. SSH is enabled on OPNsense" + echo " 2. Your SSH key or password is correct" + echo " 3. The IP address is correct" + exit 1 +fi + +# Clean up old plugin artifacts (renamed from HCloudDDNS to HCloudDNS in Dec 2025) +echo -e "${YELLOW}[2/6] Cleaning up old plugin artifacts (hcloudddns → hclouddns)...${NC}" +${SSH_CMD} root@${OPNSENSE_IP} " +rm -f /usr/local/etc/inc/plugins.inc.d/hcloudddns.inc +rm -f /usr/local/etc/rc.syshook.d/monitor/50-hcloudddns +rm -f /usr/local/etc/rc.syshook.d/carp/20-hcloudddns +rm -f /usr/local/opnsense/service/conf/actions.d/actions_hcloudddns.conf +rm -rf /usr/local/opnsense/scripts/HCloudDDNS +rm -rf /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDDNS +rm -rf /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDDNS +rm -rf /usr/local/opnsense/mvc/app/views/OPNsense/HCloudDDNS +" + +# Create directories on OPNsense +echo -e "${YELLOW}[3/6] Creating directories...${NC}" +${SSH_CMD} root@${OPNSENSE_IP} " +mkdir -p /usr/local/opnsense/scripts/HCloudDNS/lib +mkdir -p /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api +mkdir -p /usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms +mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL +mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu +mkdir -p /usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations +mkdir -p /usr/local/opnsense/mvc/app/views/OPNsense/HCloudDNS +mkdir -p /usr/local/opnsense/service/conf/actions.d +mkdir -p /usr/local/etc/inc/plugins.inc.d +mkdir -p /usr/local/etc/rc.syshook.d/carp +mkdir -p /usr/local/etc/rc.syshook.d/monitor +" + +# Copy files +echo -e "${YELLOW}[4/6] Copying files...${NC}" + +# Python scripts +${SCP_CMD} ${SRC_DIR}/opnsense/scripts/HCloudDNS/*.py root@${OPNSENSE_IP}:/usr/local/opnsense/scripts/HCloudDNS/ +${SCP_CMD} ${SRC_DIR}/opnsense/scripts/HCloudDNS/lib/*.py root@${OPNSENSE_IP}:/usr/local/opnsense/scripts/HCloudDNS/lib/ + +# PHP Controllers +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/ +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ + +# Forms +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/ + +# Models +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/*.xml root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/ +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/*.php root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/ + +# Views +${SCP_CMD} ${SRC_DIR}/opnsense/mvc/app/views/OPNsense/HCloudDNS/*.volt root@${OPNSENSE_IP}:/usr/local/opnsense/mvc/app/views/OPNsense/HCloudDNS/ + +# Configd actions +${SCP_CMD} ${SRC_DIR}/opnsense/service/conf/actions.d/actions_hclouddns.conf root@${OPNSENSE_IP}:/usr/local/opnsense/service/conf/actions.d/ + +# Plugin hook +${SCP_CMD} ${SRC_DIR}/etc/inc/plugins.inc.d/hclouddns.inc root@${OPNSENSE_IP}:/usr/local/etc/inc/plugins.inc.d/ + +# Syshooks (gateway monitor + CARP transition) +${SCP_CMD} ${SRC_DIR}/etc/rc.syshook.d/monitor/50-hclouddns root@${OPNSENSE_IP}:/usr/local/etc/rc.syshook.d/monitor/ +${SCP_CMD} ${SRC_DIR}/etc/rc.syshook.d/carp/20-hclouddns root@${OPNSENSE_IP}:/usr/local/etc/rc.syshook.d/carp/ + +# Syslog filter template (for Log File tab) +${SSH_CMD} root@${OPNSENSE_IP} "mkdir -p /usr/local/opnsense/service/templates/OPNsense/Syslog/local" +${SCP_CMD} ${SRC_DIR}/opnsense/service/templates/OPNsense/Syslog/local/hclouddns.conf root@${OPNSENSE_IP}:/usr/local/opnsense/service/templates/OPNsense/Syslog/local/ + +# Set permissions and restart services +echo -e "${YELLOW}[5/6] Setting permissions and restarting services...${NC}" +${SSH_CMD} root@${OPNSENSE_IP} " +chmod +x /usr/local/opnsense/scripts/HCloudDNS/*.py +chmod +x /usr/local/opnsense/scripts/HCloudDNS/lib/*.py +chmod +x /usr/local/etc/rc.syshook.d/carp/20-hclouddns +chmod +x /usr/local/etc/rc.syshook.d/monitor/50-hclouddns +service configd restart +# Regenerate syslog-ng config to include hclouddns filter and reload +configctl template reload OPNsense/Syslog +service syslog-ng restart +" + +# Test +echo -e "${YELLOW}[6/6] Testing installation...${NC}" +RESULT=$(${SSH_CMD} root@${OPNSENSE_IP} "configctl hclouddns status 2>&1 || echo 'FAIL'") +if [[ "$RESULT" == *"FAIL"* ]] || [[ "$RESULT" == *"error"* ]]; then + echo -e "${RED}WARNING: configctl test returned unexpected result${NC}" + echo "$RESULT" +else + echo -e "${GREEN}configctl hclouddns status: OK${NC}" +fi + +echo "" +echo -e "${GREEN}=== Deployment complete! ===${NC}" +echo "" +echo "Next steps:" +echo " 1. Open https://${OPNSENSE_IP} in your browser" +echo " 2. Navigate to: Services → Hetzner Cloud DDNS" +echo " 3. If menu doesn't appear, run on OPNsense:" +echo " service php-fpm restart" +echo "" +echo "To test the backend manually:" +echo " ssh root@${OPNSENSE_IP}" +echo " configctl hclouddns validate YOUR_HETZNER_TOKEN" +echo " configctl hclouddns list zones YOUR_HETZNER_TOKEN" diff --git a/net/hclouddns/install.sh b/net/hclouddns/install.sh new file mode 100755 index 000000000..ebfbcce94 --- /dev/null +++ b/net/hclouddns/install.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# +# HCloudDNS Plugin Installer for OPNsense +# Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +# +# Usage: ./install.sh [user@]hostname +# + +set -e + +TARGET="${1:-root@192.168.1.1}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==========================================" +echo " HCloudDNS Plugin Installer v2.0.0" +echo "==========================================" +echo "" +echo "Target: $TARGET" +echo "Source: $SCRIPT_DIR/src" +echo "" + +# Check if src directory exists +if [ ! -d "$SCRIPT_DIR/src" ]; then + echo "ERROR: src/ directory not found!" + exit 1 +fi + +echo ">>> Copying files to OPNsense..." +scp -r "$SCRIPT_DIR/src/"* "$TARGET:/usr/local/" + +echo "" +echo ">>> Setting permissions..." +ssh "$TARGET" "chmod +x /usr/local/opnsense/scripts/HCloudDNS/*.py" 2>/dev/null || true +ssh "$TARGET" "chmod +x /usr/local/etc/rc.syshook.d/monitor/50-hclouddns" 2>/dev/null || true + +echo "" +echo ">>> Restarting configd service..." +ssh "$TARGET" 'service configd restart' + +echo "" +echo "==========================================" +echo " Installation complete!" +echo "==========================================" +echo "" +echo "Access the plugin at:" +echo " Services -> Hetzner Cloud DNS" +echo "" +echo "If menu doesn't appear, clear browser cache" +echo "or restart the web GUI:" +echo " service php-fpm restart" +echo "" diff --git a/net/hclouddns/pkg-plist b/net/hclouddns/pkg-plist index f14463d2d..9f5d3e4e5 100644 --- a/net/hclouddns/pkg-plist +++ b/net/hclouddns/pkg-plist @@ -1,4 +1,5 @@ etc/inc/plugins.inc.d/hclouddns.inc +etc/rc.syshook.d/carp/20-hclouddns 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 @@ -41,6 +42,7 @@ 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/service_control.py opnsense/scripts/HCloudDNS/simulate_failover.py opnsense/scripts/HCloudDNS/status.py opnsense/scripts/HCloudDNS/test_notify.py diff --git a/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc index f13564995..543e0c565 100644 --- a/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc +++ b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc @@ -39,9 +39,12 @@ function hclouddns_services() $services[] = array( 'description' => gettext('Hetzner Cloud Dynamic DNS'), 'configd' => array( + 'start' => array('hclouddns start'), + 'stop' => array('hclouddns stop'), 'restart' => array('hclouddns update'), ), 'name' => 'hclouddns', + 'nocheck' => true, ); } diff --git a/net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns b/net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns new file mode 100755 index 000000000..f106f421a --- /dev/null +++ b/net/hclouddns/src/etc/rc.syshook.d/carp/20-hclouddns @@ -0,0 +1,51 @@ +#!/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 CARP Transition Syshook +# Called by OPNsense on CARP state transitions +# Arguments: $1 = interface, $2 = MASTER|BACKUP|INIT + +SUBSYSTEM="carp" +TYPE="${1}" +STATE="${2}" + +logger -t hclouddns "CARP transition: interface=${TYPE} state=${STATE}" + +case "${STATE}" in + MASTER) + # This node became MASTER - trigger DNS update + # The Python script will verify CARP status again before updating + logger -t hclouddns "CARP MASTER on ${TYPE} - triggering DNS update" + /usr/local/sbin/configctl -d hclouddns update + ;; + BACKUP|INIT) + logger -t hclouddns "CARP ${STATE} on ${TYPE} - no action" + ;; +esac + +exit 0 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 b22323ad2..8d34910c7 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 @@ -28,72 +28,94 @@ namespace OPNsense\HCloudDNS\Api; -use OPNsense\Base\ApiMutableModelControllerBase; +use OPNsense\Base\ApiControllerBase; use OPNsense\Core\Backend; -use OPNsense\Core\Config; +use OPNsense\HCloudDNS\HCloudDNS; /** * Class HistoryController * @package OPNsense\HCloudDNS\Api */ -class HistoryController extends ApiMutableModelControllerBase +class HistoryController extends ApiControllerBase { - protected static $internalModelName = 'hclouddns'; - protected static $internalModelClass = 'OPNsense\HCloudDNS\HCloudDNS'; + private static $historyFile = '/var/log/hclouddns/history.jsonl'; /** - * Search history entries + * Add a history entry (called from HetznerController after DNS changes) + */ + public static function addEntry( + $action, + $accountUuid, + $accountName, + $zoneId, + $zoneName, + $recordName, + $recordType, + $oldValue, + $oldTtl, + $newValue, + $newTtl + ) { + $dir = dirname(self::$historyFile); + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + + $entry = [ + 'uuid' => sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ), + 'timestamp' => time(), + 'action' => $action, + 'accountUuid' => $accountUuid, + 'accountName' => $accountName, + 'zoneId' => $zoneId, + 'zoneName' => $zoneName, + 'recordName' => $recordName, + 'recordType' => $recordType, + 'oldValue' => $oldValue, + 'oldTtl' => intval($oldTtl), + 'newValue' => $newValue, + 'newTtl' => intval($newTtl), + 'reverted' => false + ]; + + $line = json_encode($entry) . "\n"; + $fp = @fopen(self::$historyFile, 'a'); + if ($fp) { + flock($fp, LOCK_EX); + fwrite($fp, $line); + flock($fp, LOCK_UN); + fclose($fp); + @chmod(self::$historyFile, 0600); + } + } + + /** + * Search history entries (from JSONL via configd) * @return array */ public function searchItemAction() { - $mdl = $this->getModel(); - $retentionDays = (int)$mdl->general->historyRetentionDays; - $cutoffTime = time() - ($retentionDays * 86400); + $backend = new Backend(); + $response = $backend->configdRun('hclouddns history search'); + $data = json_decode(trim($response), true); - $result = [ + if ($data !== null) { + return $data; + } + + return [ '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; } /** @@ -103,33 +125,19 @@ class HistoryController extends ApiMutableModelControllerBase */ public function getItemAction($uuid) { - $mdl = $this->getModel(); - $node = $mdl->getNodeByReference('history.change.' . $uuid); - - if ($node === null) { - return ['status' => 'error', 'message' => 'History entry not found']; + if (empty($uuid) || !preg_match('/^[a-f0-9-]{36}$/', $uuid)) { + return ['status' => 'error', 'message' => 'Invalid UUID']; } - 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 - ] - ]; + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns history get', [$uuid]); + $data = json_decode(trim($response), true); + + if ($data !== null) { + return $data; + } + + return ['status' => 'error', 'message' => 'History entry not found']; } /** @@ -143,28 +151,35 @@ class HistoryController extends ApiMutableModelControllerBase return ['status' => 'error', 'message' => 'POST required']; } - $mdl = $this->getModel(); - $node = $mdl->getNodeByReference('history.change.' . $uuid); + if (empty($uuid) || !preg_match('/^[a-f0-9-]{36}$/', $uuid)) { + return ['status' => 'error', 'message' => 'Invalid UUID']; + } - if ($node === null) { + // Get the history entry details + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns history get', [$uuid]); + $data = json_decode(trim($response), true); + + if ($data === null || $data['status'] !== 'ok' || !isset($data['change'])) { return ['status' => 'error', 'message' => 'History entry not found']; } - if ((string)$node->reverted === '1') { + $change = $data['change']; + + if ($change['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; + $action = $change['action']; + $accountUuid = $change['accountUuid']; + $zoneId = $change['zoneId']; + $recordName = $change['recordName']; + $recordType = $change['recordType']; + $oldValue = $change['oldValue']; + $oldTtl = $change['oldTtl'] ?? '300'; // Get the account's API token + $mdl = new HCloudDNS(); $accountNode = $mdl->getNodeByReference('accounts.account.' . $accountUuid); if ($accountNode === null) { return ['status' => 'error', 'message' => 'Account not found - cannot revert']; @@ -176,25 +191,21 @@ class HistoryController extends ApiMutableModelControllerBase } $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 @@ -203,10 +214,8 @@ class HistoryController extends ApiMutableModelControllerBase } if ($result !== null && isset($result['status']) && $result['status'] === 'ok') { - // Mark the history entry as reverted - $node->reverted = '1'; - $mdl->serializeToConfig(); - Config::getInstance()->save(); + // Mark the history entry as reverted via configd + $backend->configdpRun('hclouddns history revert', [$uuid]); return [ 'status' => 'ok', @@ -230,35 +239,18 @@ class HistoryController extends ApiMutableModelControllerBase return ['status' => 'error', 'message' => 'POST required']; } - $mdl = $this->getModel(); - $retentionDays = (int)$mdl->general->historyRetentionDays; - $cutoffTime = time() - ($retentionDays * 86400); + $mdl = new HCloudDNS(); + $retentionDays = (string)$mdl->general->historyRetentionDays ?: '7'; - $deleted = 0; - $toDelete = []; + $backend = new Backend(); + $response = $backend->configdpRun('hclouddns history cleanup', [$retentionDays]); + $data = json_decode(trim($response), true); - foreach ($mdl->history->change->iterateItems() as $uuid => $change) { - $timestamp = (int)(string)$change->timestamp; - if ($timestamp < $cutoffTime) { - $toDelete[] = $uuid; - } + if ($data !== null) { + return $data; } - 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" - ]; + return ['status' => 'error', 'message' => 'Cleanup failed']; } /** @@ -271,68 +263,14 @@ class HistoryController extends ApiMutableModelControllerBase return ['status' => 'error', 'message' => 'POST required']; } - $mdl = $this->getModel(); - $deleted = 0; - $toDelete = []; + $backend = new Backend(); + $response = $backend->configdRun('hclouddns history clear'); + $data = json_decode(trim($response), true); - foreach ($mdl->history->change->iterateItems() as $uuid => $change) { - $toDelete[] = $uuid; + if ($data !== null) { + return $data; } - 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 - * @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; + return ['status' => 'error', 'message' => 'Clear failed']; } } 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 index 9815e4816..50bfd3af4 100644 --- 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 @@ -52,9 +52,85 @@ class ServiceController extends ApiControllerBase return ['status' => 'error', 'message' => 'Failed to get status']; } + // Determine running/stopped for updateServiceControlUI + $mdl = new HCloudDNS(); + $enabled = (string)$mdl->general->enabled === '1'; + $stopped = file_exists('/var/run/hclouddns.stopped'); + $data['status'] = ($enabled && !$stopped) ? 'running' : 'stopped'; + return $data; } + /** + * Start service + * @return array + */ + public function startAction() + { + if ($this->request->isPost()) { + @unlink('/var/run/hclouddns.stopped'); + $backend = new Backend(); + $response = $backend->configdRun('hclouddns start'); + return ['status' => 'ok']; + } + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Stop service + * @return array + */ + public function stopAction() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns stop'); + return ['status' => 'ok']; + } + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Restart service + * @return array + */ + public function restartAction() + { + if ($this->request->isPost()) { + @unlink('/var/run/hclouddns.stopped'); + $backend = new Backend(); + $response = $backend->configdRun('hclouddns update'); + return ['status' => 'ok']; + } + return ['status' => 'error', 'message' => 'POST request required']; + } + + /** + * Get list of CARP VIPs from system config + * @return array + */ + public function getVipListAction() + { + $result = ['status' => 'ok', 'rows' => []]; + + $config = \OPNsense\Core\Config::getInstance()->object(); + + if (isset($config->virtualip) && isset($config->virtualip->vip)) { + foreach ($config->virtualip->vip as $vip) { + if ((string)$vip->mode === 'carp') { + $result['rows'][] = [ + 'vhid' => (string)$vip->vhid, + 'subnet' => (string)$vip->subnet, + 'interface' => (string)$vip->interface, + 'descr' => (string)$vip->descr + ]; + } + } + } + + return $result; + } + /** * Trigger manual update * @return array @@ -103,7 +179,7 @@ class ServiceController extends ApiControllerBase { if ($this->request->isPost()) { $backend = new Backend(); - $response = $backend->configdRun('hclouddns updatev2'); + $response = $backend->configdRun('hclouddns update'); $data = json_decode($response, true); if ($data === null) { @@ -116,6 +192,27 @@ class ServiceController extends ApiControllerBase return ['status' => 'error', 'message' => 'POST request required']; } + /** + * Preview DNS changes (dry run) + * @return array + */ + public function previewAction() + { + if ($this->request->isPost()) { + $backend = new Backend(); + $response = $backend->configdRun('hclouddns dryrun'); + $data = json_decode($response, true); + + if ($data === null) { + return ['status' => 'error', 'message' => 'Preview failed']; + } + + return $data; + } + + return ['status' => 'error', 'message' => 'POST request required']; + } + /** * Get failover history * @return array @@ -221,13 +318,18 @@ class ServiceController extends ApiControllerBase /** * Test notification channels + * @param string $channel Optional: email, webhook, ntfy (empty = all) * @return array */ - public function testNotifyAction() + public function testNotifyAction($channel = '') { if ($this->request->isPost()) { $backend = new Backend(); - $response = $backend->configdRun('hclouddns testnotify'); + $validChannels = ['email', 'webhook', 'ntfy', '']; + if (!in_array($channel, $validChannels)) { + return ['status' => 'error', 'message' => 'Invalid channel']; + } + $response = $backend->configdpRun('hclouddns testnotify', [$channel]); $data = json_decode(trim($response), true); if ($data !== null) { 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 40bf0662d..d6f703e06 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 @@ -66,9 +66,16 @@ class SettingsController extends ApiMutableModelControllerBase $hcloud->notifications->addChild('notifyOnError', '1'); $hcloud->notifications->addChild('emailEnabled', '0'); $hcloud->notifications->addChild('emailTo', ''); + $hcloud->notifications->addChild('emailFrom', ''); + $hcloud->notifications->addChild('smtpServer', ''); + $hcloud->notifications->addChild('smtpPort', '587'); + $hcloud->notifications->addChild('smtpTls', 'starttls'); + $hcloud->notifications->addChild('smtpUser', ''); + $hcloud->notifications->addChild('smtpPassword', ''); $hcloud->notifications->addChild('webhookEnabled', '0'); $hcloud->notifications->addChild('webhookUrl', ''); $hcloud->notifications->addChild('webhookMethod', 'POST'); + $hcloud->notifications->addChild('webhookSecret', ''); $hcloud->notifications->addChild('ntfyEnabled', '0'); $hcloud->notifications->addChild('ntfyServer', 'https://ntfy.sh'); $hcloud->notifications->addChild('ntfyTopic', ''); @@ -225,7 +232,9 @@ class SettingsController extends ApiMutableModelControllerBase 'failbackDelay' => (string)$general->failbackDelay, 'cronEnabled' => (string)$general->cronEnabled, 'cronInterval' => (string)$general->cronInterval, - 'historyRetentionDays' => (string)$general->historyRetentionDays + 'historyRetentionDays' => (string)$general->historyRetentionDays, + 'carpAware' => (string)$general->carpAware, + 'carpVhid' => (string)$general->carpVhid ]; // Export notification settings @@ -238,9 +247,15 @@ class SettingsController extends ApiMutableModelControllerBase 'notifyOnError' => (string)$notifications->notifyOnError, 'emailEnabled' => (string)$notifications->emailEnabled, 'emailTo' => (string)$notifications->emailTo, + 'emailFrom' => (string)$notifications->emailFrom, + 'smtpServer' => (string)$notifications->smtpServer, + 'smtpPort' => (string)$notifications->smtpPort, + 'smtpTls' => (string)$notifications->smtpTls, + 'smtpUser' => (string)$notifications->smtpUser, 'webhookEnabled' => (string)$notifications->webhookEnabled, 'webhookUrl' => (string)$notifications->webhookUrl, 'webhookMethod' => (string)$notifications->webhookMethod, + 'webhookSecret' => (string)$notifications->webhookSecret, 'ntfyEnabled' => (string)$notifications->ntfyEnabled, 'ntfyServer' => (string)$notifications->ntfyServer, 'ntfyTopic' => (string)$notifications->ntfyTopic, @@ -338,6 +353,8 @@ class SettingsController extends ApiMutableModelControllerBase 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']; + if (isset($gen['carpAware'])) $mdl->general->carpAware = $gen['carpAware']; + if (isset($gen['carpVhid'])) $mdl->general->carpVhid = $gen['carpVhid']; } // Import notification settings @@ -350,9 +367,16 @@ class SettingsController extends ApiMutableModelControllerBase 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['emailFrom'])) $mdl->notifications->emailFrom = $notif['emailFrom']; + if (isset($notif['smtpServer'])) $mdl->notifications->smtpServer = $notif['smtpServer']; + if (isset($notif['smtpPort'])) $mdl->notifications->smtpPort = $notif['smtpPort']; + if (isset($notif['smtpTls'])) $mdl->notifications->smtpTls = $notif['smtpTls']; + if (isset($notif['smtpUser'])) $mdl->notifications->smtpUser = $notif['smtpUser']; + if (isset($notif['smtpPassword'])) $mdl->notifications->smtpPassword = $notif['smtpPassword']; 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['webhookSecret'])) $mdl->notifications->webhookSecret = $notif['webhookSecret']; 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']; 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 e00a07a05..30210abde 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 @@ -49,6 +49,7 @@ class IndexController extends BaseIndexController $this->view->scheduledForm = $this->getForm('dialogScheduled'); $this->view->entrySettingsForm = $this->getForm('dialogEntrySettings'); $this->view->failoverForm = $this->getForm('failover'); + $this->view->dyndnsSettingsForm = $this->getForm('dyndnsSettings'); } /** diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dyndnsSettings.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dyndnsSettings.xml new file mode 100644 index 000000000..74ae264e9 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dyndnsSettings.xml @@ -0,0 +1,18 @@ +
    + + header + + + + hclouddns.general.carpAware + + checkbox + Only run DNS updates on the CARP master node. When enabled, the backup node will skip all DNS operations. Standalone systems without CARP interfaces are not affected (fail-open). + + + hclouddns.general.carpVhid + + dropdown + Select a CARP VIP to monitor, or "Any" to monitor all CARP interfaces. If any monitored VIP is BACKUP, DNS updates are skipped. + +
    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 index 5e2fbdae4..a58c3e488 100644 --- 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 @@ -11,6 +11,12 @@ checkbox Write detailed log entries to syslog + + hclouddns.general.forceInterval + + text + Force DNS update even if IP unchanged after this many days. 0 = disabled (default: 0) + hclouddns.general.historyRetentionDays 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 9d0f7ee13..e45df9613 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.2 + 2.1.0 @@ -69,6 +69,15 @@ <_86400>86400s (1 day) + + + 0 + + + N + /^(\d{1,3})?$/ + VHID must be a number (1-255) or empty for all + N @@ -221,56 +230,6 @@ - - - - - Y - - - Y - - Create - Update - Delete - - - - Y - - - N - - - Y - - - Y - - - Y - - - Y - - - N - - - N - - - N - - - N - - - 0 - - - - @@ -296,6 +255,35 @@ N Valid email address required + + N + Valid sender email address required + + + N + /^.{0,255}$/ + + + 587 + 1 + 65535 + + + starttls + + None + STARTTLS (Port 587) + SSL/TLS (Port 465) + + + + N + /^.{0,128}$/ + + + N + /^.{0,256}$/ + 0 @@ -311,6 +299,10 @@ GET + + N + /^.{0,256}$/ + 0 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 dcf2de0b3..4f38de1a9 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 @@ -5,7 +5,7 @@ - + diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_3.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_3.php new file mode 100644 index 000000000..f06184465 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_3.php @@ -0,0 +1,39 @@ +object(); + + if (!isset($config->OPNsense->HCloudDNS->general)) { + return; + } + + $general = $config->OPNsense->HCloudDNS->general; + + // Add carpAware field with default disabled + if (!isset($general->carpAware)) { + $general->addChild('carpAware', '0'); + } + + // Add carpVhid field (empty = monitor all CARP interfaces) + if (!isset($general->carpVhid)) { + $general->addChild('carpVhid', ''); + } + } +} diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_1_0.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_1_0.php new file mode 100644 index 000000000..6acd15908 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_1_0.php @@ -0,0 +1,110 @@ +object(); + + if (!isset($config->OPNsense->HCloudDNS)) { + return; + } + + $hcloud = $config->OPNsense->HCloudDNS; + + // 1. Export existing history entries to JSONL before removing them + if (isset($hcloud->history) && isset($hcloud->history->change)) { + $historyDir = '/var/log/hclouddns'; + $historyFile = $historyDir . '/history.jsonl'; + + if (!is_dir($historyDir)) { + @mkdir($historyDir, 0700, true); + } + + $entries = []; + foreach ($hcloud->history->children() as $change) { + if ($change->getName() !== 'change') { + continue; + } + $uuid = (string)$change->attributes()['uuid'] ?? ''; + if (empty($uuid)) { + $uuid = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } + + $entry = [ + 'uuid' => $uuid, + 'timestamp' => (int)(string)$change->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' => (int)(string)$change->oldTtl, + 'newValue' => (string)$change->newValue, + 'newTtl' => (int)(string)$change->newTtl, + 'reverted' => ((string)$change->reverted === '1') + ]; + $entries[] = $entry; + } + + if (!empty($entries)) { + $jsonlContent = ''; + foreach ($entries as $entry) { + $jsonlContent .= json_encode($entry) . "\n"; + } + file_put_contents($historyFile, $jsonlContent); + chmod($historyFile, 0600); + } + + // Remove history section from config + unset($hcloud->history); + } + + // 2. Add webhookSecret default to notifications + if (isset($hcloud->notifications)) { + if (!isset($hcloud->notifications->webhookSecret)) { + $hcloud->notifications->addChild('webhookSecret', ''); + } + } + + // 3. Remove deprecated apiLayer field from existing accounts + if (isset($hcloud->accounts)) { + foreach ($hcloud->accounts->children() as $account) { + if ($account->getName() !== 'account') { + continue; + } + if (isset($account->apiLayer)) { + unset($account->apiLayer); + } + } + } + } +} 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 ebbba3462..dd494ed1f 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 @@ -490,6 +490,8 @@ 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 index 05531bb12..e57e14283 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt @@ -329,7 +329,7 @@ {{ lang._('ID') }} - {{ lang._('On') }} + {{ lang._('On') }} {{ lang._('Record') }} {{ lang._('Type') }} {{ lang._('Current IP') }} 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 index 1ca44b38f..2aeca13ba 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt @@ -128,7 +128,7 @@ {{ lang._('ID') }} - {{ lang._('Enabled') }} + {{ lang._('Enabled') }} {{ lang._('Name') }} {{ lang._('Interface') }} {{ lang._('Priority') }} 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 index 7345309e6..620e18933 100644 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt @@ -2,31 +2,81 @@ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) All rights reserved. - Hetzner Cloud DNS - Change History + Hetzner Cloud DNS - Audit Dashboard & Change History #}
    -

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

    +

    {{ lang._('DNS Audit Dashboard') }}

    - {{ 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._('Complete audit trail and analytics of all DNS changes - automatic updates (DynDNS, failover) and manual changes.') }}

    @@ -34,7 +84,29 @@ {{ lang._('History retention is configured in Settings (current:') }} ... {{ lang._('days).') }}
    - + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    -
    @@ -52,8 +124,52 @@
    -
    {{ lang._('Deletes') }}
    +
    +
    -
    +
    {{ lang._('Reverted') }}
    +
    +
    +
    -
    +
    {{ lang._('Avg/Day') }}
    +
    + +
    +
    {{ lang._('Activity Timeline') }}
    +
    +
    + +
    +
    +
    +
    +
    {{ lang._('Create') }}
    +
    {{ lang._('Update') }}
    +
    {{ lang._('Delete') }}
    +
    +
    + + +
    +
    +
    {{ lang._('Action Breakdown') }}
    +
    +
    +
    +
    +
    +
    {{ lang._('Top Zones') }}
    +
    +
    +
    +
    +
    + +
    +

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

    +
    + @@ -66,7 +182,7 @@ - + @@ -75,9 +191,13 @@
    {{ lang._('New Value') }} {{ lang._('Account') }} {{ lang._('Status') }}{{ lang._('Actions') }}

    - - - +
    + +
    + + +
    +
    @@ -85,68 +205,259 @@ $(document).ready(function() { updateServiceControlUI('hclouddns'); - function loadHistory() { - var $tbody = $('#historyTable tbody'); - $tbody.html(' Loading...'); + var allHistoryRows = []; // cache for client-side filtering - ajaxCall('/api/hclouddns/history/searchItem', {}, function(data) { - $tbody.empty(); + // Load retention days from settings + ajaxCall('/api/hclouddns/settings/get', {}, function(data) { + if (data && data.hclouddns && data.hclouddns.general) { + $('#retentionDays').text(data.hclouddns.general.historyRetentionDays || '7'); + } + }); - // Calculate statistics - var stats = {total: 0, creates: 0, updates: 0, deletes: 0}; + function loadDashboard() { + var days = parseInt($('#dashboardRange').val()) || 7; + loadStats(days); + loadHistory(); + } - if (!data || !data.rows || data.rows.length === 0) { - $tbody.html('{{ lang._("No history entries found. Changes will appear here when DNS records are modified.") }}'); - updateStats(stats); + function getAccountFilter() { + return $('#dashboardAccount').val() || ''; + } + + function loadStats(days) { + ajaxCall('/api/hclouddns/history/stats', {days: days}, function(data) { + if (!data || data.status !== 'ok') { + $('#statTotal, #statCreates, #statUpdates, #statDeletes, #statReverted, #statAvg').text('-'); return; } - $.each(data.rows, function(i, row) { - stats.total++; - if (row.action === 'create') stats.creates++; - else if (row.action === 'update') stats.updates++; - else if (row.action === 'delete') stats.deletes++; - - 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 || '-'; - - $tbody.append( - '' + - '' + row.timestampFormatted + '' + - ' ' + row.action + '' + - '' + recordFqdn + '' + - '' + row.recordType + '' + - '' + escapeHtml(oldVal) + '' + - '' + escapeHtml(newVal) + '' + - '' + (row.accountName || '-') + '' + - '' + revertedBadge + '' + - '' + revertBtn + '' + - '' - ); + // Populate account filter (preserve selection) + var currentAccount = getAccountFilter(); + var $accountSelect = $('#dashboardAccount'); + var accounts = data.byAccount || {}; + $accountSelect.find('option:not(:first)').remove(); + $.each(accounts, function(name) { + $accountSelect.append(''); }); + if (currentAccount) { + $accountSelect.val(currentAccount); + } - updateStats(stats); + // Update tiles + $('#statTotal').text(data.total || 0); + $('#statCreates').text(data.creates || 0); + $('#statUpdates').text(data.updates || 0); + $('#statDeletes').text(data.deletes || 0); + $('#statReverted').text(data.reverted || 0); + $('#statAvg').text(data.avgPerDay || 0); + + // Render activity timeline + renderTimeline(data.byDate || {}, days); + + // Render action breakdown + renderBreakdown(data.total || 0, data.creates || 0, data.updates || 0, data.deletes || 0); + + // Render top zones + renderTopZones(data.byZone || {}); }); } - function updateStats(stats) { - $('#statTotal').text(stats.total); - $('#statCreates').text(stats.creates); - $('#statUpdates').text(stats.updates); - $('#statDeletes').text(stats.deletes); + function renderTimeline(byDate, days) { + var $chart = $('#timelineChart').empty(); + var $labels = $('#timelineLabels').empty(); + + // Generate date range + var dates = []; + var now = new Date(); + for (var i = days - 1; i >= 0; i--) { + var d = new Date(now); + d.setDate(d.getDate() - i); + dates.push(d.toISOString().split('T')[0]); + } + + // Find max value for scaling + var maxVal = 0; + $.each(dates, function(i, date) { + var entry = byDate[date] || {create: 0, update: 0, delete: 0}; + var total = (entry.create || 0) + (entry.update || 0) + (entry.delete || 0); + if (total > maxVal) maxVal = total; + }); + if (maxVal === 0) maxVal = 1; + + // Render bars + $.each(dates, function(i, date) { + var entry = byDate[date] || {create: 0, update: 0, delete: 0}; + var c = entry.create || 0; + var u = entry.update || 0; + var d = entry.delete || 0; + var total = c + u + d; + + var cH = total > 0 ? Math.max(2, (c / maxVal) * 100) : 0; + var uH = total > 0 ? Math.max(2, (u / maxVal) * 100) : 0; + var dH = total > 0 ? Math.max(2, (d / maxVal) * 100) : 0; + if (c === 0) cH = 0; + if (u === 0) uH = 0; + if (d === 0) dH = 0; + + var shortDate = date.substring(5); + var tooltip = date + ': ' + c + ' create, ' + u + ' update, ' + d + ' delete'; + + $chart.append( + '
    ' + + '
    ' + tooltip + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + ); + + var showLabel = false; + if (days <= 7) showLabel = true; + else if (days <= 30) showLabel = (i % 3 === 0 || i === dates.length - 1); + else showLabel = (i % 7 === 0 || i === dates.length - 1); + + if (showLabel) { + $labels.append('' + shortDate + ''); + } + }); + } + + function renderBreakdown(total, creates, updates, deletes) { + if (total === 0) { + $('#actionBreakdown').html('
    {{ lang._("No data") }}
    '); + return; + } + + var html = ''; + var items = [ + {label: '{{ lang._("Create") }}', value: creates, cls: 'create'}, + {label: '{{ lang._("Update") }}', value: updates, cls: 'update'}, + {label: '{{ lang._("Delete") }}', value: deletes, cls: 'delete'} + ]; + + $.each(items, function(i, item) { + var pct = Math.round((item.value / total) * 100); + html += '
    ' + + '' + item.label + '' + + '
    ' + (pct > 10 ? pct + '%' : '') + '
    ' + + '' + item.value + '' + + '
    '; + }); + + $('#actionBreakdown').html(html); + } + + function renderTopZones(byZone) { + var zones = []; + $.each(byZone, function(name, count) { + zones.push({name: name, count: count}); + }); + zones.sort(function(a, b) { return b.count - a.count; }); + zones = zones.slice(0, 5); + + if (zones.length === 0) { + $('#topZones').html('
    {{ lang._("No data") }}
    '); + return; + } + + var maxCount = zones[0].count || 1; + var html = ''; + $.each(zones, function(i, z) { + var pct = Math.round((z.count / maxCount) * 100); + html += '
    ' + + '' + z.name + '' + + '
    ' + + '' + z.count + '' + + '
    '; + }); + + $('#topZones').html(html); + } + + function loadHistory() { + var $tbody = $('#historyTable tbody'); + $tbody.html(' {{ lang._("Loading...") }}'); + + ajaxCall('/api/hclouddns/history/searchItem', {}, function(data) { + if (!data || !data.rows || data.rows.length === 0) { + allHistoryRows = []; + renderHistoryTable(); + return; + } + allHistoryRows = data.rows; + renderHistoryTable(); + }); + } + + function renderHistoryTable() { + var $tbody = $('#historyTable tbody').empty(); + var days = parseInt($('#dashboardRange').val()) || 7; + var accountFilter = getAccountFilter(); + var cutoff = Math.floor(Date.now() / 1000) - (days * 86400); + + // Filter rows by time range and account + var filtered = []; + $.each(allHistoryRows, function(i, row) { + if (row.timestamp < cutoff) return; + if (accountFilter && row.accountName !== accountFilter) return; + filtered.push(row); + }); + + // Info text + var infoText = filtered.length + ' {{ lang._("entries") }}'; + if (filtered.length !== allHistoryRows.length) { + infoText += ' ({{ lang._("filtered from") }} ' + allHistoryRows.length + ' {{ lang._("total") }})'; + } + $('#historyTableInfo').text(infoText); + + if (filtered.length === 0) { + if (allHistoryRows.length === 0) { + // True empty state + $tbody.html( + '' + + '
    ' + + '' + + '

    {{ lang._("No changes recorded yet.") }}

    ' + + '

    {{ lang._("Changes appear here automatically when DNS records are modified — through DynDNS updates, failover events, or manual edits on the DNS management page.") }}

    ' + + '
    ' + + '' + ); + } else { + // Filtered empty + $tbody.html('{{ lang._("No entries match the selected filters. Try a wider time range or different account.") }}'); + } + return; + } + + $.each(filtered, 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' ? '{{ lang._("Reverted") }}' : '{{ lang._("Active") }}'; + + var revertBtn = ''; + if (row.reverted !== '1') { + revertBtn = ''; + } + + var recordFqdn = row.recordName + '.' + row.zoneName; + var oldVal = row.oldValue || '-'; + var newVal = row.newValue || '-'; + + $tbody.append( + '' + + '' + row.timestampFormatted + '' + + ' ' + row.action + '' + + '' + escapeHtml(recordFqdn) + '' + + '' + row.recordType + '' + + '' + escapeHtml(oldVal) + '' + + '' + escapeHtml(newVal) + '' + + '' + escapeHtml(row.accountName || '-') + '' + + '' + revertedBadge + '' + + '' + revertBtn + '' + + '' + ); + }); } function escapeHtml(text) { @@ -154,14 +465,23 @@ $(document).ready(function() { return $('
    ').text(text).html(); } - // Load history on page load - loadHistory(); + // Load dashboard on page load + loadDashboard(); - // Load retention days from settings - ajaxCall('/api/hclouddns/settings/get', {}, function(data) { - if (data && data.hclouddns && data.hclouddns.general) { - $('#retentionDays').text(data.hclouddns.general.historyRetentionDays || '7'); - } + // Filters trigger re-render + $('#dashboardRange').on('change', function() { + var days = parseInt($(this).val()) || 7; + loadStats(days); + renderHistoryTable(); // client-side re-filter, no extra API call + }); + + $('#dashboardAccount').on('change', function() { + renderHistoryTable(); // client-side re-filter + }); + + // Refresh button + $('#refreshDashboardBtn, #refreshHistoryBtn').on('click', function() { + loadDashboard(); }); // Revert history entry @@ -185,7 +505,7 @@ $(document).ready(function() { title: '{{ lang._("Change Reverted") }}', message: data.message || '{{ lang._("The DNS change has been reverted successfully.") }}' }); - loadHistory(); + loadDashboard(); } else { $btn.prop('disabled', false).html(''); BootstrapDialog.alert({ @@ -200,30 +520,35 @@ $(document).ready(function() { }); }); - // Refresh history button - $('#refreshHistoryBtn').click(function() { - loadHistory(); - }); - // Cleanup old history entries $('#cleanupHistoryBtn').click(function() { - var $btn = $(this).prop('disabled', true).html(' {{ lang._("Cleaning...") }}'); + BootstrapDialog.confirm({ + title: '{{ lang._("Cleanup History") }}', + message: '{{ lang._("Remove entries older than the configured retention period?") }}', + type: BootstrapDialog.TYPE_WARNING, + btnOKLabel: '{{ lang._("Cleanup") }}', + btnOKClass: 'btn-warning', + callback: function(result) { + if (!result) return; + var $btn = $('#cleanupHistoryBtn').prop('disabled', true).html(' {{ lang._("Cleaning...") }}'); - ajaxCall('/api/hclouddns/history/cleanup', {_: ''}, function(data) { - $btn.prop('disabled', false).html(' {{ lang._("Cleanup Old Entries") }}'); + ajaxCall('/api/hclouddns/history/cleanup', {_: ''}, function(data) { + $btn.prop('disabled', false).html(' {{ lang._("Cleanup Old Entries") }}'); - if (data && data.status === 'ok') { - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_SUCCESS, - title: '{{ lang._("Cleanup Complete") }}', - message: data.message || (data.deleted + ' {{ lang._("old entries removed.") }}') - }); - loadHistory(); - } else { - BootstrapDialog.alert({ - type: BootstrapDialog.TYPE_DANGER, - title: '{{ lang._("Cleanup Failed") }}', - message: data.message || '{{ lang._("Failed to cleanup history.") }}' + if (data && data.status === 'ok') { + BootstrapDialog.alert({ + type: BootstrapDialog.TYPE_SUCCESS, + title: '{{ lang._("Cleanup Complete") }}', + message: data.message || (data.deleted + ' {{ lang._("old entries removed.") }}') + }); + loadDashboard(); + } else { + BootstrapDialog.alert({ + type: BootstrapDialog.TYPE_DANGER, + title: '{{ lang._("Cleanup Failed") }}', + message: data.message || '{{ lang._("Failed to cleanup history.") }}' + }); + } }); } }); @@ -239,11 +564,10 @@ $(document).ready(function() { btnOKClass: 'btn-danger', callback: function(result) { if (result) { - // Call cleanup with 0 days to clear all ajaxCall('/api/hclouddns/history/clearAll', {_: ''}, function(data) { if (data && data.status === 'ok') { BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, message: data.message}); - loadHistory(); + loadDashboard(); } else { BootstrapDialog.alert({type: BootstrapDialog.TYPE_DANGER, message: data.message || '{{ lang._("Failed to clear history.") }}'}); } 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 294da0a44..f5a036027 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 @@ -24,6 +24,8 @@ .sim-gateway.up { border-left: 4px solid #5cb85c; } .sim-gateway.down { border-left: 4px solid #d9534f; } .sim-gateway.simulated { border-left: 4px solid #f0ad4e; background: #fff8e1; } + .sim-gateway.maintenance { border-left: 4px solid #f0ad4e; background: #fef9e7; } + .maint-schedule-info { font-size: 11px; color: #8a6d3b; background: #fcf8e3; border-radius: 3px; padding: 2px 6px; margin-top: 3px; display: inline-block; } .sim-gateway .gw-name { font-weight: 500; } .sim-gateway .gw-ip { font-size: 12px; color: #666; } .sim-gateway .gw-status { text-align: right; } @@ -601,6 +603,16 @@ $(document).ready(function() { }, true); }); + function formatDatetime(isoStr) { + if (!isoStr) return ''; + try { + var d = new Date(isoStr); + return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } catch(e) { + return isoStr; + } + } + function loadSimulationStatus() { // Load gateway status with simulation info ajaxCall('/api/hclouddns/gateways/status', {}, function(data) { @@ -613,26 +625,45 @@ $(document).ready(function() { var hasSimulation = false; $.each(data.gateways, function(uuid, gw) { gatewaysCache[uuid] = gw.name; - var statusClass = gw.simulated ? 'simulated' : (gw.status === 'up' ? 'up' : 'down'); + var statusClass = gw.maintenance ? 'maintenance' : (gw.simulated ? 'simulated' : (gw.status === 'up' ? 'up' : 'down')); if (gw.simulated) hasSimulation = true; var statusHtml = ''; - if (gw.simulated) { - statusHtml = 'Simulated Down'; + if (gw.maintenance) { + statusHtml = '{{ lang._("Maintenance") }}'; + } else if (gw.simulated) { + statusHtml = '{{ lang._("Simulated Down") }}'; } else if (gw.status === 'up') { - statusHtml = 'Up'; + statusHtml = '{{ lang._("Up") }}'; } else { - statusHtml = 'Down'; + statusHtml = '{{ lang._("Down") }}'; } - var btnHtml = gw.simulated - ? '' - : ''; + // Simulation buttons + var simBtnHtml = gw.simulated + ? '' + : ''; + + // Maintenance buttons + var maintBtnHtml = ''; + if (gw.maintenance) { + maintBtnHtml = ' '; + } else { + maintBtnHtml = ' '; + } + maintBtnHtml += ' '; + + // Scheduled maintenance info + var schedHtml = ''; + if (gw.maintenanceScheduled && gw.maintenanceStart && gw.maintenanceEnd) { + schedHtml = '
    {{ lang._("Scheduled") }}: ' + + formatDatetime(gw.maintenanceStart) + ' – ' + formatDatetime(gw.maintenanceEnd) + '
    '; + } $c.append( '
    ' + - '
    ' + gw.name + '
    ' + (gw.ipv4 || '-') + '
    ' + - '
    ' + statusHtml + ' ' + btnHtml + '
    ' + + '
    ' + gw.name + '
    ' + (gw.ipv4 || '-') + '
    ' + schedHtml + '
    ' + + '
    ' + statusHtml + ' ' + maintBtnHtml + simBtnHtml + '
    ' + '
    ' ); }); @@ -681,12 +712,17 @@ $(document).ready(function() { if (counts.hasOwnProperty(statusLower)) counts[statusLower]++; else counts.pending++; + var propagationBtn = ''; + if (e.currentIp) { + propagationBtn = ' '; + } + $tb.append( '' + '' + (e.enabled === '1' ? '' : '') + '' + '' + e.recordName + '.' + e.zoneName + '' + '' + e.recordType + '' + - '' + (e.currentIp || '-') + '' + + '' + (e.currentIp || '-') + propagationBtn + '' + '' + gwName + '' + '' + statusIcon + statusText + '' + '' @@ -807,6 +843,156 @@ $(document).ready(function() { }); }); + // ==================== MAINTENANCE HANDLERS ==================== + $(document).on('click', '.maint-start-btn', function() { + var uuid = $(this).data('uuid'); + var gwName = gatewaysCache[uuid] || uuid; + BootstrapDialog.show({ + title: "{{ lang._('Start Maintenance') }}", + message: "{{ lang._('Start maintenance mode for') }} " + gwName + "?
    " + + "{{ lang._('Gateway will be treated as down and entries will failover.') }}", + type: BootstrapDialog.TYPE_WARNING, + buttons: [{ + label: "{{ lang._('Cancel') }}", + action: function(dialogRef) { dialogRef.close(); } + }, { + label: "{{ lang._('Start Maintenance') }}", + cssClass: 'btn-warning', + action: function(dialogRef) { + dialogRef.close(); + ajaxCall('/api/hclouddns/service/maintenanceStart/' + uuid, {}, function(data) { + if (data && data.status === 'ok') { + loadSimulationStatus(); + $('#overviewEntryTable tbody').prepend( + '' + + ' {{ lang._("Updating DNS records...") }}' + ); + ajaxCall('/api/hclouddns/service/updateV2', {_: ''}, function() { + $('#updatingRow').remove(); + loadOverviewEntryStatus(); + }); + } + }); + } + }] + }); + }); + + $(document).on('click', '.maint-stop-btn', function() { + var uuid = $(this).data('uuid'); + ajaxCall('/api/hclouddns/service/maintenanceStop/' + uuid, {}, function(data) { + if (data && data.status === 'ok') { + loadSimulationStatus(); + $('#overviewEntryTable tbody').prepend( + '' + + ' {{ lang._("Failback: updating DNS records...") }}' + ); + ajaxCall('/api/hclouddns/service/updateV2', {_: ''}, function() { + $('#updatingRow').remove(); + loadOverviewEntryStatus(); + loadSimulationStatus(); + }); + } + }); + }); + + $(document).on('click', '.maint-schedule-btn', function() { + var uuid = $(this).data('uuid'); + var gwName = gatewaysCache[uuid] || uuid; + + var tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + var defStart = tomorrow.toISOString().slice(0, 11) + '02:00'; + var defEnd = tomorrow.toISOString().slice(0, 11) + '06:00'; + + BootstrapDialog.show({ + title: "{{ lang._('Schedule Maintenance') }}: " + gwName, + message: '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + ' {{ lang._("The gateway will automatically enter maintenance mode at the start time and exit at the end time. This requires the update cron job to be running.") }}' + + '
    ', + buttons: [{ + label: "{{ lang._('Cancel') }}", + action: function(dialog) { dialog.close(); } + }, { + label: "{{ lang._('Schedule') }}", + cssClass: 'btn-primary', + action: function(dialog) { + var start = $('#maintStart').val(); + var end = $('#maintEnd').val(); + if (!start || !end) { + alert("{{ lang._('Both start and end times are required.') }}"); + return; + } + if (new Date(start) >= new Date(end)) { + alert("{{ lang._('End time must be after start time.') }}"); + return; + } + ajaxCall('/api/hclouddns/service/maintenanceSchedule/' + uuid, { + start: start, + end: end + }, function(data) { + if (data && data.status === 'ok') { + dialog.close(); + BootstrapDialog.alert({ + type: BootstrapDialog.TYPE_SUCCESS, + title: "{{ lang._('Maintenance Scheduled') }}", + message: '' + gwName + '
    ' + + '{{ lang._("From") }}: ' + formatDatetime(start) + '
    ' + + '{{ lang._("Until") }}: ' + formatDatetime(end) + }); + loadSimulationStatus(); + } + }); + } + }] + }); + }); + + // ==================== PROPAGATION CHECK ==================== + $(document).on('click', '.propagation-check-btn', function() { + var uuid = $(this).data('uuid'); + var $btn = $(this); + $btn.prop('disabled', true).find('i').addClass('fa-spin'); + + ajaxCall('/api/hclouddns/service/propagationCheck/' + uuid, {}, function(data) { + $btn.prop('disabled', false).find('i').removeClass('fa-spin'); + + if (data && data.status === 'ok') { + var resultsHtml = ''; + if (data.results) { + $.each(data.results, function(ns, ip) { + resultsHtml += ' ' + ns + ': ' + ip + '
    '; + }); + } + if (data.errors) { + $.each(data.errors, function(ns, err) { + resultsHtml += ' ' + ns + ': ' + err + '
    '; + }); + } + + BootstrapDialog.show({ + title: "{{ lang._('DNS Propagation Check') }}", + message: '{{ lang._("Propagated") }}: ' + + (data.propagated ? '{{ lang._("Yes") }}' : '{{ lang._("Pending") }}') + + '

    ' + resultsHtml, + type: data.propagated ? BootstrapDialog.TYPE_SUCCESS : BootstrapDialog.TYPE_WARNING, + buttons: [{ + label: "{{ lang._('Close') }}", + action: function(dialog) { dialog.close(); } + }] + }); + } + }); + }); + $('#updateNowBtn').click(function() { var $btn = $(this).prop('disabled', true).html(' Updating...'); ajaxCall('/api/hclouddns/service/updateV2', {_: ''}, function(data) { 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 deleted file mode 100644 index a04c20814..000000000 --- a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt +++ /dev/null @@ -1,397 +0,0 @@ -{# - 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/scripts/HCloudDNS/create_zone.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_zone.py new file mode 100644 index 000000000..ebfcc5346 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_zone.py @@ -0,0 +1,70 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Create a new DNS zone 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_name + if len(sys.argv) < 3: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: create_zone.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_name = sys.argv[2].strip().lower() + + if not all([token, zone_name]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + # Basic domain name validation + if not all(c.isalnum() or c in '.-' for c in zone_name) or '.' not in zone_name: + print(json.dumps({ + 'status': 'error', + 'message': f'Invalid zone name: {zone_name}' + })) + sys.exit(1) + + api = HCloudAPI(token) + + try: + success, message, zone_id = api.create_zone(zone_name) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Zone {zone_name} created successfully', + 'zone_id': zone_id, + 'zone_name': zone_name + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to create zone: {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/dns_health_check.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/dns_health_check.py new file mode 100755 index 000000000..9a76c5b15 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/dns_health_check.py @@ -0,0 +1,488 @@ +#!/usr/local/bin/python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +DNS Health Check for HCloudDNS zones. +Checks NS delegation, SOA consistency, MX reachability, missing security records, +and CNAME at apex. +""" + +import json +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + +HETZNER_NAMESERVERS = [ + '213.133.100.98', # hydrogen.ns.hetzner.com + '88.198.229.192', # oxygen.ns.hetzner.com + '193.47.99.3', # helium.ns.hetzner.de +] + +HETZNER_NS_NAMES = [ + 'hydrogen.ns.hetzner.com', + 'oxygen.ns.hetzner.com', + 'helium.ns.hetzner.de', +] + + +def dns_query(fqdn, rdtype, nameserver=None, timeout=5): + """Query DNS records. Uses dnspython, falls back to drill.""" + results = [] + try: + import dns.resolver + import dns.rdatatype + + resolver = dns.resolver.Resolver(configure=False) + if nameserver: + resolver.nameservers = [nameserver] + resolver.lifetime = timeout + + try: + answer = resolver.resolve(fqdn, dns.rdatatype.from_text(rdtype)) + for rdata in answer: + results.append(str(rdata)) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, + dns.resolver.NoNameservers, dns.exception.Timeout): + pass + except ImportError: + import subprocess + cmd = ['drill'] + if nameserver: + cmd.append(f'@{nameserver}') + cmd.extend([fqdn, rdtype]) + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5) + if proc.returncode == 0: + in_answer = False + for line in proc.stdout.splitlines(): + if line.strip() == ';; ANSWER SECTION:': + in_answer = True + continue + if in_answer and line.strip() and not line.startswith(';;'): + parts = line.split() + if len(parts) >= 5: + results.append(parts[-1]) + elif in_answer and (line.startswith(';;') or not line.strip()): + break + except Exception: + pass + return results + + +def dns_query_full(fqdn, rdtype, nameserver=None, timeout=5): + """Query DNS returning full record strings (for SOA etc).""" + results = [] + try: + import dns.resolver + import dns.rdatatype + + resolver = dns.resolver.Resolver(configure=False) + if nameserver: + resolver.nameservers = [nameserver] + resolver.lifetime = timeout + + try: + answer = resolver.resolve(fqdn, dns.rdatatype.from_text(rdtype)) + for rdata in answer: + results.append(str(rdata)) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, + dns.resolver.NoNameservers, dns.exception.Timeout): + pass + except ImportError: + return dns_query(fqdn, rdtype, nameserver, timeout) + return results + + +def check_ns_delegation(zone_name): + """Check if NS delegation points to Hetzner nameservers.""" + ns_records = dns_query(zone_name, 'NS') + ns_lower = [ns.rstrip('.').lower() for ns in ns_records] + + hetzner_found = 0 + for hns in HETZNER_NS_NAMES: + if hns.lower() in ns_lower: + hetzner_found += 1 + + if hetzner_found >= 3: + return { + 'name': 'NS Delegation', + 'status': 'pass', + 'message': f'All {hetzner_found} Hetzner nameservers delegated', + 'details': ns_lower + } + elif hetzner_found > 0: + return { + 'name': 'NS Delegation', + 'status': 'warn', + 'message': f'Only {hetzner_found}/3 Hetzner nameservers found', + 'details': ns_lower + } + elif len(ns_records) > 0: + return { + 'name': 'NS Delegation', + 'status': 'warn', + 'message': f'NS records found but not pointing to Hetzner ({", ".join(ns_lower[:3])})', + 'details': ns_lower + } + else: + return { + 'name': 'NS Delegation', + 'status': 'fail', + 'message': 'No NS records found - domain may not be delegated', + 'details': [] + } + + +def check_soa_consistency(zone_name): + """Check if SOA serial is consistent across all nameservers.""" + serials = {} + for ns in HETZNER_NAMESERVERS: + soa_records = dns_query_full(zone_name, 'SOA', ns) + if soa_records: + parts = soa_records[0].split() + if len(parts) >= 3: + serials[ns] = parts[2] + + if not serials: + return { + 'name': 'SOA Consistency', + 'status': 'warn', + 'message': 'Could not query SOA from nameservers', + 'details': [] + } + + unique_serials = set(serials.values()) + if len(unique_serials) == 1: + serial = list(unique_serials)[0] + return { + 'name': 'SOA Consistency', + 'status': 'pass', + 'message': f'Serial {serial} consistent across {len(serials)} nameservers', + 'details': serials + } + else: + return { + 'name': 'SOA Consistency', + 'status': 'warn', + 'message': f'Serial mismatch: {", ".join(unique_serials)}', + 'details': serials + } + + +def check_mx_records(zone_name, records): + """Check if MX records exist and are resolvable.""" + mx_records = [r for r in records if r.get('type') == 'MX'] + if not mx_records: + return { + 'name': 'MX Records', + 'status': 'pass', + 'message': 'No MX records (domain may not handle email)', + 'details': [] + } + + resolvable = 0 + details = [] + for mx in mx_records: + value = mx.get('value', '') + parts = value.split() + hostname = parts[-1] if parts else value + hostname = hostname.rstrip('.') + + a_records = dns_query(hostname, 'A') + aaaa_records = dns_query(hostname, 'AAAA') + if a_records or aaaa_records: + resolvable += 1 + details.append(f'{hostname}: OK') + else: + details.append(f'{hostname}: unresolvable') + + if resolvable == len(mx_records): + return { + 'name': 'MX Records', + 'status': 'pass', + 'message': f'{len(mx_records)} MX record(s), all resolvable', + 'details': details + } + else: + return { + 'name': 'MX Records', + 'status': 'warn', + 'message': f'{resolvable}/{len(mx_records)} MX records resolvable', + 'details': details + } + + +def check_spf_record(records): + """Check if SPF record exists.""" + for r in records: + if r.get('type') == 'TXT': + val = r.get('value', '').strip().strip('"').strip("'") + if val.lower().startswith('v=spf1'): + return { + 'name': 'SPF Record', + 'status': 'pass', + 'message': 'SPF record found', + 'details': [val] + } + return { + 'name': 'SPF Record', + 'status': 'warn', + 'message': 'No SPF record found - email spoofing possible', + 'details': [] + } + + +def check_dmarc_record(zone_name, records): + """Check if DMARC record exists.""" + for r in records: + if r.get('type') == 'TXT' and r.get('name', '') == '_dmarc': + val = r.get('value', '').strip().strip('"').strip("'") + if val.lower().startswith('v=dmarc1'): + return { + 'name': 'DMARC Record', + 'status': 'pass', + 'message': 'DMARC record found', + 'details': [val] + } + return { + 'name': 'DMARC Record', + 'status': 'warn', + 'message': 'No DMARC record found - email authentication incomplete', + 'details': [] + } + + +def check_caa_record(records): + """Check if CAA record exists.""" + caa_records = [r for r in records if r.get('type') == 'CAA'] + if caa_records: + details = [r.get('value', '') for r in caa_records] + return { + 'name': 'CAA Record', + 'status': 'pass', + 'message': f'{len(caa_records)} CAA record(s) found', + 'details': details + } + return { + 'name': 'CAA Record', + 'status': 'warn', + 'message': 'No CAA record - any CA can issue certificates', + 'details': [] + } + + +def check_cname_at_apex(records): + """Check if there's a CNAME at the zone apex (invalid).""" + for r in records: + if r.get('type') == 'CNAME' and r.get('name', '') == '@': + return { + 'name': 'CNAME at Apex', + 'status': 'fail', + 'message': 'CNAME at zone apex detected - this breaks DNS!', + 'details': [r.get('value', '')] + } + return { + 'name': 'CNAME at Apex', + 'status': 'pass', + 'message': 'No CNAME at zone apex', + 'details': [] + } + + +def run_health_check(token, zone_id, zone_name=None): + """Run all health checks for a zone.""" + api = HCloudAPI(token) + ALL_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'SOA'] + records = api.list_records(zone_id, ALL_TYPES) + + if zone_name is None: + zones = api.list_zones() + for z in zones: + if z.get('id') == zone_id: + zone_name = z.get('name', zone_id) + break + else: + zone_name = zone_id + + checks = [] + checks.append(check_ns_delegation(zone_name)) + checks.append(check_soa_consistency(zone_name)) + checks.append(check_mx_records(zone_name, records)) + checks.append(check_spf_record(records)) + checks.append(check_dmarc_record(zone_name, records)) + checks.append(check_caa_record(records)) + checks.append(check_cname_at_apex(records)) + + # DNSSEC check + dnssec = check_dnssec(zone_name) + if dnssec['signed'] and dnssec['delegated']: + checks.append({ + 'name': 'DNSSEC', + 'status': 'pass', + 'message': f'DNSSEC active ({dnssec["dnskey_count"]} DNSKEY, DS delegated)', + 'details': dnssec['ds_records'] + }) + elif dnssec['signed']: + checks.append({ + 'name': 'DNSSEC', + 'status': 'warn', + 'message': 'Zone is signed but no DS record at parent (not fully delegated)', + 'details': [] + }) + else: + checks.append({ + 'name': 'DNSSEC', + 'status': 'warn', + 'message': 'DNSSEC not enabled (optional but recommended)', + 'details': [] + }) + + score = sum(1 for c in checks if c['status'] == 'pass') + max_score = len(checks) + + return { + 'status': 'ok', + 'zone': zone_name, + 'checks': checks, + 'score': score, + 'maxScore': max_score + } + + +def check_dnssec(zone_name): + """Check DNSSEC status for a zone via DNS queries.""" + result = { + 'signed': False, + 'delegated': False, + 'dnskey_count': 0, + 'ds_records': [] + } + + # Check for DNSKEY records (zone is signed) + dnskeys = dns_query(zone_name, 'DNSKEY') + if dnskeys: + result['signed'] = True + result['dnskey_count'] = len(dnskeys) + + # Check for DS records at parent (delegation) + ds_records = dns_query(zone_name, 'DS') + if ds_records: + result['delegated'] = True + result['ds_records'] = ds_records + + return result + + +def run_dnssec_check(zone_name): + """Run DNSSEC check for a zone.""" + dnssec = check_dnssec(zone_name) + + return { + 'status': 'ok', + 'zone': zone_name, + 'dnssec': dnssec + } + + +def run_propagation_check(token, zone_id): + """Check propagation for all records in a zone.""" + api = HCloudAPI(token) + ALL_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'] + records = api.list_records(zone_id, ALL_TYPES) + + zones = api.list_zones() + zone_name = zone_id + for z in zones: + if z.get('id') == zone_id: + zone_name = z.get('name', zone_id) + break + + results = [] + for rec in records: + rec_name = rec.get('name', '@') + rec_type = rec.get('type', 'A') + expected = rec.get('value', '') + + # Skip SOA and NS + if rec_type in ('SOA', 'NS'): + continue + + fqdn = f"{rec_name}.{zone_name}" if rec_name != '@' else zone_name + + ns_results = {} + for ns in HETZNER_NAMESERVERS: + answers = dns_query(fqdn, rec_type, ns) + if answers: + ns_results[ns] = answers[0] + else: + ns_results[ns] = None + + propagated = any( + val is not None and val.rstrip('.') == expected.rstrip('.') + for val in ns_results.values() + ) + # For TXT/MX records with quotes or complex values, be more lenient + if not propagated and rec_type in ('TXT', 'MX', 'CAA'): + propagated = any( + val is not None + for val in ns_results.values() + ) + + results.append({ + 'name': rec_name, + 'type': rec_type, + 'expected': expected, + 'nsResults': ns_results, + 'propagated': propagated + }) + + return { + 'status': 'ok', + 'zone': zone_name, + 'records': results, + 'total': len(results), + 'propagated': sum(1 for r in results if r['propagated']) + } + + +def main(): + if len(sys.argv) < 3: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: dns_health_check.py [zone_name]' + })) + sys.exit(1) + + mode = sys.argv[1].strip() + token = sys.argv[2].strip() + + if mode == 'health': + if len(sys.argv) < 4: + print(json.dumps({'status': 'error', 'message': 'zone_id required'})) + sys.exit(1) + zone_id = sys.argv[3].strip() + zone_name = sys.argv[4].strip() if len(sys.argv) > 4 else None + result = run_health_check(token, zone_id, zone_name) + elif mode == 'propagation': + if len(sys.argv) < 4: + print(json.dumps({'status': 'error', 'message': 'zone_id required'})) + sys.exit(1) + zone_id = sys.argv[3].strip() + result = run_propagation_check(token, zone_id) + elif mode == 'dnssec': + if len(sys.argv) < 4: + print(json.dumps({'status': 'error', 'message': 'zone_name required'})) + sys.exit(1) + zone_name = sys.argv[3].strip() + result = run_dnssec_check(zone_name) + else: + result = {'status': 'error', 'message': f'Unknown mode: {mode}'} + + print(json.dumps(result)) + + +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 index 04b983a73..c7354e324 100755 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py @@ -174,6 +174,97 @@ def is_valid_ip(ip): return False +HETZNER_NAMESERVERS = [ + '213.133.100.98', # hydrogen.ns.hetzner.com + '88.198.229.192', # oxygen.ns.hetzner.com + '193.47.99.3', # helium.ns.hetzner.de +] + + +def verify_dns_propagation(record_name, zone_name, record_type, expected_ip, + nameservers=None, timeout=5): + """Query authoritative Hetzner nameservers to verify DNS propagation. + + Uses dnspython (available on OPNsense via py-dnspython) for direct queries. + Falls back to drill (FreeBSD) if dnspython is not available. + + Returns: + dict with 'propagated' (bool), 'results' (ns->ip), 'errors' (ns->error) + """ + if nameservers is None: + nameservers = HETZNER_NAMESERVERS + + fqdn = f"{record_name}.{zone_name}" if record_name != '@' else zone_name + + results = {} + errors = {} + + # Try dnspython first (preferred, available on OPNsense) + try: + import dns.resolver + import dns.rdatatype + + rdtype = dns.rdatatype.from_text(record_type) + + for ns in nameservers: + try: + resolver = dns.resolver.Resolver(configure=False) + resolver.nameservers = [ns] + resolver.lifetime = timeout + + answer = resolver.resolve(fqdn, rdtype) + for rdata in answer: + results[ns] = str(rdata) + break # first answer + except dns.resolver.NXDOMAIN: + errors[ns] = 'NXDOMAIN' + except dns.resolver.NoAnswer: + errors[ns] = 'no answer' + except dns.resolver.NoNameservers: + errors[ns] = 'no nameservers' + except dns.exception.Timeout: + errors[ns] = 'timeout' + except Exception as e: + errors[ns] = str(e) + + except ImportError: + # Fallback to drill (available on FreeBSD/OPNsense via ldns) + for ns in nameservers: + try: + cmd = ['drill', f'@{ns}', fqdn, record_type] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5) + if proc.returncode == 0: + # Parse drill output: look for answer section + in_answer = False + for line in proc.stdout.splitlines(): + if line.strip() == ';; ANSWER SECTION:': + in_answer = True + continue + if in_answer and line.strip() and not line.startswith(';;'): + parts = line.split() + if len(parts) >= 5: + results[ns] = parts[-1] + break + elif in_answer and (line.startswith(';;') or not line.strip()): + break + if ns not in results: + errors[ns] = 'empty response' + else: + errors[ns] = proc.stderr.strip() or 'drill failed' + except subprocess.TimeoutExpired: + errors[ns] = 'timeout' + except Exception as e: + errors[ns] = str(e) + + propagated = any(ip == expected_ip for ip in results.values()) + + return { + 'propagated': propagated, + 'results': results, + 'errors': errors + } + + def get_opnsense_gateway_status(): """Query OPNsense's dpinger-based gateway status and gateway-to-interface mapping. @@ -383,6 +474,21 @@ def main(): print(json.dumps(result)) + elif action == 'propagation': + if len(sys.argv) < 6: + print(json.dumps({'status': 'error', + 'message': 'Usage: propagation '})) + sys.exit(1) + + record_name = sys.argv[2] + zone_name = sys.argv[3] + record_type = sys.argv[4] + expected_ip = sys.argv[5] + + result = verify_dns_propagation(record_name, zone_name, record_type, expected_ip) + result['status'] = 'ok' + print(json.dumps(result)) + else: print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'})) sys.exit(1) diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py index 9b3c7ccd7..99c7afe12 100755 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py @@ -67,6 +67,9 @@ class HCloudAPI: def list_zones(self): return self._api.list_zones() + def create_zone(self, zone_name): + return self._api.create_zone(zone_name) + def get_zone_id(self, zone_name): return self._api.get_zone_id(zone_name) 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 92b664af2..60f350e97 100644 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py @@ -163,6 +163,34 @@ class HetznerLegacyAPI: self._log(syslog.LOG_ERR, f"Failed to list zones: {str(e)}") return [] + def create_zone(self, zone_name): + """Create a new DNS zone. Returns (success, message, zone_id).""" + try: + response = self._request('POST', '/zones', json_data={'name': zone_name}) + + if response.status_code in [200, 201]: + data = response.json() + zone = data.get('zone', {}) + zone_id = zone.get('id', '') + if self.verbose: + self._log(syslog.LOG_INFO, f"Created zone {zone_name} (id={zone_id})") + return True, f"Zone {zone_name} created", zone_id + + 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 zone {zone_name}: {error_msg}") + return False, error_msg, None + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to create zone: {str(e)}") + return False, str(e), None + def get_zone_id(self, zone_name): """Get zone ID by zone name""" try: diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api_v2.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api_v2.py index 96a452e35..f4dc8b334 100644 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api_v2.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api_v2.py @@ -269,6 +269,34 @@ class HetznerCloudAPIv2: self._log(syslog.LOG_ERR, f"Failed to list zones: {str(e)}") return [] + def create_zone(self, zone_name): + """Create a new DNS zone. Returns (success, message, zone_id).""" + try: + response = self._request('POST', '/zones', json_data={'name': zone_name}) + + if response.status_code in [200, 201]: + data = response.json() + zone = data.get('zone', {}) + zone_id = zone.get('id', '') + if self.verbose: + self._log(syslog.LOG_INFO, f"v2: Created zone {zone_name} (id={zone_id})") + return True, f"Zone {zone_name} created", zone_id + + 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 zone {zone_name}: {error_msg}") + return False, error_msg, None + + except HetznerAPIError as e: + self._log(syslog.LOG_ERR, f"Failed to create zone: {str(e)}") + return False, str(e), None + def get_zone_id(self, zone_name): """Get zone ID by zone name.""" try: diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py index 0531529e7..efa579589 100755 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/read_history.py @@ -54,8 +54,91 @@ def read_history(): } +def read_stats(days=30): + """Aggregate statistics from history entries.""" + rows = [] + + if not os.path.exists(HISTORY_FILE): + return { + 'status': 'ok', + 'total': 0, 'creates': 0, 'updates': 0, 'deletes': 0, + 'reverted': 0, + 'byDate': {}, 'byZone': {}, 'byAccount': {}, + 'avgPerDay': 0 + } + + cutoff = time.time() - (days * 86400) + + try: + with open(HISTORY_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + rows.append(entry) + except json.JSONDecodeError: + continue + except IOError: + pass + + # Filter by time range + filtered = [r for r in rows if r.get('timestamp', 0) >= cutoff] + + total = len(filtered) + creates = sum(1 for r in filtered if r.get('action') == 'create') + updates = sum(1 for r in filtered if r.get('action') == 'update') + deletes = sum(1 for r in filtered if r.get('action') == 'delete') + reverted = sum(1 for r in filtered if r.get('reverted')) + + # Group by date + by_date = {} + for r in filtered: + date_str = time.strftime('%Y-%m-%d', time.localtime(r.get('timestamp', 0))) + if date_str not in by_date: + by_date[date_str] = {'create': 0, 'update': 0, 'delete': 0} + action = r.get('action', '') + if action in by_date[date_str]: + by_date[date_str][action] += 1 + + # Group by zone + by_zone = {} + for r in filtered: + zone = r.get('zoneName', 'Unknown') + by_zone[zone] = by_zone.get(zone, 0) + 1 + + # Group by account + by_account = {} + for r in filtered: + account = r.get('accountName', 'Unknown') + by_account[account] = by_account.get(account, 0) + 1 + + # Avg per day + unique_days = len(by_date) if by_date else 1 + avg_per_day = round(total / unique_days, 1) + + return { + 'status': 'ok', + 'total': total, + 'creates': creates, + 'updates': updates, + 'deletes': deletes, + 'reverted': reverted, + 'byDate': by_date, + 'byZone': by_zone, + 'byAccount': by_account, + 'avgPerDay': avg_per_day + } + + def main(): - result = read_history() + # Check for stats mode + if len(sys.argv) > 1 and sys.argv[1] == 'stats': + days = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + result = read_stats(days) + else: + result = read_history() print(json.dumps(result)) 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 765482235..b1c63ab53 100755 --- a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py @@ -18,7 +18,8 @@ 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, get_opnsense_gateway_status, is_gateway_up, write_state_file +from gateway_health import (get_gateway_ip, get_opnsense_gateway_status, is_gateway_up, + write_state_file, verify_dns_propagation) STATE_FILE = '/var/run/hclouddns_state.json' SIMULATION_FILE = '/var/run/hclouddns_simulation.json' @@ -405,7 +406,11 @@ def send_batch_notification(config, batch_results): 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}" + is_maintenance = first_fo.get('maintenance', False) + if is_maintenance: + title = f"HCloudDNS: Maintenance Failover {from_gw} -> {to_gw}" + else: + title = f"HCloudDNS: Failover {from_gw} -> {to_gw}" tags = 'warning,hclouddns' records_to_show = failovers @@ -471,6 +476,17 @@ def send_batch_notification(config, batch_results): lines.append(f"{e['record']}") lines.append(f" ✗ {e['error']}") + # Add propagation status if available + propagation = batch_results.get('propagation') + if propagation and propagation.get('total', 0) > 0: + lines.append("") + verified = propagation['verified'] + total = propagation['total'] + if verified == total: + lines.append(f"DNS propagated: {verified}/{total}") + else: + lines.append(f"DNS propagation pending: {verified}/{total}") + message = "\n".join(lines) # Send batch notification @@ -481,6 +497,7 @@ def send_batch_notification(config, batch_results): 'failovers': len(failovers), 'failbacks': len(failbacks), 'errors': len(errors), + 'propagation': propagation, 'details': batch_results }) @@ -608,6 +625,9 @@ def load_config(): config['forceInterval'] = int(general.findtext('forceInterval', '0')) config['carpAware'] = general.findtext('carpAware', '0') == '1' config['carpVhid'] = general.findtext('carpVhid', '') + config['propagationCheck'] = general.findtext('propagationCheck', '1') == '1' + config['propagationRetries'] = int(general.findtext('propagationRetries', '3')) + config['propagationDelay'] = int(general.findtext('propagationDelay', '2')) # Accounts (API tokens) accounts = hcloud.find('accounts') @@ -638,7 +658,11 @@ def load_config(): 'interface': gw.findtext('interface', ''), 'priority': int(gw.findtext('priority', '10')), 'checkipMethod': gw.findtext('checkipMethod', 'web_ipify'), - 'healthCheckTarget': gw.findtext('healthCheckTarget', '8.8.8.8') + 'healthCheckTarget': gw.findtext('healthCheckTarget', '8.8.8.8'), + 'maintenance': gw.findtext('maintenance', '0') == '1', + 'maintenanceScheduled': gw.findtext('maintenanceScheduled', '0') == '1', + 'maintenanceStart': gw.findtext('maintenanceStart', ''), + 'maintenanceEnd': gw.findtext('maintenanceEnd', '') } # Entries @@ -673,6 +697,7 @@ def load_config(): 'notifyOnFailover': notifications.findtext('notifyOnFailover', '1') == '1', 'notifyOnFailback': notifications.findtext('notifyOnFailback', '1') == '1', 'notifyOnError': notifications.findtext('notifyOnError', '1') == '1', + 'notifyOnMaintenance': notifications.findtext('notifyOnMaintenance', '0') == '1', 'emailEnabled': notifications.findtext('emailEnabled', '0') == '1', 'emailTo': notifications.findtext('emailTo', ''), 'emailFrom': notifications.findtext('emailFrom', ''), @@ -721,6 +746,64 @@ def save_runtime_state(state): log(f'Error saving state: {str(e)}', syslog.LOG_ERR) +def _is_in_maintenance_window(gw): + """Check if current time is within a scheduled maintenance window.""" + if not gw.get('maintenanceScheduled'): + return False + start_str = gw.get('maintenanceStart', '') + end_str = gw.get('maintenanceEnd', '') + if not start_str or not end_str: + return False + try: + from datetime import datetime + now = datetime.now() + start = datetime.fromisoformat(start_str) + end = datetime.fromisoformat(end_str) + return start <= now <= end + except (ValueError, TypeError): + return False + + +def _is_past_maintenance_window(gw): + """Check if current time is past a scheduled maintenance window end.""" + if not gw.get('maintenanceScheduled'): + return False + end_str = gw.get('maintenanceEnd', '') + if not end_str: + return False + try: + from datetime import datetime + now = datetime.now() + end = datetime.fromisoformat(end_str) + return now > end + except (ValueError, TypeError): + return False + + +def _clear_maintenance_in_config(uuid): + """Clear maintenance fields for a gateway in config.xml.""" + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + gateways = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways is not None: + for gw in gateways.findall('gateway'): + if gw.get('uuid') == uuid: + for field in ['maintenance', 'maintenanceScheduled', 'maintenanceStart', 'maintenanceEnd']: + node = gw.find(field) + if node is not None: + if field in ('maintenance', 'maintenanceScheduled'): + node.text = '0' + else: + node.text = '' + tree.write('/conf/config.xml', xml_declaration=True, encoding='UTF-8') + log(f"Cleared maintenance fields for gateway {uuid}") + return True + except Exception as e: + log(f"Failed to clear maintenance in config: {e}", syslog.LOG_ERR) + return False + + def check_all_gateways(config, state): """Check health and get IPs for all gateways""" simulation = load_simulation() @@ -738,7 +821,8 @@ def check_all_gateways(config, state): 'lastCheck': 0, 'failCount': 0, 'upSince': None, - 'simulated': False + 'simulated': False, + 'maintenance': False } gw_state = state['gateways'][uuid] @@ -748,6 +832,31 @@ def check_all_gateways(config, state): gw_state['ipv4'] = ip_result.get('ipv4') gw_state['ipv6'] = ip_result.get('ipv6') + # Check maintenance mode + in_maintenance = gw.get('maintenance', False) + + # Check scheduled maintenance window + if not in_maintenance and _is_in_maintenance_window(gw): + in_maintenance = True + + # Auto-clear expired scheduled maintenance + if gw.get('maintenanceScheduled') and _is_past_maintenance_window(gw): + log(f"Maintenance window expired for gateway '{gw['name']}', auto-clearing") + _clear_maintenance_in_config(uuid) + in_maintenance = False + gw['maintenance'] = False + gw['maintenanceScheduled'] = False + + gw_state['maintenance'] = in_maintenance + + if in_maintenance: + old_status = gw_state.get('status', 'unknown') + gw_state['status'] = 'maintenance' + # Don't increment failCount for maintenance (not a real failure) + if old_status not in ('maintenance', 'unknown'): + log(f"MAINTENANCE: Gateway '{gw['name']}' entering maintenance mode", syslog.LOG_WARNING) + continue + # 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 @@ -808,6 +917,11 @@ def determine_active_gateway(entry, config, state): primary_healthy = primary_state.get('status') == 'up' failover_healthy = failover_state.get('status') == 'up' + # Maintenance mode is treated as down for failover purposes + primary_in_maintenance = primary_state.get('status') == 'maintenance' + if primary_in_maintenance: + primary_healthy = False + # 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 @@ -849,7 +963,7 @@ def determine_active_gateway(entry, config, state): return None, None, 'no_gateway' -def update_dns_record(api, entry, target_ip, state, force=False, dry_run=False): +def update_dns_record(api, entry, target_ip, state, config=None, force=False, dry_run=False, skip_propagation=False): """Update DNS record at Hetzner""" zone_id = entry['zoneId'] record_name = entry['recordName'] @@ -872,27 +986,47 @@ def update_dns_record(api, entry, target_ip, state, force=False, dry_run=False): if force: log(f"Force-updating {record_name}.{entry['zoneName']} (interval expired)") else: - return True, 'unchanged' + return True, 'unchanged', None # Dry-run: report what would change without making API calls if dry_run: if current_value is None: - return True, 'would_create' - return True, 'would_update' + return True, 'would_create', None + return True, 'would_update', None # 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' + + # DNS propagation verification (skip during maintenance for faster failover) + propagation = None + if config and config.get('propagationCheck', False) and not skip_propagation: + retries = config.get('propagationRetries', 3) + delay = config.get('propagationDelay', 2) + for attempt in range(retries): + propagation = verify_dns_propagation( + record_name, entry['zoneName'], record_type, target_ip + ) + if propagation['propagated']: + log(f"DNS propagated for {record_name}.{entry['zoneName']} " + f"(attempt {attempt + 1}/{retries})") + break + if attempt < retries - 1: + time.sleep(delay) + if propagation and not propagation['propagated']: + log(f"DNS propagation pending for {record_name}.{entry['zoneName']} " + f"after {retries} attempts", syslog.LOG_WARNING) + + return True, 'updated', propagation else: log(f"DNS update failed for {record_name}.{entry['zoneName']}: {message}", syslog.LOG_ERR) - return False, message + return False, message, None except Exception as e: log(f"DNS update failed for {record_name}.{entry['zoneName']}: {str(e)}", syslog.LOG_ERR) - return False, str(e) + return False, str(e), None def _process_single_entry(entry, account, api, config, state, state_lock, dry_run=False): @@ -964,21 +1098,28 @@ def _process_single_entry(entry, account, api, config, state, state_lock, dry_ru old_gw_name = old_gw_config.get('name', old_active_gw[:8]) new_gw_name = active_gw.get('name', active_uuid[:8]) + # Determine if failover is due to maintenance + primary_state_status = state['gateways'].get(entry['primaryGateway'], {}).get('status', '') + is_maintenance_failover = primary_state_status == 'maintenance' + if reason == 'failover': - log(f"FAILOVER: {record_fqdn} switching from {old_gw_name} to {new_gw_name}") + failover_reason = 'maintenance' if is_maintenance_failover else 'primary_down' + log(f"FAILOVER: {record_fqdn} switching from {old_gw_name} to {new_gw_name} ({failover_reason})") result['failover_event'] = 'failover' + result['failover_reason'] = failover_reason result['failover_history'] = { 'timestamp': int(time.time()), 'entry': entry_uuid, 'from': old_active_gw, 'to': active_uuid, - 'reason': 'primary_down' + 'reason': failover_reason } 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_reason'] = 'failback' result['failover_history'] = { 'timestamp': int(time.time()), 'entry': entry_uuid, @@ -997,8 +1138,18 @@ def _process_single_entry(entry, account, api, config, state, state_lock, dry_ru if last_update_ts > 0 and (time.time() - last_update_ts) > config['forceInterval'] * 86400: force_update = True + # Skip propagation check during maintenance (faster failover/failback) + primary_maint_ended = state['gateways'].get(entry.get('primaryGateway', ''), {}).get('maintenanceEnded', 0) + is_maintenance_update = ( + result.get('failover_reason') == 'maintenance' or + (reason == 'failback' and primary_maint_ended and (time.time() - primary_maint_ended) < 120) + ) + # Update DNS (this is the slow network call - runs in parallel) - success, update_reason = update_dns_record(api, entry, target_ip, state, force=force_update, dry_run=dry_run) + success, update_reason, propagation = update_dns_record( + api, entry, target_ip, state, config=config, force=force_update, dry_run=dry_run, + skip_propagation=is_maintenance_update + ) # In dry-run mode, report what would happen without changing state if dry_run: @@ -1025,8 +1176,18 @@ def _process_single_entry(entry, account, api, config, state, state_lock, dry_ru entry_state['lastUpdate'] = int(time.time()) entry_state['status'] = 'active' if reason in ['primary', 'failback'] else 'failover' + # Store propagation status + if propagation is not None: + entry_state['propagated'] = propagation['propagated'] + entry_state['propagationResults'] = propagation['results'] + elif update_reason == 'unchanged': + pass # keep existing propagation state + else: + entry_state['propagated'] = None + if update_reason in ['updated', 'created']: result['updated'] = True + result['propagation'] = propagation # 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) @@ -1088,6 +1249,9 @@ def process_entries(config, state, dry_run=False): 'errors': [] } + # Track propagation results + propagation_results = [] + # Lock for thread-safe state access state_lock = threading.Lock() @@ -1136,8 +1300,9 @@ def process_entries(config, state, dry_run=False): with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit all tasks - future_to_entry = { - executor.submit( + future_to_entry = {} + for record_key, data in unique_entries.items(): + future = executor.submit( _process_single_entry, data['entry'], data['account'], @@ -1146,9 +1311,8 @@ def process_entries(config, state, dry_run=False): state, state_lock, dry_run - ): record_key - for record_key, data in unique_entries.items() - } + ) + future_to_entry[future] = record_key # Collect results as they complete for future in as_completed(future_to_entry): @@ -1160,8 +1324,8 @@ def process_entries(config, state, dry_run=False): if result.get('updated'): results['updated'] += 1 - if result.get('update_event'): - batch_events['updates'].append(result['update_event']) + if result.get('propagation') is not None: + propagation_results.append(result['propagation']) if result.get('error'): results['errors'] += 1 @@ -1169,20 +1333,52 @@ def process_entries(config, state, dry_run=False): if result.get('failover_event') == 'failover': results['failovers'] += 1 - if result.get('failover_notification'): - batch_events['failovers'].append(result['failover_notification']) + is_maintenance = result.get('failover_reason') == 'maintenance' + notify_maintenance = config.get('notifications', {}).get('notifyOnMaintenance') + + if is_maintenance and not notify_maintenance: + pass + else: + if result.get('update_event'): + if is_maintenance: + result['update_event']['maintenance'] = True + batch_events['updates'].append(result['update_event']) + if result.get('failover_notification'): + if is_maintenance: + result['failover_notification']['maintenance'] = True + 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']) + # Check if failback is from a recent maintenance stop + primary_gw_uuid = '' + for e in config.get('entries', []): + if e.get('recordName') == record_key[1] and e.get('recordType') == record_key[2]: + primary_gw_uuid = e.get('primaryGateway', '') + break + maint_ended = state['gateways'].get(primary_gw_uuid, {}).get('maintenanceEnded', 0) + is_maintenance_failback = maint_ended and (time.time() - maint_ended) < 120 + notify_maintenance = config.get('notifications', {}).get('notifyOnMaintenance') + + if is_maintenance_failback and not notify_maintenance: + pass + else: + if result.get('failover_notification'): + batch_events['failbacks'].append(result['failover_notification']) + if result.get('update_event'): + batch_events['updates'].append(result['update_event']) if result.get('failover_history'): with state_lock: state['failoverHistory'].append(result['failover_history']) + elif result.get('updated'): + # Non-failover, non-failback update (regular IP change) + if result.get('update_event'): + batch_events['updates'].append(result['update_event']) + except Exception as e: log(f"Error processing entry {record_key}: {str(e)}", syslog.LOG_ERR) results['errors'] += 1 @@ -1197,6 +1393,121 @@ def process_entries(config, state, dry_run=False): if len(state['failoverHistory']) > 100: state['failoverHistory'] = state['failoverHistory'][-100:] + # Check if maintenance started and all entries have failed over + # Send "Maintenance started" notification only after DNS failover is complete + maintenance_started_gateways = set() + if not dry_run: + for gw_uuid, gw_state_data in state.get('gateways', {}).items(): + maint_started_ts = gw_state_data.get('maintenanceStarted', 0) + if not maint_started_ts: + continue + if (time.time() - maint_started_ts) > 120: + gw_state_data.pop('maintenanceStarted', None) + continue + + # Check all entries that use this gateway as primary: + # have they ALL failed over to a different gateway? + all_failedover = True + has_entries = False + for entry in config['entries']: + if not entry['enabled'] or entry['status'] == 'paused': + continue + if entry.get('primaryGateway') != gw_uuid: + continue + has_entries = True + entry_state = state['entries'].get(entry['uuid'], {}) + active_gw = entry_state.get('activeGateway') + if not active_gw or active_gw == gw_uuid: + all_failedover = False + break + + if has_entries and all_failedover: + gw_name = config['gateways'].get(gw_uuid, {}).get('name', gw_uuid[:8]) + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + _send_maintenance_notification(root, gw_name, 'start') + except Exception as e: + log(f"Failed to send maintenance started notification: {e}", syslog.LOG_ERR) + gw_state_data.pop('maintenanceStarted', None) + maintenance_started_gateways.add(gw_uuid) + log(f"Maintenance fully started for gateway {gw_name}: all entries failed over") + + # Check if maintenance ended and all entries have failbacked + # Send "Maintenance ended" notification only after DNS is fully restored + maintenance_ended_gateways = set() + if not dry_run: + for gw_uuid, gw_state_data in state.get('gateways', {}).items(): + maint_ended_ts = gw_state_data.get('maintenanceEnded', 0) + if not maint_ended_ts: + continue + # Check if within 120s window (maintenance recently ended) + if (time.time() - maint_ended_ts) > 120: + # Too old, clear the flag + gw_state_data.pop('maintenanceEnded', None) + continue + + # Check all entries that use this gateway as primary: + # are they ALL back on primary (activeGateway == primaryGateway)? + all_failbacked = True + has_entries = False + for entry in config['entries']: + if not entry['enabled'] or entry['status'] == 'paused': + continue + if entry.get('primaryGateway') != gw_uuid: + continue + has_entries = True + entry_state = state['entries'].get(entry['uuid'], {}) + active_gw = entry_state.get('activeGateway') + if active_gw and active_gw != gw_uuid: + all_failbacked = False + break + + if has_entries and all_failbacked: + # All entries are back on primary - send "Maintenance ended" notification + gw_name = config['gateways'].get(gw_uuid, {}).get('name', gw_uuid[:8]) + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + _send_maintenance_notification(root, gw_name, 'stop') + except Exception as e: + log(f"Failed to send maintenance ended notification: {e}", syslog.LOG_ERR) + # Clear the flag so we don't send again + gw_state_data.pop('maintenanceEnded', None) + maintenance_ended_gateways.add(gw_uuid) + log(f"Maintenance fully ended for gateway {gw_name}: all entries restored") + + # Remove failover/failback/update events from batch that are already covered + # by the maintenance start/end notification to avoid duplicate notifications + maint_notified_gateways = maintenance_started_gateways | maintenance_ended_gateways + if maint_notified_gateways: + maint_records = set() + for entry in config.get('entries', []): + if entry.get('primaryGateway') in maint_notified_gateways: + fqdn = entry.get('recordName', '') + '.' + entry.get('zoneName', '') + maint_records.add(fqdn) + batch_events['failovers'] = [ + fo for fo in batch_events['failovers'] + if fo.get('record') not in maint_records + ] + batch_events['failbacks'] = [ + fb for fb in batch_events['failbacks'] + if fb.get('record') not in maint_records + ] + batch_events['updates'] = [ + u for u in batch_events['updates'] + if u.get('record') not in maint_records + ] + + # Count propagation results + propagated_count = sum(1 for r in propagation_results if r.get('propagated')) + total_propagation = len(propagation_results) + if total_propagation > 0: + batch_events['propagation'] = { + 'verified': propagated_count, + 'total': total_propagation + } + # Send single batch notification with all changes (skip in dry-run) if not dry_run: send_batch_notification(config, batch_events) @@ -1205,10 +1516,193 @@ def process_entries(config, state, dry_run=False): return results +def _update_gateway_state(uuid, maintenance): + """Update maintenance flag in the runtime state file immediately.""" + try: + state = {} + if os.path.exists(STATE_FILE): + with open(STATE_FILE, 'r') as f: + state = json.load(f) + if 'gateways' not in state: + state['gateways'] = {} + if uuid not in state['gateways']: + state['gateways'][uuid] = {} + state['gateways'][uuid]['maintenance'] = maintenance + if maintenance: + state['gateways'][uuid]['status'] = 'maintenance' + state['gateways'][uuid].pop('maintenanceEnded', None) + state['gateways'][uuid]['maintenanceStarted'] = int(time.time()) + else: + state['gateways'][uuid]['status'] = 'up' + state['gateways'][uuid]['maintenanceEnded'] = int(time.time()) + write_state_file(STATE_FILE, state) + except Exception as e: + log(f"Failed to update gateway state: {e}", syslog.LOG_ERR) + + +def _get_gateway_name(root, uuid): + """Get gateway name from config.xml by UUID.""" + gateways = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways is not None: + for gw in gateways.findall('gateway'): + if gw.get('uuid') == uuid: + return gw.findtext('name', uuid[:8]) + return uuid[:8] + + +def _send_maintenance_notification(root, gw_name, action): + """Send a maintenance mode notification (start/stop).""" + try: + notifications = root.find('.//OPNsense/HCloudDNS/notifications') + if notifications is None: + return + if notifications.findtext('enabled', '0') != '1': + return + + settings = { + 'ntfyEnabled': notifications.findtext('ntfyEnabled', '0') == '1', + 'ntfyServer': notifications.findtext('ntfyServer', 'https://ntfy.sh'), + 'ntfyTopic': notifications.findtext('ntfyTopic', ''), + 'ntfyPriority': notifications.findtext('ntfyPriority', 'default'), + 'emailEnabled': notifications.findtext('emailEnabled', '0') == '1', + 'emailTo': notifications.findtext('emailTo', ''), + 'emailFrom': notifications.findtext('emailFrom', ''), + 'smtpServer': notifications.findtext('smtpServer', ''), + 'smtpPort': notifications.findtext('smtpPort', '587'), + 'smtpTls': notifications.findtext('smtpTls', 'starttls'), + 'smtpUser': notifications.findtext('smtpUser', ''), + 'smtpPassword': notifications.findtext('smtpPassword', ''), + 'webhookEnabled': notifications.findtext('webhookEnabled', '0') == '1', + 'webhookUrl': notifications.findtext('webhookUrl', ''), + 'webhookMethod': notifications.findtext('webhookMethod', 'POST'), + 'webhookSecret': notifications.findtext('webhookSecret', ''), + } + + if action == 'start': + title = f"Maintenance: {gw_name}" + message = (f"Gateway '{gw_name}' is entering maintenance mode.\n" + f"DNS entries will failover to backup gateways.\n" + f"This is a planned maintenance - no action required.") + tags = 'wrench,maintenance' + else: + title = f"Maintenance ended: {gw_name}" + message = (f"Gateway '{gw_name}' has exited maintenance mode.\n" + f"DNS entries will failback to primary gateway.") + tags = 'white_check_mark,maintenance' + + send_ntfy(settings, title, message, tags) + send_email(settings, title, message) + send_webhook(settings, f'maintenance_{action}', { + 'gateway': gw_name, + 'action': action + }) + log(f"Sent maintenance {action} notification for {gw_name}") + except Exception as e: + log(f"Failed to send maintenance notification: {e}", syslog.LOG_ERR) + + +def handle_maintenance_start(uuid): + """Set maintenance=1 for a gateway in config.xml and update runtime state.""" + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + gateways = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways is not None: + for gw in gateways.findall('gateway'): + if gw.get('uuid') == uuid: + maint = gw.find('maintenance') + if maint is None: + maint = ET.SubElement(gw, 'maintenance') + maint.text = '1' + tree.write('/conf/config.xml', xml_declaration=True, encoding='UTF-8') + _update_gateway_state(uuid, True) + gw_name = gw.findtext('name', uuid[:8]) + # Don't send notification here - it will be sent after + # the update cycle confirms all entries have failed over + log(f"Maintenance started for gateway {gw_name} ({uuid})") + return {'status': 'ok', 'message': 'Maintenance mode started'} + return {'status': 'error', 'message': 'Gateway not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + +def handle_maintenance_stop(uuid): + """Clear maintenance for a gateway, update runtime state and send notification.""" + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + gw_name = _get_gateway_name(root, uuid) + except Exception: + gw_name = uuid[:8] + root = None + + result = _clear_maintenance_in_config(uuid) + if result: + _update_gateway_state(uuid, False) + # Don't send "Maintenance ended" notification here - it will be sent + # after the update cycle confirms all entries have failbacked + log(f"Maintenance stopped for gateway {gw_name} ({uuid})") + return {'status': 'ok', 'message': 'Maintenance mode stopped'} + return {'status': 'error', 'message': 'Failed to clear maintenance'} + + +def handle_maintenance_schedule(uuid, start, end): + """Set scheduled maintenance window for a gateway.""" + try: + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + gateways = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways is not None: + for gw in gateways.findall('gateway'): + if gw.get('uuid') == uuid: + for field, value in [('maintenanceScheduled', '1'), + ('maintenanceStart', start), + ('maintenanceEnd', end)]: + node = gw.find(field) + if node is None: + node = ET.SubElement(gw, field) + node.text = value + tree.write('/conf/config.xml', xml_declaration=True, encoding='UTF-8') + log(f"Maintenance scheduled for gateway {uuid}: {start} to {end}") + return {'status': 'ok', 'message': f'Maintenance scheduled: {start} to {end}'} + return {'status': 'error', 'message': 'Gateway not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def main(): parser = argparse.ArgumentParser(description='HCloudDNS DNS updater') parser.add_argument('--dry-run', action='store_true', help='Preview changes without making API calls') - args = parser.parse_args() + parser.add_argument('--maintenance-start', action='store_true', help='Start maintenance mode for gateway') + parser.add_argument('--maintenance-stop', action='store_true', help='Stop maintenance mode for gateway') + parser.add_argument('--maintenance-schedule', action='store_true', help='Schedule maintenance window') + args, remaining = parser.parse_known_args() + + # Handle maintenance commands + if args.maintenance_start: + if not remaining: + print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'})) + return + result = handle_maintenance_start(remaining[0]) + print(json.dumps(result)) + return + + if args.maintenance_stop: + if not remaining: + print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'})) + return + result = handle_maintenance_stop(remaining[0]) + print(json.dumps(result)) + return + + if args.maintenance_schedule: + if len(remaining) < 3: + print(json.dumps({'status': 'error', 'message': 'Usage: --maintenance-schedule UUID START END'})) + return + result = handle_maintenance_schedule(remaining[0], remaining[1], remaining[2]) + print(json.dumps(result)) + return + dry_run = args.dry_run result = { diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_export.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_export.py new file mode 100755 index 000000000..187491de5 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_export.py @@ -0,0 +1,104 @@ +#!/usr/local/bin/python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Export DNS zone in BIND format for HCloudDNS. +""" + +import json +import sys +import os +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + +ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'SOA'] + + +def export_zone(token, zone_id): + """Export zone as BIND-format zonefile.""" + api = HCloudAPI(token) + + zones = api.list_zones() + zone_name = zone_id + for z in zones: + if z.get('id') == zone_id: + zone_name = z.get('name', zone_id) + break + + records = api.list_records(zone_id, ALL_RECORD_TYPES) + + lines = [] + lines.append(f'; Zone: {zone_name}') + lines.append(f'; Exported: {time.strftime("%Y-%m-%d %H:%M:%S")}') + lines.append(f'; Records: {len(records)}') + lines.append(f'$ORIGIN {zone_name}.') + lines.append('') + + # Sort records: SOA first, then NS, then by type and name + type_order = {'SOA': 0, 'NS': 1, 'A': 2, 'AAAA': 3, 'CNAME': 4, 'MX': 5, 'TXT': 6, 'SRV': 7, 'CAA': 8} + + records.sort(key=lambda r: ( + type_order.get(r.get('type', ''), 99), + r.get('name', '') + )) + + for rec in records: + name = rec.get('name', '@') + rtype = rec.get('type', 'A') + value = rec.get('value', '') + ttl = rec.get('ttl', 300) + + # Format name: pad to 16 chars + display_name = name if name != '@' else '@' + display_name = display_name.ljust(16) + + # Format value based on type + if rtype == 'TXT': + # Ensure TXT values are quoted + if not value.startswith('"'): + value = f'"{value}"' + elif rtype == 'CNAME' or rtype == 'NS' or rtype == 'MX': + # Add trailing dot if not present + if value and not value.endswith('.') and not value.endswith('. '): + # For MX: priority hostname. + parts = value.split() + if rtype == 'MX' and len(parts) == 2: + if not parts[1].endswith('.'): + value = f'{parts[0]} {parts[1]}.' + elif rtype != 'MX' and not value.endswith('.'): + value = value + '.' + + lines.append(f'{display_name} {ttl}\tIN\t{rtype}\t{value}') + + content = '\n'.join(lines) + '\n' + filename = f'{zone_name}.zone' + + return { + 'status': 'ok', + 'content': content, + 'filename': filename, + 'zone': zone_name, + 'recordCount': len(records) + } + + +def main(): + if len(sys.argv) < 3: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: zone_export.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + + result = export_zone(token, zone_id) + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_import.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_import.py new file mode 100755 index 000000000..152fcb0c2 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/zone_import.py @@ -0,0 +1,188 @@ +#!/usr/local/bin/python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Parse BIND zonefile for importing into HCloudDNS. +Only parses - does not create records. Frontend handles selection and creation. +""" + +import json +import re +import sys + + +def parse_zonefile(content): + """Parse BIND-format zonefile content into records.""" + records = [] + origin = '' + default_ttl = 300 + + for line in content.split('\n'): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith(';'): + continue + + # Handle $ORIGIN + if line.upper().startswith('$ORIGIN'): + origin = line.split(None, 1)[1].rstrip('.') if len(line.split(None, 1)) > 1 else '' + continue + + # Handle $TTL + if line.upper().startswith('$TTL'): + try: + default_ttl = int(line.split(None, 1)[1]) + except (ValueError, IndexError): + pass + continue + + # Skip other directives + if line.startswith('$'): + continue + + # Parse record line + record = parse_record_line(line, default_ttl) + if record: + records.append(record) + + return records + + +def parse_record_line(line, default_ttl): + """Parse a single DNS record line.""" + # Remove inline comments + if ';' in line and '"' not in line.split(';')[0]: + line = line.split(';')[0].strip() + elif ';' in line: + # Handle TXT records with semicolons inside quotes + in_quotes = False + clean = [] + for char in line: + if char == '"': + in_quotes = not in_quotes + if char == ';' and not in_quotes: + break + clean.append(char) + line = ''.join(clean).strip() + + if not line: + return None + + # Tokenize respecting quoted strings + tokens = tokenize(line) + if len(tokens) < 3: + return None + + name = '' + ttl = default_ttl + rclass = 'IN' + rtype = '' + value = '' + + idx = 0 + + # First token: name or empty (continuation) + if tokens[0] not in ('IN', 'CH', 'HS') and not is_record_type(tokens[0]) and not tokens[0].isdigit(): + name = tokens[0] + idx = 1 + else: + name = '@' + + # Next: optional TTL + if idx < len(tokens) and tokens[idx].isdigit(): + ttl = int(tokens[idx]) + idx += 1 + + # Next: optional class + if idx < len(tokens) and tokens[idx].upper() in ('IN', 'CH', 'HS'): + rclass = tokens[idx].upper() + idx += 1 + + # Next: record type + if idx < len(tokens) and is_record_type(tokens[idx]): + rtype = tokens[idx].upper() + idx += 1 + else: + return None + + # Rest is the value + value = ' '.join(tokens[idx:]) + + # Clean up TXT values + if rtype == 'TXT' and value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + # Clean trailing dots from hostnames + if rtype in ('CNAME', 'NS') and value.endswith('.'): + value = value[:-1] + + # Clean name + if name.endswith('.'): + name = name[:-1] + + if not rtype or not value: + return None + + return { + 'name': name, + 'type': rtype, + 'value': value, + 'ttl': ttl + } + + +def tokenize(line): + """Split line into tokens, respecting quoted strings.""" + tokens = [] + current = [] + in_quotes = False + + for char in line: + if char == '"': + in_quotes = not in_quotes + current.append(char) + elif char in (' ', '\t') and not in_quotes: + if current: + tokens.append(''.join(current)) + current = [] + else: + current.append(char) + + if current: + tokens.append(''.join(current)) + + return tokens + + +def is_record_type(token): + """Check if token is a known DNS record type.""" + return token.upper() in ( + 'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', + 'SOA', 'PTR', 'TLSA', 'DNSKEY', 'DS', 'NAPTR', 'SSHFP' + ) + + +def main(): + # Read zonefile content from stdin + content = sys.stdin.read() + + if not content.strip(): + print(json.dumps({ + 'status': 'error', + 'message': 'No zonefile content provided' + })) + sys.exit(1) + + records = parse_zonefile(content) + + print(json.dumps({ + 'status': 'ok', + 'records': records, + 'count': len(records) + })) + + +if __name__ == '__main__': + main() 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 index 9166604a2..58564cb9e 100644 --- a/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf +++ b/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf @@ -106,6 +106,12 @@ parameters: type:script_output message:Getting simulation status +[dns.createzone] +command:/usr/local/opnsense/scripts/HCloudDNS/create_zone.py +parameters:%s %s +type:script_output +message:Creating DNS zone at Hetzner + [dns.create] command:/usr/local/opnsense/scripts/HCloudDNS/create_record.py parameters:%s %s %s %s %s %s @@ -130,12 +136,72 @@ parameters:%s type:script_output message:Testing notification channels +[maintenance.start] +command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-start +parameters:%s +type:script_output +message:Starting gateway maintenance mode + +[maintenance.stop] +command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-stop +parameters:%s +type:script_output +message:Stopping gateway maintenance mode + +[maintenance.schedule] +command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py --maintenance-schedule +parameters:%s %s %s +type:script_output +message:Scheduling gateway maintenance window + +[propagation.check] +command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py propagation +parameters:%s %s %s %s +type:script_output +message:Checking DNS propagation + +[dns.export] +command:/usr/local/opnsense/scripts/HCloudDNS/zone_export.py +parameters:%s %s +type:script_output +message:Export DNS zone + +[dns.parseimport] +command:/usr/local/opnsense/scripts/HCloudDNS/zone_import.py +parameters: +type:script_output +message:Parse zone import + +[dns.healthcheck] +command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py health +parameters:%s %s %s +type:script_output +message:DNS health check + +[dns.propagation] +command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py propagation +parameters:%s %s +type:script_output +message:DNS propagation check + +[dns.dnssec] +command:/usr/local/opnsense/scripts/HCloudDNS/dns_health_check.py dnssec +parameters:%s %s +type:script_output +message:Checking DNSSEC status + [history.search] command:/usr/local/opnsense/scripts/HCloudDNS/read_history.py parameters: type:script_output message:Reading DNS change history +[history.stats] +command:/usr/local/opnsense/scripts/HCloudDNS/read_history.py stats +parameters:%s +type:script_output +message:Getting history statistics + [history.cleanup] command:/usr/local/opnsense/scripts/HCloudDNS/manage_history.py cleanup parameters:%s