diff --git a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py
index 68074597ee..aa07e2b44c 100644
--- a/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py
+++ b/dns/ddclient/src/opnsense/scripts/ddclient/lib/account/hetzner.py
@@ -144,34 +144,26 @@ def _get_record(self, headers, zone_id, record_name, record_type):
return None
def _update_record(self, headers, zone_id, record_name, record_type, address):
- """Update existing record with new address"""
- url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}"
-
- data = {
- 'records': [{'value': str(address)}],
- 'ttl': int(self.settings.get('ttl', 300))
- }
+ """Update existing record with new address
- response = requests.put(url, headers=headers, json=data)
+ NOTE: Hetzner Cloud API has a bug where PUT returns 200 but doesn't update.
+ Workaround: DELETE old record, then POST new record.
+ """
+ # DELETE old record first
+ delete_url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}"
+ delete_response = requests.delete(delete_url, headers=headers)
- if response.status_code != 200:
+ if delete_response.status_code not in [200, 201, 204]:
syslog.syslog(
syslog.LOG_ERR,
- "Account %s error updating record: HTTP %d - %s" % (
- self.description, response.status_code, response.text
+ "Account %s error deleting record for update: HTTP %d - %s" % (
+ self.description, delete_response.status_code, delete_response.text
)
)
return False
- if self.is_verbose:
- syslog.syslog(
- syslog.LOG_NOTICE,
- "Account %s updated %s %s to %s" % (
- self.description, record_name, record_type, address
- )
- )
-
- return True
+ # CREATE new record
+ return self._create_record(headers, zone_id, record_name, record_type, address)
def _create_record(self, headers, zone_id, record_name, record_type, address):
"""Create new record"""
diff --git a/net/hclouddns/Makefile b/net/hclouddns/Makefile
new file mode 100644
index 0000000000..30dc093609
--- /dev/null
+++ b/net/hclouddns/Makefile
@@ -0,0 +1,8 @@
+PLUGIN_NAME= hclouddns
+PLUGIN_VERSION= 2.0.0
+PLUGIN_COMMENT= Hetzner Cloud DNS Management with Multi-Zone and Failover
+PLUGIN_MAINTAINER= info@arcan-it.de
+PLUGIN_WWW= https://github.com/ArcanConsulting/os-hclouddns
+PLUGIN_DEPENDS= python311
+
+.include "../../Mk/plugins.mk"
diff --git a/net/hclouddns/pkg-descr b/net/hclouddns/pkg-descr
new file mode 100644
index 0000000000..3a34c9e81e
--- /dev/null
+++ b/net/hclouddns/pkg-descr
@@ -0,0 +1,16 @@
+Hetzner Cloud DNS Management Plugin for OPNsense
+
+Features:
+- Multi-account support (multiple Hetzner API tokens)
+- Multi-zone DNS management
+- Dynamic DNS with automatic failover between WAN interfaces
+- IPv4 and IPv6 (Dual-Stack) support
+- DNS record templates for quick setup
+- Direct DNS management (view/edit/delete records)
+- Change history with undo functionality
+- Notifications (Email, Webhook, Ntfy)
+- Configuration backup/restore
+
+Supports both Hetzner Cloud API and legacy DNS Console API.
+
+WWW: https://github.com/ArcanConsulting/os-hclouddns
diff --git a/net/hclouddns/pkg-plist b/net/hclouddns/pkg-plist
new file mode 100644
index 0000000000..f14463d2da
--- /dev/null
+++ b/net/hclouddns/pkg-plist
@@ -0,0 +1,51 @@
+etc/inc/plugins.inc.d/hclouddns.inc
+etc/rc.syshook.d/monitor/50-hclouddns
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php
+opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php
+opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml
+opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php
+opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.xml
+opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml
+opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt
+opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt
+opnsense/scripts/ddclient/lib/account/hetzner_cloud.py
+opnsense/scripts/ddclient/lib/account/hetzner_legacy.py
+opnsense/scripts/HCloudDNS/create_record.py
+opnsense/scripts/HCloudDNS/delete_record.py
+opnsense/scripts/HCloudDNS/gateway_health.py
+opnsense/scripts/HCloudDNS/get_hetzner_ip.py
+opnsense/scripts/HCloudDNS/hcloud_api.py
+opnsense/scripts/HCloudDNS/lib/__init__.py
+opnsense/scripts/HCloudDNS/lib/hetzner_api.py
+opnsense/scripts/HCloudDNS/list_records.py
+opnsense/scripts/HCloudDNS/list_zones.py
+opnsense/scripts/HCloudDNS/refresh_status.py
+opnsense/scripts/HCloudDNS/simulate_failover.py
+opnsense/scripts/HCloudDNS/status.py
+opnsense/scripts/HCloudDNS/test_notify.py
+opnsense/scripts/HCloudDNS/update_record.py
+opnsense/scripts/HCloudDNS/update_records.py
+opnsense/scripts/HCloudDNS/update_records_v2.py
+opnsense/scripts/HCloudDNS/validate_token.py
+opnsense/service/conf/actions.d/actions_hclouddns.conf
diff --git a/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc
new file mode 100644
index 0000000000..f13564995b
--- /dev/null
+++ b/net/hclouddns/src/etc/inc/plugins.inc.d/hclouddns.inc
@@ -0,0 +1,135 @@
+general->enabled == '1') {
+ $services[] = array(
+ 'description' => gettext('Hetzner Cloud Dynamic DNS'),
+ 'configd' => array(
+ 'restart' => array('hclouddns update'),
+ ),
+ 'name' => 'hclouddns',
+ );
+ }
+
+ return $services;
+}
+
+/**
+ * Register cron jobs for HCloudDNS
+ * Only active when explicitly enabled - automatic triggers (gateway syshook, newwanip)
+ * handle most use cases without needing scheduled updates.
+ * @return array
+ */
+function hclouddns_cron()
+{
+ $jobs = [];
+
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ // Cron is only registered when both service AND cron are enabled
+ if ((string)$mdl->general->enabled == '1' && (string)$mdl->general->cronEnabled == '1') {
+ // Use cronInterval setting (in minutes) - cast to string first as model fields are objects
+ $minutes = intval((string)$mdl->general->cronInterval);
+ if (empty($minutes) || $minutes < 1) {
+ $minutes = 5; // Default 5 minutes
+ }
+ if ($minutes > 60) {
+ $minutes = 60;
+ }
+
+ // autocron format: [command, minute, hour, monthday, month, weekday]
+ $jobs[]['autocron'] = [
+ '/usr/local/sbin/configctl hclouddns update',
+ "*/{$minutes}"
+ ];
+ }
+
+ return $jobs;
+}
+
+/**
+ * Register plugin hooks - triggers on interface IP changes
+ * @return array
+ */
+function hclouddns_configure()
+{
+ return [
+ 'newwanip' => ['hclouddns_configure_do:2'],
+ ];
+}
+
+/**
+ * Called when WAN IP changes - trigger DNS update
+ * @param bool $verbose
+ */
+function hclouddns_configure_do($verbose = false)
+{
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ if ((string)$mdl->general->enabled != '1') {
+ return;
+ }
+
+ service_log('Hetzner Cloud DDNS: Interface IP changed, updating DNS...', $verbose);
+
+ // Trigger update via configd
+ configd_run('hclouddns update');
+
+ service_log("done.\n", $verbose);
+}
+
+/**
+ * Register syslog facility
+ * @return array
+ */
+function hclouddns_syslog()
+{
+ $logfacilities = [];
+ $logfacilities['hclouddns'] = ['facility' => ['hclouddns']];
+ return $logfacilities;
+}
+
+/**
+ * XML-RPC sync handler
+ * @return array
+ */
+function hclouddns_xmlrpc_sync()
+{
+ $result = array();
+ $result['id'] = 'hclouddns';
+ $result['section'] = 'OPNsense.HCloudDNS';
+ $result['description'] = gettext('Hetzner Cloud Dynamic DNS');
+ return array($result);
+}
diff --git a/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns b/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns
new file mode 100644
index 0000000000..2f10e00944
--- /dev/null
+++ b/net/hclouddns/src/etc/rc.syshook.d/monitor/50-hclouddns
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+#
+# Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
+# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+# HCloudDNS Gateway Monitor Syshook
+# Called by rc.routing_configure when gateway status changes
+# Arguments: $1 = comma-separated list of gateway names that triggered the alarm
+
+GATEWAYS="${1}"
+
+# Log the gateway alarm
+logger -t hclouddns "Gateway alarm triggered for: ${GATEWAYS}"
+
+# Trigger async DNS update via configd (non-blocking)
+# The -d flag runs the command detached so we don't block the routing reconfigure
+/usr/local/sbin/configctl -d hclouddns update
+
+exit 0
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php
new file mode 100644
index 0000000000..101b365311
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/AccountsController.php
@@ -0,0 +1,215 @@
+searchBase(
+ 'accounts.account',
+ ['enabled', 'name', 'apiType', 'description'],
+ 'name'
+ );
+ }
+
+ /**
+ * Get single account
+ * @param string $uuid
+ * @return array
+ */
+ public function getItemAction($uuid = null)
+ {
+ return $this->getBase('account', 'accounts.account', $uuid);
+ }
+
+ /**
+ * Check if token already exists in another account
+ * @param string $token the token to check
+ * @param string $excludeUuid optional UUID to exclude (for updates)
+ * @return string|null account name if duplicate found, null otherwise
+ */
+ private function findDuplicateToken($token, $excludeUuid = null)
+ {
+ if (empty($token)) {
+ return null;
+ }
+
+ $mdl = $this->getModel();
+ foreach ($mdl->accounts->account->iterateItems() as $uuid => $account) {
+ if ($excludeUuid !== null && $uuid === $excludeUuid) {
+ continue;
+ }
+ if ((string)$account->apiToken === $token) {
+ return (string)$account->name;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Add new account
+ * @return array
+ */
+ public function addItemAction()
+ {
+ // Check for duplicate token before adding
+ $postData = $this->request->getPost('account');
+ if (is_array($postData) && !empty($postData['apiToken'])) {
+ $existingAccount = $this->findDuplicateToken($postData['apiToken']);
+ if ($existingAccount !== null) {
+ return [
+ 'status' => 'error',
+ 'validations' => [
+ 'account.apiToken' => sprintf('This token is already used by account "%s"', $existingAccount)
+ ]
+ ];
+ }
+ }
+ return $this->addBase('account', 'accounts.account');
+ }
+
+ /**
+ * Update account
+ * @param string $uuid
+ * @return array
+ */
+ public function setItemAction($uuid)
+ {
+ // Check for duplicate token before updating
+ $postData = $this->request->getPost('account');
+ if (is_array($postData) && !empty($postData['apiToken'])) {
+ $existingAccount = $this->findDuplicateToken($postData['apiToken'], $uuid);
+ if ($existingAccount !== null) {
+ return [
+ 'status' => 'error',
+ 'validations' => [
+ 'account.apiToken' => sprintf('This token is already used by account "%s"', $existingAccount)
+ ]
+ ];
+ }
+ }
+ return $this->setBase('account', 'accounts.account', $uuid);
+ }
+
+ /**
+ * Delete account and all associated DNS entries (cascade delete)
+ * @param string $uuid
+ * @return array
+ */
+ public function delItemAction($uuid)
+ {
+ if (empty($uuid)) {
+ return ['status' => 'error', 'message' => 'Invalid UUID'];
+ }
+
+ $mdl = $this->getModel();
+
+ // Find and delete all entries associated with this account
+ $entriesToDelete = [];
+ foreach ($mdl->entries->entry->iterateItems() as $entryUuid => $entry) {
+ if ((string)$entry->account === $uuid) {
+ $entriesToDelete[] = $entryUuid;
+ }
+ }
+
+ // Delete associated entries
+ $deletedEntries = 0;
+ foreach ($entriesToDelete as $entryUuid) {
+ $mdl->entries->entry->del($entryUuid);
+ $deletedEntries++;
+ }
+
+ // Now delete the account itself
+ $result = $this->delBase('accounts.account', $uuid);
+
+ // Add info about deleted entries to result
+ if ($deletedEntries > 0) {
+ $result['deletedEntries'] = $deletedEntries;
+ $result['message'] = "Account deleted along with $deletedEntries associated DNS entries";
+ }
+
+ return $result;
+ }
+
+ /**
+ * Toggle account enabled status
+ * @param string $uuid
+ * @param int $enabled
+ * @return array
+ */
+ public function toggleItemAction($uuid, $enabled = null)
+ {
+ return $this->toggleBase('accounts.account', $uuid, $enabled);
+ }
+
+ /**
+ * Get count of entries associated with an account
+ * @param string $uuid
+ * @return array
+ */
+ public function getEntryCountAction($uuid = null)
+ {
+ if (empty($uuid)) {
+ return ['status' => 'error', 'count' => 0];
+ }
+
+ $mdl = $this->getModel();
+ $count = 0;
+ $entries = [];
+
+ foreach ($mdl->entries->entry->iterateItems() as $entryUuid => $entry) {
+ if ((string)$entry->account === $uuid) {
+ $count++;
+ $entries[] = (string)$entry->recordName . '.' . (string)$entry->zoneName;
+ }
+ }
+
+ return [
+ 'status' => 'ok',
+ 'count' => $count,
+ 'entries' => $entries
+ ];
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php
new file mode 100644
index 0000000000..a707a5b9e6
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/EntriesController.php
@@ -0,0 +1,949 @@
+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) && 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'];
+ }
+
+ $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 ?? '';
+ // TTL is an OptionField with underscore prefix (_60, _300, etc.)
+ $ttlValue = $entry['ttl'] ?? $ttl;
+ $node->ttl = '_' . ltrim($ttlValue, '_');
+ $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
+ ];
+ }
+
+ /**
+ * Apply default TTL to all DynDNS entries
+ * Updates both local config and Hetzner DNS records
+ * @return array result with updated count
+ */
+ public function applyDefaultTtlAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $mdl = $this->getModel();
+
+ // Get the default TTL from settings
+ $defaultTtl = (string)$mdl->general->defaultTtl;
+ // Remove underscore prefix if present (e.g. "_60" -> "60")
+ if (strpos($defaultTtl, '_') === 0) {
+ $defaultTtl = substr($defaultTtl, 1);
+ }
+ $ttl = intval($defaultTtl) ?: 60;
+
+ $updated = 0;
+ $failed = 0;
+ $skipped = 0;
+ $errors = [];
+ $backend = new Backend();
+
+ // Loop through all entries
+ foreach ($mdl->entries->entry->iterateItems() as $uuid => $entry) {
+ // Skip disabled entries
+ if ((string)$entry->enabled !== '1') {
+ $skipped++;
+ continue;
+ }
+
+ // Get entry details
+ $accountUuid = (string)$entry->account;
+
+ if (empty($accountUuid)) {
+ $skipped++;
+ continue;
+ }
+
+ // Get account token
+ $account = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
+ if ($account === null) {
+ $skipped++;
+ continue;
+ }
+
+ $token = (string)$account->apiToken;
+ $zoneId = (string)$entry->zoneId;
+ $recordName = (string)$entry->recordName;
+ $recordType = (string)$entry->recordType;
+
+ if (empty($token) || empty($zoneId) || empty($recordName)) {
+ $skipped++;
+ continue;
+ }
+
+ // Get current IP from state or entry
+ $stateFile = '/var/run/hclouddns_state.json';
+ $currentIp = (string)$entry->currentIp;
+ if (file_exists($stateFile)) {
+ $state = json_decode(file_get_contents($stateFile), true) ?? [];
+ if (isset($state['entries'][$uuid]['hetznerIp'])) {
+ $currentIp = $state['entries'][$uuid]['hetznerIp'];
+ }
+ }
+
+ if (empty($currentIp)) {
+ $skipped++;
+ continue;
+ }
+
+ // 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);
+
+ // Update at Hetzner
+ $response = $backend->configdpRun('hclouddns dns update', [
+ $token, $zoneId, $recordName, $recordType, $currentIp, $ttl
+ ]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null && isset($data['status']) && $data['status'] === 'ok') {
+ // Update local entry TTL
+ $entry->ttl = '_' . $ttl;
+ $updated++;
+ } else {
+ $failed++;
+ $errorMsg = $data['message'] ?? 'Unknown error';
+ $errors[] = "{$recordName}.{$entry->zoneName}: {$errorMsg}";
+ }
+ }
+
+ // Save config changes
+ if ($updated > 0) {
+ $mdl->serializeToConfig();
+ \OPNsense\Core\Config::getInstance()->save();
+ }
+
+ $message = "{$updated} entries updated to TTL {$ttl}s";
+ if ($skipped > 0) {
+ $message .= ", {$skipped} skipped";
+ }
+ if ($failed > 0) {
+ $message .= ", {$failed} failed";
+ }
+
+ return [
+ 'status' => $failed === 0 ? 'ok' : 'partial',
+ 'message' => $message,
+ 'updated' => $updated,
+ 'skipped' => $skipped,
+ 'failed' => $failed,
+ 'ttl' => $ttl,
+ 'errors' => $errors
+ ];
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php
new file mode 100644
index 0000000000..a99274da66
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/GatewaysController.php
@@ -0,0 +1,174 @@
+searchBase('gateways.gateway', ['enabled', 'name', 'interface', 'priority', 'checkipMethod']);
+ }
+
+ /**
+ * Get gateway by UUID
+ * @param string $uuid item unique id
+ * @return array gateway data
+ */
+ public function getItemAction($uuid = null)
+ {
+ return $this->getBase('gateway', 'gateways.gateway', $uuid);
+ }
+
+ /**
+ * Add new gateway
+ * @return array save result
+ */
+ public function addItemAction()
+ {
+ return $this->addBase('gateway', 'gateways.gateway');
+ }
+
+ /**
+ * Update gateway
+ * @param string $uuid item unique id
+ * @return array save result
+ */
+ public function setItemAction($uuid)
+ {
+ return $this->setBase('gateway', 'gateways.gateway', $uuid);
+ }
+
+ /**
+ * Delete gateway
+ * @param string $uuid item unique id
+ * @return array delete result
+ */
+ public function delItemAction($uuid)
+ {
+ return $this->delBase('gateways.gateway', $uuid);
+ }
+
+ /**
+ * Toggle gateway enabled status
+ * @param string $uuid item unique id
+ * @param string $enabled desired state (0/1), leave empty to toggle
+ * @return array result
+ */
+ public function toggleItemAction($uuid, $enabled = null)
+ {
+ return $this->toggleBase('gateways.gateway', $uuid, $enabled);
+ }
+
+ /**
+ * Check health of a specific gateway
+ * @param string $uuid gateway UUID
+ * @return array health check result
+ */
+ public function checkHealthAction($uuid = null)
+ {
+ $result = ['status' => 'error', 'message' => 'Invalid gateway'];
+
+ if ($uuid !== null) {
+ $mdl = $this->getModel();
+ $node = $mdl->getNodeByReference('gateways.gateway.' . $uuid);
+ if ($node !== null) {
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns healthcheck', [$uuid]);
+ $data = json_decode(trim($response), true);
+ if ($data !== null) {
+ $result = $data;
+ } else {
+ $result = ['status' => 'error', 'message' => 'Backend error', 'raw' => $response];
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get current IP for a gateway
+ * @param string $uuid gateway UUID
+ * @return array IP information
+ */
+ public function getIpAction($uuid = null)
+ {
+ $result = ['status' => 'error', 'message' => 'Invalid gateway'];
+
+ if ($uuid !== null) {
+ $mdl = $this->getModel();
+ $node = $mdl->getNodeByReference('gateways.gateway.' . $uuid);
+ if ($node !== null) {
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns getip', [$uuid]);
+ $data = json_decode(trim($response), true);
+ if ($data !== null) {
+ $result = $data;
+ } else {
+ $result = ['status' => 'error', 'message' => 'Backend error', 'raw' => $response];
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get status of all gateways
+ * @return array status information
+ */
+ public function statusAction()
+ {
+ $result = [
+ 'status' => 'ok',
+ 'gateways' => []
+ ];
+
+ // Load runtime state for simulation status
+ $stateFile = '/var/run/hclouddns_state.json';
+ $state = [];
+ if (file_exists($stateFile)) {
+ $content = file_get_contents($stateFile);
+ $state = json_decode($content, true) ?? [];
+ }
+
+ // Get model data
+ $mdl = $this->getModel();
+ $gateways = $mdl->gateways->gateway;
+
+ foreach ($gateways->iterateItems() as $uuid => $gw) {
+ $gwState = $state['gateways'][$uuid] ?? [];
+
+ $result['gateways'][$uuid] = [
+ 'uuid' => $uuid,
+ 'name' => (string)$gw->name,
+ 'interface' => (string)$gw->interface,
+ 'enabled' => (string)$gw->enabled,
+ 'status' => $gwState['status'] ?? 'unknown',
+ 'ipv4' => $gwState['ipv4'] ?? null,
+ 'ipv6' => $gwState['ipv6'] ?? null,
+ 'simulated' => $gwState['simulated'] ?? false,
+ 'lastCheck' => $gwState['lastCheck'] ?? 0
+ ];
+ }
+
+ $result['lastUpdate'] = $state['lastUpdate'] ?? 0;
+
+ return $result;
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php
new file mode 100644
index 0000000000..ecf39c1ba3
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HetznerController.php
@@ -0,0 +1,519 @@
+ 'error', 'valid' => false, 'message' => 'Invalid request'];
+
+ if ($this->request->isPost()) {
+ $token = $this->request->getPost('token', 'string', '');
+
+ if (empty($token)) {
+ return ['status' => 'error', 'valid' => false, 'message' => 'No token provided'];
+ }
+
+ // Sanitize token - only allow alphanumeric and common token characters
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns validate', [$token]);
+ $data = json_decode($response, true);
+
+ if ($data !== null) {
+ $result = [
+ 'status' => $data['valid'] ? 'ok' : 'error',
+ 'valid' => $data['valid'] ?? false,
+ 'message' => $data['message'] ?? 'Unknown error',
+ 'zone_count' => $data['zone_count'] ?? 0
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * List zones for token
+ * @return array
+ */
+ public function listZonesAction()
+ {
+ $result = ['status' => 'error', 'zones' => []];
+
+ if ($this->request->isPost()) {
+ $token = $this->request->getPost('token', 'string', '');
+
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'No token provided', 'zones' => []];
+ }
+
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns list zones', [$token]);
+ $data = json_decode($response, true);
+
+ if ($data !== null && isset($data['zones'])) {
+ $result = [
+ 'status' => 'ok',
+ 'zones' => $data['zones']
+ ];
+ } else {
+ $result = ['status' => 'error', 'message' => $data['message'] ?? 'Failed to list zones', 'zones' => []];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * List zones for an existing account (by UUID)
+ * @return array
+ */
+ public function listZonesForAccountAction()
+ {
+ $result = ['status' => 'error', 'zones' => []];
+
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required', 'zones' => []];
+ }
+
+ $uuid = $this->request->getPost('account_uuid', 'string', '');
+ if (empty($uuid)) {
+ return ['status' => 'error', 'message' => 'Account UUID required', 'zones' => []];
+ }
+
+ // Load the model and get the account
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ $node = $mdl->getNodeByReference('accounts.account.' . $uuid);
+
+ if ($node === null) {
+ return ['status' => 'error', 'message' => 'Account not found', 'zones' => []];
+ }
+
+ $token = (string)$node->apiToken;
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'Account has no API token', 'zones' => []];
+ }
+
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns list zones', [$token]);
+ $data = json_decode($response, true);
+
+ if ($data !== null && isset($data['zones'])) {
+ $result = [
+ 'status' => 'ok',
+ 'zones' => $data['zones'],
+ 'accountUuid' => $uuid
+ ];
+ } else {
+ $result = ['status' => 'error', 'message' => $data['message'] ?? 'Failed to list zones', 'zones' => []];
+ }
+
+ return $result;
+ }
+
+ /**
+ * List records for zone using account UUID
+ * @return array
+ */
+ public function listRecordsForAccountAction()
+ {
+ $result = ['status' => 'error', 'records' => []];
+
+ if ($this->request->isPost()) {
+ $accountUuid = $this->request->getPost('account_uuid', 'string', '');
+ $zoneId = $this->request->getPost('zone_id', 'string', '');
+ $allTypes = $this->request->getPost('all_types', 'string', '0');
+
+ if (empty($accountUuid) || empty($zoneId)) {
+ return ['status' => 'error', 'message' => 'Account UUID and zone_id required', 'records' => []];
+ }
+
+ // Load the model and get the account
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
+
+ if ($node === null) {
+ return ['status' => 'error', 'message' => 'Account not found', 'records' => []];
+ }
+
+ $token = (string)$node->apiToken;
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'Account has no API token', 'records' => []];
+ }
+
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+ $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
+
+ $backend = new Backend();
+ // Use allrecords action if all_types is requested
+ $action = ($allTypes === '1') ? 'hclouddns list allrecords' : 'hclouddns list records';
+ $response = $backend->configdpRun($action, [$token, $zoneId]);
+ $data = json_decode($response, true);
+
+ if ($data !== null && isset($data['records'])) {
+ $result = [
+ 'status' => 'ok',
+ 'records' => $data['records']
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * List records for zone
+ * @return array
+ */
+ public function listRecordsAction()
+ {
+ $result = ['status' => 'error', 'records' => []];
+
+ if ($this->request->isPost()) {
+ $token = $this->request->getPost('token', 'string', '');
+ $zoneId = $this->request->getPost('zone_id', 'string', '');
+
+ if (empty($token) || empty($zoneId)) {
+ return ['status' => 'error', 'message' => 'Token and zone_id required', 'records' => []];
+ }
+
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+ $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns list records', [$token, $zoneId]);
+ $data = json_decode($response, true);
+
+ if ($data !== null && isset($data['records'])) {
+ $result = [
+ 'status' => 'ok',
+ 'records' => $data['records']
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Sanitize record value based on record type
+ * @param string $value
+ * @param string $recordType
+ * @return string
+ */
+ private function sanitizeRecordValue($value, $recordType)
+ {
+ switch ($recordType) {
+ case 'A':
+ // IPv4 address
+ return preg_replace('/[^0-9.]/', '', $value);
+ case 'AAAA':
+ // IPv6 address
+ return preg_replace('/[^a-fA-F0-9:]/', '', $value);
+ case 'CNAME':
+ case 'NS':
+ case 'PTR':
+ // Hostname
+ return preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
+ case 'MX':
+ // Priority + hostname (e.g., "10 mail.example.com")
+ return preg_replace('/[^a-zA-Z0-9._ -]/', '', $value);
+ case 'TXT':
+ case 'SPF':
+ // Allow most printable ASCII for TXT records (SPF, DKIM, DMARC, etc.)
+ // Remove only control characters and null bytes
+ return preg_replace('/[\x00-\x1F\x7F]/', '', $value);
+ case 'SRV':
+ // Priority weight port target (e.g., "10 100 443 server.example.com")
+ return preg_replace('/[^a-zA-Z0-9._ -]/', '', $value);
+ case 'CAA':
+ // Flags tag value (e.g., '0 issue "letsencrypt.org"')
+ return preg_replace('/[^a-zA-Z0-9._ "\'-]/', '', $value);
+ default:
+ // Generic sanitization
+ return preg_replace('/[^a-zA-Z0-9._:@" -]/', '', $value);
+ }
+ }
+
+ /**
+ * Create a new DNS record at Hetzner
+ * @return array
+ */
+ public function createRecordAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $accountUuid = $this->request->getPost('account_uuid', 'string', '');
+ $zoneId = $this->request->getPost('zone_id', 'string', '');
+ $recordName = $this->request->getPost('record_name', 'string', '');
+ $recordType = $this->request->getPost('record_type', 'string', 'A');
+ $value = $this->request->getPost('value', 'string', '');
+ $ttl = $this->request->getPost('ttl', 'int', 300);
+
+ if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($value)) {
+ return ['status' => 'error', 'message' => 'Missing required parameters'];
+ }
+
+ // Load the model and get the account
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
+
+ if ($node === null) {
+ return ['status' => 'error', 'message' => 'Account not found'];
+ }
+
+ $token = (string)$node->apiToken;
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'Account has no API token'];
+ }
+
+ // Sanitize inputs
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+ $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
+ $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName);
+ $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType));
+ $value = $this->sanitizeRecordValue($value, $recordType);
+ $ttl = max(60, min(86400, intval($ttl)));
+
+ // Get zone name for history
+ $zoneName = $this->request->getPost('zone_name', 'string', '');
+ if (empty($zoneName)) {
+ $zoneName = $zoneId;
+ }
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns dns create', [
+ $token, $zoneId, $recordName, $recordType, $value, $ttl
+ ]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null && isset($data['status']) && $data['status'] === 'ok') {
+ // Record history entry
+ HistoryController::addEntry(
+ 'create',
+ $accountUuid,
+ (string)$node->name,
+ $zoneId,
+ $zoneName,
+ $recordName,
+ $recordType,
+ '',
+ 0,
+ $value,
+ $ttl
+ );
+ return $data;
+ }
+
+ if ($data !== null) {
+ return $data;
+ }
+
+ return ['status' => 'error', 'message' => 'Failed to create record'];
+ }
+
+ /**
+ * Update an existing DNS record at Hetzner
+ * @return array
+ */
+ public function updateRecordAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $accountUuid = $this->request->getPost('account_uuid', 'string', '');
+ $zoneId = $this->request->getPost('zone_id', 'string', '');
+ $recordName = $this->request->getPost('record_name', 'string', '');
+ $recordType = $this->request->getPost('record_type', 'string', 'A');
+ $value = $this->request->getPost('value', 'string', '');
+ $ttl = $this->request->getPost('ttl', 'int', 300);
+
+ if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($value)) {
+ return ['status' => 'error', 'message' => 'Missing required parameters'];
+ }
+
+ // Load the model and get the account
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
+
+ if ($node === null) {
+ return ['status' => 'error', 'message' => 'Account not found'];
+ }
+
+ $token = (string)$node->apiToken;
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'Account has no API token'];
+ }
+
+ // Sanitize inputs
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+ $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
+ $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName);
+ $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType));
+ $value = $this->sanitizeRecordValue($value, $recordType);
+ $ttl = max(60, min(86400, intval($ttl)));
+
+ // Get old values for history
+ $oldValue = $this->request->getPost('old_value', 'string', '');
+ $oldTtl = $this->request->getPost('old_ttl', 'int', 0);
+ $zoneName = $this->request->getPost('zone_name', 'string', '');
+ if (empty($zoneName)) {
+ $zoneName = $zoneId;
+ }
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns dns update', [
+ $token, $zoneId, $recordName, $recordType, $value, $ttl
+ ]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null && isset($data['status']) && $data['status'] === 'ok') {
+ // Record history entry
+ HistoryController::addEntry(
+ 'update',
+ $accountUuid,
+ (string)$node->name,
+ $zoneId,
+ $zoneName,
+ $recordName,
+ $recordType,
+ $oldValue,
+ $oldTtl,
+ $value,
+ $ttl
+ );
+ return $data;
+ }
+
+ if ($data !== null) {
+ return $data;
+ }
+
+ return ['status' => 'error', 'message' => 'Failed to update record'];
+ }
+
+ /**
+ * Delete a DNS record at Hetzner
+ * @return array
+ */
+ public function deleteRecordAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $accountUuid = $this->request->getPost('account_uuid', 'string', '');
+ $zoneId = $this->request->getPost('zone_id', 'string', '');
+ $recordName = $this->request->getPost('record_name', 'string', '');
+ $recordType = $this->request->getPost('record_type', 'string', 'A');
+
+ if (empty($accountUuid) || empty($zoneId) || empty($recordName) || empty($recordType)) {
+ return ['status' => 'error', 'message' => 'Missing required parameters'];
+ }
+
+ // Load the model and get the account
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+ $node = $mdl->getNodeByReference('accounts.account.' . $accountUuid);
+
+ if ($node === null) {
+ return ['status' => 'error', 'message' => 'Account not found'];
+ }
+
+ $token = (string)$node->apiToken;
+ if (empty($token)) {
+ return ['status' => 'error', 'message' => 'Account has no API token'];
+ }
+
+ // Sanitize inputs
+ $token = preg_replace('/[^a-zA-Z0-9_-]/', '', $token);
+ $zoneId = preg_replace('/[^a-zA-Z0-9_-]/', '', $zoneId);
+ $recordName = preg_replace('/[^a-zA-Z0-9@._*-]/', '', $recordName);
+ $recordType = strtoupper(preg_replace('/[^a-zA-Z]/', '', $recordType));
+
+ // Get old value and zone name for history
+ $oldValue = $this->request->getPost('old_value', 'string', '');
+ $oldTtl = $this->request->getPost('old_ttl', 'int', 0);
+ $zoneName = $this->request->getPost('zone_name', 'string', '');
+ if (empty($zoneName)) {
+ $zoneName = $zoneId;
+ }
+
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns dns delete', [
+ $token, $zoneId, $recordName, $recordType
+ ]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null && isset($data['status']) && $data['status'] === 'ok') {
+ // Record history entry
+ HistoryController::addEntry(
+ 'delete',
+ $accountUuid,
+ (string)$node->name,
+ $zoneId,
+ $zoneName,
+ $recordName,
+ $recordType,
+ $oldValue,
+ $oldTtl,
+ '',
+ 0
+ );
+ return $data;
+ }
+
+ if ($data !== null) {
+ return $data;
+ }
+
+ return ['status' => 'error', 'message' => 'Failed to delete record'];
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php
new file mode 100644
index 0000000000..b22323ad21
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/HistoryController.php
@@ -0,0 +1,338 @@
+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"
+ ];
+ }
+
+ /**
+ * 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
+ * @param string $accountUuid
+ * @param string $accountName
+ * @param string $zoneId
+ * @param string $zoneName
+ * @param string $recordName
+ * @param string $recordType
+ * @param string $oldValue
+ * @param int $oldTtl
+ * @param string $newValue
+ * @param int $newTtl
+ * @return bool
+ */
+ public static function addEntry($action, $accountUuid, $accountName, $zoneId, $zoneName, $recordName, $recordType, $oldValue = '', $oldTtl = 0, $newValue = '', $newTtl = 0)
+ {
+ $mdl = new \OPNsense\HCloudDNS\HCloudDNS();
+
+ $change = $mdl->history->change->Add();
+ $change->timestamp = time();
+ $change->action = $action;
+ $change->accountUuid = $accountUuid;
+ $change->accountName = $accountName;
+ $change->zoneId = $zoneId;
+ $change->zoneName = $zoneName;
+ $change->recordName = $recordName;
+ $change->recordType = $recordType;
+ $change->oldValue = $oldValue;
+ $change->oldTtl = $oldTtl;
+ $change->newValue = $newValue;
+ $change->newTtl = $newTtl;
+ $change->reverted = '0';
+
+ $mdl->serializeToConfig();
+ Config::getInstance()->save();
+
+ return true;
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php
new file mode 100644
index 0000000000..9815e4816f
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/ServiceController.php
@@ -0,0 +1,241 @@
+configdRun('hclouddns status');
+ $data = json_decode($response, true);
+
+ if ($data === null) {
+ return ['status' => 'error', 'message' => 'Failed to get status'];
+ }
+
+ return $data;
+ }
+
+ /**
+ * Trigger manual update
+ * @return array
+ */
+ public function updateAction()
+ {
+ if ($this->request->isPost()) {
+ $backend = new Backend();
+ $response = $backend->configdRun('hclouddns update');
+ $data = json_decode($response, true);
+
+ if ($data === null) {
+ return ['status' => 'error', 'message' => 'Update failed'];
+ }
+
+ return $data;
+ }
+
+ return ['status' => 'error', 'message' => 'POST request required'];
+ }
+
+ /**
+ * Reconfigure service (apply settings)
+ * @return array
+ */
+ public function reconfigureAction()
+ {
+ if ($this->request->isPost()) {
+ $mdl = new HCloudDNS();
+ $backend = new Backend();
+
+ // Generate configuration if needed
+ $backend->configdRun('template reload OPNsense/HCloudDNS');
+
+ return ['status' => 'ok'];
+ }
+
+ return ['status' => 'error', 'message' => 'POST request required'];
+ }
+
+ /**
+ * Trigger manual update with v2 failover support
+ * @return array
+ */
+ public function updateV2Action()
+ {
+ if ($this->request->isPost()) {
+ $backend = new Backend();
+ $response = $backend->configdRun('hclouddns updatev2');
+ $data = json_decode($response, true);
+
+ if ($data === null) {
+ return ['status' => 'error', 'message' => 'Update failed', 'raw' => $response];
+ }
+
+ return $data;
+ }
+
+ return ['status' => 'error', 'message' => 'POST request required'];
+ }
+
+ /**
+ * Get failover history
+ * @return array
+ */
+ public function failoverHistoryAction()
+ {
+ $stateFile = '/var/run/hclouddns_state.json';
+
+ if (file_exists($stateFile)) {
+ $content = file_get_contents($stateFile);
+ $data = json_decode($content, true);
+
+ if ($data !== null && isset($data['failoverHistory'])) {
+ return [
+ 'status' => 'ok',
+ 'history' => $data['failoverHistory'],
+ 'lastUpdate' => $data['lastUpdate'] ?? 0
+ ];
+ }
+ }
+
+ return ['status' => 'ok', 'history' => [], 'lastUpdate' => 0];
+ }
+
+ /**
+ * Simulate gateway failure
+ * @param string $uuid gateway UUID
+ * @return array
+ */
+ public function simulateDownAction($uuid = null)
+ {
+ if ($this->request->isPost() && $uuid !== null) {
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns simulate down', [$uuid]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null) {
+ return $data;
+ }
+ return ['status' => 'error', 'message' => 'Simulation failed'];
+ }
+
+ return ['status' => 'error', 'message' => 'POST request with gateway UUID required'];
+ }
+
+ /**
+ * Simulate gateway recovery
+ * @param string $uuid gateway UUID
+ * @return array
+ */
+ public function simulateUpAction($uuid = null)
+ {
+ if ($this->request->isPost() && $uuid !== null) {
+ $backend = new Backend();
+ $response = $backend->configdpRun('hclouddns simulate up', [$uuid]);
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null) {
+ return $data;
+ }
+ return ['status' => 'error', 'message' => 'Simulation failed'];
+ }
+
+ return ['status' => 'error', 'message' => 'POST request with gateway UUID required'];
+ }
+
+ /**
+ * Clear all simulations
+ * @return array
+ */
+ public function simulateClearAction()
+ {
+ if ($this->request->isPost()) {
+ $backend = new Backend();
+ $response = $backend->configdRun('hclouddns simulate clear');
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null) {
+ return $data;
+ }
+ return ['status' => 'error', 'message' => 'Clear failed'];
+ }
+
+ return ['status' => 'error', 'message' => 'POST request required'];
+ }
+
+ /**
+ * Get simulation status
+ * @return array
+ */
+ public function simulateStatusAction()
+ {
+ $backend = new Backend();
+ $response = $backend->configdRun('hclouddns simulate status');
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null) {
+ return $data;
+ }
+
+ return ['status' => 'ok', 'simulation' => ['active' => false, 'simulatedDown' => []]];
+ }
+
+ /**
+ * Test notification channels
+ * @return array
+ */
+ public function testNotifyAction()
+ {
+ if ($this->request->isPost()) {
+ $backend = new Backend();
+ $response = $backend->configdRun('hclouddns testnotify');
+ $data = json_decode(trim($response), true);
+
+ if ($data !== null) {
+ return $data;
+ }
+ return ['status' => 'error', 'message' => 'Test notification failed'];
+ }
+
+ return ['status' => 'error', 'message' => 'POST request required'];
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php
new file mode 100644
index 0000000000..40bf0662dd
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/Api/SettingsController.php
@@ -0,0 +1,600 @@
+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
+ */
+ public function setAction()
+ {
+ $result = ['status' => 'error', 'message' => 'Invalid request'];
+ if ($this->request->isPost()) {
+ $this->ensureNotificationsExist();
+ $mdl = $this->getModel();
+
+ // 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';
+ } 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']
+ )
+ ];
+ }
+
+ /**
+ * Get zone groups configuration
+ * @return array
+ */
+ public function getZoneGroupsAction()
+ {
+ $mdl = $this->getModel();
+ $zoneGroupsJson = (string)$mdl->general->zoneGroups;
+
+ if (empty($zoneGroupsJson)) {
+ return [
+ 'status' => 'ok',
+ 'groups' => [],
+ 'assignments' => []
+ ];
+ }
+
+ $data = json_decode($zoneGroupsJson, true);
+ if (!is_array($data)) {
+ return [
+ 'status' => 'ok',
+ 'groups' => [],
+ 'assignments' => []
+ ];
+ }
+
+ return [
+ 'status' => 'ok',
+ 'groups' => $data['groups'] ?? [],
+ 'assignments' => $data['assignments'] ?? []
+ ];
+ }
+
+ /**
+ * Set zone group assignment
+ * @return array
+ */
+ public function setZoneGroupAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $zoneId = $this->request->getPost('zone_id');
+ $groupName = $this->request->getPost('group_name');
+
+ if (empty($zoneId)) {
+ return ['status' => 'error', 'message' => 'zone_id required'];
+ }
+
+ $mdl = $this->getModel();
+ $zoneGroupsJson = (string)$mdl->general->zoneGroups;
+ $data = json_decode($zoneGroupsJson, true);
+
+ if (!is_array($data)) {
+ $data = ['groups' => [], 'assignments' => []];
+ }
+ if (!isset($data['groups'])) {
+ $data['groups'] = [];
+ }
+ if (!isset($data['assignments'])) {
+ $data['assignments'] = [];
+ }
+
+ // Add group if new and not empty
+ if (!empty($groupName) && !in_array($groupName, $data['groups'])) {
+ $data['groups'][] = $groupName;
+ }
+
+ // Set or remove assignment
+ if (empty($groupName)) {
+ unset($data['assignments'][$zoneId]);
+ } else {
+ $data['assignments'][$zoneId] = $groupName;
+ }
+
+ $mdl->general->zoneGroups = json_encode($data);
+ $mdl->serializeToConfig();
+ \OPNsense\Core\Config::getInstance()->save();
+
+ return [
+ 'status' => 'ok',
+ 'groups' => $data['groups'],
+ 'assignments' => $data['assignments']
+ ];
+ }
+
+ /**
+ * Delete a zone group
+ * @return array
+ */
+ public function deleteZoneGroupAction()
+ {
+ if (!$this->request->isPost()) {
+ return ['status' => 'error', 'message' => 'POST required'];
+ }
+
+ $groupName = $this->request->getPost('group_name');
+ if (empty($groupName)) {
+ return ['status' => 'error', 'message' => 'group_name required'];
+ }
+
+ $mdl = $this->getModel();
+ $zoneGroupsJson = (string)$mdl->general->zoneGroups;
+ $data = json_decode($zoneGroupsJson, true);
+
+ if (!is_array($data)) {
+ return ['status' => 'ok', 'message' => 'No groups exist'];
+ }
+
+ // Remove group from list
+ if (isset($data['groups'])) {
+ $data['groups'] = array_values(array_filter($data['groups'], function($g) use ($groupName) {
+ return $g !== $groupName;
+ }));
+ }
+
+ // Remove all assignments to this group
+ if (isset($data['assignments'])) {
+ foreach ($data['assignments'] as $zoneId => $group) {
+ if ($group === $groupName) {
+ unset($data['assignments'][$zoneId]);
+ }
+ }
+ }
+
+ $mdl->general->zoneGroups = json_encode($data);
+ $mdl->serializeToConfig();
+ \OPNsense\Core\Config::getInstance()->save();
+
+ return [
+ 'status' => 'ok',
+ 'groups' => $data['groups'] ?? [],
+ 'assignments' => $data['assignments'] ?? []
+ ];
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php
new file mode 100644
index 0000000000..1dafd888f6
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/DnsController.php
@@ -0,0 +1,46 @@
+view->pick('OPNsense/HCloudDNS/dns');
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php
new file mode 100644
index 0000000000..f8af3646ae
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/HistoryController.php
@@ -0,0 +1,46 @@
+view->pick('OPNsense/HCloudDNS/history');
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php
new file mode 100644
index 0000000000..e00a07a052
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/IndexController.php
@@ -0,0 +1,112 @@
+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->entrySettingsForm = $this->getForm('dialogEntrySettings');
+ $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');
+ }
+
+ /**
+ * 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/controllers/OPNsense/HCloudDNS/SettingsController.php b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php
new file mode 100644
index 0000000000..7477fa457b
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/SettingsController.php
@@ -0,0 +1,48 @@
+view->generalForm = $this->getForm('general');
+ $this->view->accountForm = $this->getForm('dialogAccount');
+ $this->view->pick('OPNsense/HCloudDNS/settings');
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml
new file mode 100644
index 0000000000..551a4e983f
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogAccount.xml
@@ -0,0 +1,32 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml
new file mode 100644
index 0000000000..9df8eaa92a
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml
@@ -0,0 +1,71 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntrySettings.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntrySettings.xml
new file mode 100644
index 0000000000..c9f41fb6a1
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntrySettings.xml
@@ -0,0 +1,12 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml
new file mode 100644
index 0000000000..2f41773e87
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml
@@ -0,0 +1,38 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml
new file mode 100644
index 0000000000..02343c8d21
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml
@@ -0,0 +1,34 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml
new file mode 100644
index 0000000000..4baa3b1f4a
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml
@@ -0,0 +1,24 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml
new file mode 100644
index 0000000000..5e2fbdae4e
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml
@@ -0,0 +1,20 @@
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml
new file mode 100644
index 0000000000..fa30e2c5d8
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml
@@ -0,0 +1,16 @@
+
+
+ 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.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php
new file mode 100644
index 0000000000..2ade4892f1
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/HCloudDNS.php
@@ -0,0 +1,39 @@
+
+ //OPNsense/HCloudDNS
+ Hetzner Cloud Dynamic DNS with Multi-Zone and Failover
+ 2.0.2
+
+
+
+
+ 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
+
+
+
+ Y
+ _60
+
+ <_60>60s (1 min - DynDNS)
+ <_120>120s (2 min)
+ <_300>300s (5 min)
+ <_600>600s (10 min)
+ <_1800>1800s (30 min)
+ <_3600>3600s (1 hour)
+ <_86400>86400s (1 day)
+
+
+
+
+ N
+ {}
+
+
+
+
+
+
+
+ 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
+
+
+ N
+ Default Gateway (auto-detect)
+
+
+
+
+ OPNsense.HCloudDNS.HCloudDNS
+ gateways.gateway
+ name
+
+
+ N
+ None (no failover)
+
+
+ Y
+ 300
+
+ <_60>60s (1 min - DynDNS)
+ <_120>120s (2 min)
+ <_300>300s (5 min - default)
+ <_600>600s (10 min)
+ <_1800>1800s (30 min)
+ <_3600>3600s (1 hour)
+ <_86400>86400s (1 day)
+
+
+
+ N
+
+
+ N
+
+
+ pending
+
+ Pending
+ Active
+ Failover
+ Paused
+ Error
+ Orphaned
+
+
+
+
+ N
+
+
+
+
+
+
+
+
+ Y
+
+
+ Y
+
+ Create
+ Update
+ Delete
+
+
+
+ Y
+
+
+ N
+
+
+ Y
+
+
+ Y
+
+
+ Y
+
+
+ Y
+
+
+ N
+
+
+ N
+
+
+ N
+
+
+ N
+
+
+ 0
+
+
+
+
+
+
+
+ 0
+
+
+ 1
+
+
+ 1
+
+
+ 1
+
+
+ 1
+
+
+
+ 0
+
+
+ N
+ Valid email address required
+
+
+
+ 0
+
+
+ N
+ Valid URL required
+
+
+ POST
+
+ POST
+ GET
+
+
+
+
+ 0
+
+
+ https://ntfy.sh
+ N
+
+
+ N
+ /^[a-zA-Z0-9_-]{1,64}$/
+ Topic must be alphanumeric (max 64 characters)
+
+
+ default
+
+ Min (1)
+ Low (2)
+ Default (3)
+ High (4)
+ Urgent (5)
+
+
+
+
+
+
+
+
+ 1
+ Y
+
+
+ Y
+ /^.{1,64}$/
+ Name is required (max 64 characters)
+
+
+ N
+ /^.{0,255}$/
+
+
+ Y
+ cloud
+
+ Hetzner Cloud API
+ Hetzner DNS API (deprecated)
+
+
+
+ Y
+ /^.{10,}$/
+ API token is required (minimum 10 characters)
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml
new file mode 100644
index 0000000000..dcf2de0b33
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Menu/Menu.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php
new file mode 100644
index 0000000000..88d7cddce5
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_1.php
@@ -0,0 +1,43 @@
+object();
+ $hcloud = $config->OPNsense->HCloudDNS;
+
+ if ($hcloud && !isset($hcloud->notifications)) {
+ // Add notifications section with defaults
+ $hcloud->addChild('notifications');
+ $hcloud->notifications->addChild('enabled', '0');
+ $hcloud->notifications->addChild('notifyOnUpdate', '1');
+ $hcloud->notifications->addChild('notifyOnFailover', '1');
+ $hcloud->notifications->addChild('notifyOnFailback', '1');
+ $hcloud->notifications->addChild('notifyOnError', '1');
+ $hcloud->notifications->addChild('emailEnabled', '0');
+ $hcloud->notifications->addChild('emailTo', '');
+ $hcloud->notifications->addChild('webhookEnabled', '0');
+ $hcloud->notifications->addChild('webhookUrl', '');
+ $hcloud->notifications->addChild('webhookMethod', 'POST');
+ $hcloud->notifications->addChild('ntfyEnabled', '0');
+ $hcloud->notifications->addChild('ntfyServer', 'https://ntfy.sh');
+ $hcloud->notifications->addChild('ntfyTopic', '');
+ $hcloud->notifications->addChild('ntfyPriority', 'default');
+ }
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_2.php b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_2.php
new file mode 100644
index 0000000000..36c9d8e293
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/Migrations/M2_0_2.php
@@ -0,0 +1,44 @@
+object();
+
+ // Check if our config section exists
+ if (!isset($config->OPNsense->HCloudDNS->entries)) {
+ return;
+ }
+
+ // Valid TTL values that need underscore prefix
+ $validTtls = ['60', '120', '300', '600', '1800', '3600', '86400'];
+
+ // Iterate over entries in the raw config
+ foreach ($config->OPNsense->HCloudDNS->entries->children() as $entry) {
+ if (isset($entry->ttl)) {
+ $ttl = (string)$entry->ttl;
+ // If TTL is plain number (not already prefixed), convert to underscore format
+ if (in_array($ttl, $validTtls)) {
+ $entry->ttl = '_' . $ttl;
+ }
+ }
+ }
+ }
+}
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt
new file mode 100644
index 0000000000..683a63a6d0
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/accounts.volt
@@ -0,0 +1,229 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+ {{ lang._('ID') }}
+ {{ lang._('Enabled') }}
+ {{ lang._('Description') }}
+ {{ lang._('Zone') }}
+ {{ lang._('Records') }}
+ {{ lang._('IPv4') }}
+ {{ lang._('IPv6') }}
+ {{ lang._('Commands') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'DialogAccount', 'label': lang._('Edit Account')]) }}
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt
new file mode 100644
index 0000000000..ebbba34624
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/dns.volt
@@ -0,0 +1,1993 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Hetzner Cloud DNS - Full DNS Zone Management
+#}
+
+
+
+
+
+
{{ lang._('No API Accounts Configured') }}
+
{{ lang._('You need to add at least one Hetzner DNS API account before you can manage DNS zones.') }}
+
{{ lang._('Go to Settings') }}
+
+
+
+
+ {{ lang._('Full DNS zone management for all your Hetzner DNS zones. Create, edit, and delete any DNS record type.') }}
+
+
+
+
+ {{ lang._('Refresh Zones') }}
+ {{ lang._('Undo / History') }}
+
+
+
+
+
+
+ {{ lang._('Select Account') }}
+
+ {{ lang._('-- Select an account --') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Select an account to view DNS zones') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Loading history...') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Zone') }}:
+ {{ lang._('Record') }}:
+ {{ lang._('Type') }}:
+
+
+
+
+
+ {{ lang._('Primary Gateway') }}
+
+
+
+
+
+ {{ lang._('Failover Gateway') }} ({{ lang._('optional') }})
+
+
+
+
+
+
+ {{ lang._('The record will be managed by DynDNS and updated automatically when your gateway IP changes.') }}
+
+
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt
new file mode 100644
index 0000000000..05531bb128
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/entries.volt
@@ -0,0 +1,358 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+
+
{{ lang._('DNS Entries') }}
+
{{ lang._('Manage your dynamic DNS entries. Select multiple entries for batch operations.') }}
+
+
+
+
+
+ 0 {{ lang._('selected') }}:
+ {{ lang._('Pause') }}
+ {{ lang._('Resume') }}
+ {{ lang._('Change Gateway') }}
+ {{ lang._('Delete') }}
+
+
+
+
+
+
+
+ {{ lang._('ID') }}
+ {{ lang._('On') }}
+ {{ lang._('Record') }}
+ {{ lang._('Type') }}
+ {{ lang._('Current IP') }}
+ {{ lang._('Gateway') }}
+ {{ lang._('Status') }}
+ {{ lang._('Commands') }}
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Refresh Status') }}
+ {{ lang._('Update Now') }}
+
+
+
+
+
+
+
+{{ partial("layout_partials/base_dialog", ['fields': entryForm, 'id': 'DialogEntry', 'label': lang._('Edit Entry')]) }}
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt
new file mode 100644
index 0000000000..1ca44b38ff
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/gateways.volt
@@ -0,0 +1,155 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+
{{ lang._('Gateways') }}
+
{{ lang._('Configure WAN interfaces for dynamic DNS updates. Each gateway can have its own IP detection method and health check settings.') }}
+
+
+
+
+
+
+ {{ lang._('ID') }}
+ {{ lang._('Enabled') }}
+ {{ lang._('Name') }}
+ {{ lang._('Interface') }}
+ {{ lang._('Priority') }}
+ {{ lang._('IP Method') }}
+ {{ lang._('Commands') }}
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Refresh Status') }}
+
+
+
+
+
+
+
+{{ partial("layout_partials/base_dialog", ['fields': gatewayForm, 'id': 'DialogGateway', 'label': lang._('Edit Gateway')]) }}
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt
new file mode 100644
index 0000000000..ed77db0e22
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/general.volt
@@ -0,0 +1,366 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Hetzner Cloud Dynamic DNS') }}
+
+
+
+
+
+ {{ lang._('Service') }}
+
+
+
+ {{ lang._('API') }}
+
+
+
+ {{ lang._('Token') }}
+
+
+
+ {{ lang._('Failover') }}
+
+
+
+
+
+
+
+
+
{{ lang._('Configuration Summary') }}
+
+
+
+
+
+
+
+
{{ lang._('Settings') }}
+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }}
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Validate') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt
new file mode 100644
index 0000000000..1542363f58
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt
@@ -0,0 +1,256 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Hetzner Cloud DNS - Change History
+#}
+
+
+
+
+
+
+
+ {{ lang._('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...') }}
+
+
+
+
+
{{ lang._('Refresh') }}
+
{{ lang._('Cleanup Old Entries') }}
+
{{ lang._('Clear All 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
new file mode 100644
index 0000000000..f5d73a6467
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt
@@ -0,0 +1,2215 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Hetzner Cloud Dynamic DNS - Main Interface with Tabs
+#}
+
+
+
+
+
+
{{ lang._('No API Accounts Configured') }}
+
{{ lang._('You need to add at least one Hetzner DNS API account before you can manage DNS entries.') }}
+
{{ lang._('Go to Settings') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Service Status:') }}
+ {{ lang._('Loading...') }}
+
+
+
+ {{ lang._('Last refresh:') }} -
+
+ {{ lang._('Auto-refresh (30s)') }}
+
+
+
+
+
+
+
+
+
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._('Clear All Simulations') }}
+
+
{{ lang._('DNS Entry Status') }}
+
+
+
+
+ {{ lang._('Record') }}
+ {{ lang._('Type') }}
+ {{ lang._('Current IP') }}
+ {{ lang._('Active Gateway') }}
+ {{ lang._('Status') }}
+
+
+
+ {{ lang._('Loading...') }}
+
+
+
+
+
+
{{ lang._('Update Now') }}
+
+
+
+
+
+
+
+
{{ lang._('Failover Settings') }}
+
+
+ {{ partial("layout_partials/base_form", ['fields': failoverForm, 'id': 'frm_failover_settings']) }}
+ {{ lang._('Save 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._('Changes need to be saved and applied.') }}
+
+
{{ lang._('Save') }}
+
+
+
+
+
+ {{ 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.') }}
+
+
+
+
+ {{ lang._('Default TTL') }}:
+
+ 60s (1 min - DynDNS)
+ 120s (2 min)
+ 300s (5 min)
+ 600s (10 min)
+ 1800s (30 min)
+ 3600s (1 hour)
+ 86400s (1 day)
+
+
+ {{ lang._('Save & Apply to All') }}
+
+ {{ lang._('Updates TTL for all DynDNS records at Hetzner') }}
+
+
+
+
+ {{ lang._('Import from Hetzner') }}:
+
+ {{ lang._('-- Select Account --') }}
+
+
+ {{ lang._('Load Zones') }}
+
+ {{ lang._('Import existing A/AAAA records as DynDNS entries') }}
+
+
+
+
+
+
+
{{ lang._('Changes need to be saved.') }}
+
+
{{ lang._('Save') }}
+
{{ lang._('Refresh Status') }}
+
{{ lang._('Create Dual-Stack (A+AAAA)') }}
+
{{ lang._('Remove Orphaned') }}
+
+
+
+
+
+
{{ 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']) }}
+
+
+
{{ lang._('Save') }}
+
+
+
+
+
+{{ 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')]) }}
+
+
+
+
+
+
+
+
+
{{ lang._('What is Dual-Stack?') }}
+
{{ lang._('Dual-Stack means your domain is reachable via both IPv4 and IPv6. This creates two linked DNS records:') }}
+
+ A Record {{ lang._('- Points to your IPv4 address (e.g. 203.0.113.50)') }}
+ AAAA Record {{ lang._('- Points to your IPv6 address (e.g. 2001:db8::1)') }}
+
+
{{ lang._('The records are linked: Changes to one (enable/disable/delete) automatically affect the other.') }}
+
+
+
+ {{ lang._('Account') }}
+
+
+
+ {{ lang._('Zone') }}
+
+ {{ lang._('-- Select Account First --') }}
+
+
+
+ {{ lang._('Record Name') }}
+
+
+
+ {{ lang._('TTL') }}
+
+ 60s (1 min - DynDNS)
+ 120s (2 min)
+ 300s (5 min)
+ 600s (10 min)
+ 1800s (30 min)
+ 3600s (1 hour)
+ 86400s (1 day)
+
+
+
+
+
+
{{ lang._('IPv4 (A Record)') }}
+
+ {{ lang._('Primary Gateway') }}
+
+
+
+ {{ lang._('Failover Gateway') }}
+
+
+
+
+
{{ lang._('IPv6 (AAAA Record)') }}
+
+ {{ lang._('Primary Gateway') }}
+
+
+
+ {{ lang._('Failover Gateway') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Loading zones...') }}
+
+
+
+
+
+
+
+ {{ lang._('Primary Gateway') }}
+
+
+
+
+
+ {{ lang._('Failover Gateway') }} ({{ lang._('optional') }})
+
+ {{ lang._('-- None --') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt
new file mode 100644
index 0000000000..c04be619cd
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt
@@ -0,0 +1,916 @@
+{#
+ 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._('Save') }}
+
+
+
+
+
+
+
{{ 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._('Changes need to be saved.') }}
+
+
{{ lang._('Save') }}
+
{{ lang._('Add Token & Import') }}
+
+
+
+
+
+
+
{{ lang._('Notifications') }}
+
+
+
{{ lang._('Get notified when DNS records change, failover events occur, or errors happen.') }}
+
+
+
+
+
+ {{ lang._('Enable Notifications') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Email Notifications') }}
+
+ {{ lang._('Enable Email') }}
+
+
+
+
+
+
+
+
+
{{ lang._('Webhook Notifications') }}
+
+ {{ lang._('Enable Webhook') }}
+
+
+
+ {{ lang._('Webhook URL') }}
+
+
+
+ {{ lang._('Method') }}
+
+ POST
+ GET
+
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Ntfy Notifications') }}
+
+ {{ lang._('Enable Ntfy') }}
+
+
+
+ {{ lang._('Server URL') }}
+
+
+
+ {{ lang._('Topic') }}
+
+
+
+ {{ lang._('Priority') }}
+
+ Min (1)
+ Low (2)
+ Default (3)
+ High (4)
+ Urgent (5)
+
+
+
+
+
+
+
+
+
+
{{ lang._('Test Notifications') }}
+
{{ lang._('Send a test notification to verify your configuration.') }}
+
{{ lang._('Send Test') }}
+
+
+
+
+
+
+
+
{{ lang._('Save Notification Settings') }}
+
+
+
+
+
+
+
{{ 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._('Include API tokens (security risk!)') }}
+
+
+
{{ lang._('Export') }}
+
+
+
+
+
{{ lang._('Import Configuration') }}
+
{{ lang._('Import configuration from a JSON backup file.') }}
+
+
+
{{ lang._('Import') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Account Name') }}
+
+
+
+
+
+ {{ lang._('API Type') }}
+
+ Hetzner Cloud API
+ Hetzner DNS API (deprecated)
+
+
+
+
+
+
{{ lang._('Validate Token & Load Zones') }}
+
+
+
+
+
+
+
+
+
+
{{ lang._('Select Zones and Records to Import') }}
+
{{ lang._('Click zones to import all records, or expand to select individual records.') }}
+
+
+
+
+
+
+ {{ lang._('Primary Gateway') }}
+
+
+
+
+
+ {{ lang._('Failover Gateway') }}
+
+
+
+
+
+
{{ lang._('Import Selected') }}
+
{{ lang._('Back') }}
+
+
+
+
+
+
+
+
+{{ partial("layout_partials/base_dialog", ['fields': accountForm, 'id': 'dialogAccount', 'label': lang._('API Account')]) }}
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt
new file mode 100644
index 0000000000..a04c20814d
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/status.volt
@@ -0,0 +1,397 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+
+
+
{{ lang._('Hetzner Cloud DDNS Status') }}
+
+
+
+
+
+ {{ lang._('Service') }}:
+
+ {{ lang._('Failover') }}:
+
+ {{ lang._('Refresh') }}
+
+
+ {{ lang._('Force Update') }}
+
+
+
+
+
+
+
+
+
{{ lang._('Gateways') }}
+
+
+
+
+
+
{{ lang._('Failover Simulation') }}
+
{{ lang._('Test failover by simulating gateway failures.') }}
+
+ {{ lang._('Status') }}:
+
+
+ {{ lang._('Clear Simulation') }}
+
+
+
+
+
+
+
+
{{ lang._('DNS Entries') }}
+
+
+
+
+ {{ lang._('Record') }}
+ {{ lang._('Type') }}
+ {{ lang._('Current IP') }}
+ {{ lang._('Primary') }}
+ {{ lang._('Failover') }}
+ {{ lang._('Status') }}
+
+
+
+
+ {{ lang._('Loading...') }}
+
+
+
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt
new file mode 100644
index 0000000000..3c32b4a371
--- /dev/null
+++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/zones.volt
@@ -0,0 +1,393 @@
+{#
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+#}
+
+
+
+
+
+
+
+
+
+
{{ lang._('Zone Selection') }}
+
{{ lang._('Select DNS records from your Hetzner zones to manage with dynamic DNS.') }}
+
+
+
+
+
+
+
+
+
+
+ {{ lang._('Primary Gateway') }}
+
+ {{ lang._('-- Select Gateway --') }}
+
+
+
+ {{ lang._('Failover Gateway') }}
+
+ {{ lang._('None (no failover)') }}
+
+
+
+ {{ lang._('TTL') }}
+
+
+
+
+
+ {{ lang._('Add Selected') }}
+
+
+
+
+
+
+
+
+
+ {{ lang._('No records selected') }}
+
+
+
+
+
+
+
+ {{ lang._('Enter your API token and click "Load Zones" to see available zones.') }}
+
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py
new file mode 100644
index 0000000000..39e34cc293
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py
@@ -0,0 +1,77 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Create a new DNS record at Hetzner
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def main():
+ # Expected args: token zone_id record_name record_type value ttl
+ if len(sys.argv) < 7:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Usage: create_record.py '
+ }))
+ sys.exit(1)
+
+ token = sys.argv[1].strip()
+ zone_id = sys.argv[2].strip()
+ record_name = sys.argv[3].strip()
+ record_type = sys.argv[4].strip().upper()
+ value = sys.argv[5].strip()
+ ttl = int(sys.argv[6].strip()) if sys.argv[6].strip().isdigit() else 300
+
+ if not all([token, zone_id, record_name, value]):
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Missing required parameters'
+ }))
+ sys.exit(1)
+
+ # Support all common record types
+ supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA']
+ if record_type not in supported_types:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': f'Unsupported record type: {record_type}. Supported: {", ".join(supported_types)}'
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+
+ # TXT records need to be quoted for Hetzner API
+ if record_type == 'TXT' and not value.startswith('"'):
+ value = f'"{value}"'
+
+ try:
+ success, message = api.create_record(zone_id, record_name, record_type, value, ttl)
+ if success:
+ print(json.dumps({
+ 'status': 'ok',
+ 'message': f'Record {record_name} ({record_type}) created successfully'
+ }))
+ sys.exit(0)
+ else:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': f'Failed to create record: {message}'
+ }))
+ sys.exit(1)
+ except Exception as e:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': str(e)
+ }))
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py
new file mode 100644
index 0000000000..d6fe202449
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py
@@ -0,0 +1,62 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Delete a DNS record at Hetzner
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def main():
+ # Expected args: token zone_id record_name record_type
+ if len(sys.argv) < 5:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Usage: delete_record.py '
+ }))
+ sys.exit(1)
+
+ token = sys.argv[1].strip()
+ zone_id = sys.argv[2].strip()
+ record_name = sys.argv[3].strip()
+ record_type = sys.argv[4].strip().upper()
+
+ if not all([token, zone_id, record_name, record_type]):
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Missing required parameters'
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+
+ try:
+ success, message = api.delete_record(zone_id, record_name, record_type)
+ if success:
+ print(json.dumps({
+ 'status': 'ok',
+ 'message': f'Record {record_name} ({record_type}) deleted successfully'
+ }))
+ sys.exit(0)
+ else:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': f'Failed to delete record: {message}'
+ }))
+ sys.exit(1)
+ except Exception as e:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': str(e)
+ }))
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py
new file mode 100755
index 0000000000..b8fe969854
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py
@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+All rights reserved.
+
+Gateway health check and IP detection for HCloudDNS
+"""
+
+import json
+import subprocess
+import sys
+import os
+import socket
+import urllib.request
+import urllib.error
+import ssl
+
+# State file for gateway status persistence
+STATE_FILE = '/var/run/hclouddns_gateways.json'
+
+# IP check services
+IP_SERVICES = {
+ 'web_ipify': {
+ 'ipv4': 'https://api.ipify.org',
+ 'ipv6': 'https://api6.ipify.org'
+ },
+ 'web_dyndns': {
+ 'ipv4': 'http://checkip.dyndns.org',
+ 'ipv6': None
+ },
+ 'web_freedns': {
+ 'ipv4': 'https://freedns.afraid.org/dynamic/check.php',
+ 'ipv6': None
+ },
+ 'web_ip4only': {
+ 'ipv4': 'https://ip4only.me/api/',
+ 'ipv6': None
+ },
+ 'web_ip6only': {
+ 'ipv4': None,
+ 'ipv6': 'https://ip6only.me/api/'
+ }
+}
+
+
+def load_state():
+ """Load gateway state from file"""
+ if os.path.exists(STATE_FILE):
+ try:
+ with open(STATE_FILE, 'r') as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError):
+ pass
+ return {'gateways': {}, 'lastCheck': 0}
+
+
+def save_state(state):
+ """Save gateway state to file"""
+ try:
+ with open(STATE_FILE, 'w') as f:
+ json.dump(state, f, indent=2)
+ except IOError as e:
+ sys.stderr.write(f"Error saving state: {e}\n")
+
+
+def get_interface_ip(interface, ipv6=False):
+ """Get IP address from interface using ifconfig"""
+ try:
+ result = subprocess.run(
+ ['ifconfig', interface],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ for line in result.stdout.split('\n'):
+ line = line.strip()
+ if ipv6 and line.startswith('inet6 ') and 'scopeid' not in line.lower():
+ parts = line.split()
+ if len(parts) >= 2:
+ addr = parts[1].split('%')[0]
+ if not addr.startswith('fe80:'):
+ return addr
+ elif not ipv6 and line.startswith('inet '):
+ parts = line.split()
+ if len(parts) >= 2:
+ return parts[1]
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
+ pass
+ return None
+
+
+def get_web_ip(service, interface=None, source_ip=None, ipv6=False):
+ """Get public IP from web service, optionally binding to source IP"""
+ service_config = IP_SERVICES.get(service, {})
+ url = service_config.get('ipv6' if ipv6 else 'ipv4')
+
+ if not url:
+ return None
+
+ try:
+ # Use curl if source_ip is specified (more reliable for source binding)
+ if source_ip:
+ cmd = ['curl', '-s', '--connect-timeout', '10', '--interface', source_ip, url]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ if result.returncode == 0:
+ content = result.stdout.strip()
+ if 'dyndns' in service:
+ import re
+ match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content)
+ if match:
+ return match.group(1)
+ elif 'ip4only' in service or 'ip6only' in service:
+ parts = content.split(',')
+ if len(parts) >= 2:
+ return parts[1].strip()
+ else:
+ if is_valid_ip(content):
+ return content
+ return None
+
+ # Default: use urllib without source binding
+ ctx = ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+
+ request = urllib.request.Request(url, headers={'User-Agent': 'OPNsense-HCloudDNS/2.0'})
+
+ with urllib.request.urlopen(request, timeout=10, context=ctx) as response:
+ content = response.read().decode('utf-8').strip()
+
+ if 'dyndns' in service:
+ import re
+ match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content)
+ if match:
+ return match.group(1)
+ elif 'ip4only' in service or 'ip6only' in service:
+ parts = content.split(',')
+ if len(parts) >= 2:
+ return parts[1].strip()
+ else:
+ if is_valid_ip(content):
+ return content
+ except (urllib.error.URLError, socket.timeout, subprocess.TimeoutExpired, Exception) as e:
+ sys.stderr.write(f"Error getting IP from {service}: {e}\n")
+
+ return None
+
+
+def is_valid_ip(ip):
+ """Check if string is a valid IP address"""
+ try:
+ socket.inet_pton(socket.AF_INET, ip)
+ return True
+ except socket.error:
+ try:
+ socket.inet_pton(socket.AF_INET6, ip)
+ return True
+ except socket.error:
+ return False
+
+
+def quick_ping_check(target='8.8.8.8', count=1, timeout=2):
+ """
+ Quick ping check for gateway connectivity.
+ Used as a simple fallback health check.
+
+ Args:
+ target: IP or hostname to ping
+ count: Number of pings
+ timeout: Timeout in seconds
+
+ Returns:
+ bool: True if ping succeeded
+ """
+ cmd = ['ping', '-c', str(count), '-W', str(timeout), target]
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * count + 2)
+ return result.returncode == 0
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
+ return False
+
+
+def resolve_interface_name(interface):
+ """Resolve OPNsense interface name to physical interface and get its IP"""
+ # Map common OPNsense names to physical interfaces
+ # First try to get from config.xml
+ try:
+ import xml.etree.ElementTree as ET
+ tree = ET.parse('/conf/config.xml')
+ root = tree.getroot()
+ iface_node = root.find(f'.//interfaces/{interface}')
+ if iface_node is not None:
+ phys_if = iface_node.findtext('if')
+ if phys_if:
+ return phys_if
+ except Exception:
+ pass
+ return interface
+
+
+def get_gateway_ip(uuid, gateway_config):
+ """Get current IP for a gateway"""
+ interface = gateway_config.get('interface')
+ checkip_method = gateway_config.get('checkipMethod', 'web_ipify')
+
+ result = {
+ 'status': 'ok',
+ 'uuid': uuid,
+ 'ipv4': None,
+ 'ipv6': None
+ }
+
+ # Resolve interface name and get local IP for source binding
+ phys_interface = resolve_interface_name(interface)
+ local_ip = get_interface_ip(phys_interface, ipv6=False)
+
+ if checkip_method == 'if':
+ result['ipv4'] = local_ip
+ result['ipv6'] = get_interface_ip(phys_interface, ipv6=True)
+ else:
+ # Use local_ip as source for web requests
+ result['ipv4'] = get_web_ip(checkip_method, phys_interface, source_ip=local_ip, ipv6=False)
+ result['ipv6'] = get_web_ip(checkip_method, phys_interface, source_ip=None, ipv6=True)
+
+ if not result['ipv4'] and not result['ipv6']:
+ result['status'] = 'error'
+ result['message'] = 'Could not determine IP address'
+
+ return result
+
+
+def main():
+ """Main entry point for configd actions"""
+ if len(sys.argv) < 2:
+ print(json.dumps({'status': 'error', 'message': 'No action specified'}))
+ sys.exit(1)
+
+ action = sys.argv[1]
+
+ if action == 'healthcheck':
+ if len(sys.argv) < 3:
+ print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'}))
+ sys.exit(1)
+
+ uuid = sys.argv[2]
+ gateway_config = {}
+ if len(sys.argv) > 3:
+ try:
+ gateway_config = json.loads(sys.argv[3])
+ except json.JSONDecodeError:
+ pass
+
+ # Simple ping-based health check (dpinger handles real gateway monitoring)
+ target = gateway_config.get('healthCheckTarget', '8.8.8.8')
+ is_healthy = quick_ping_check(target, count=1, timeout=2)
+ result = {
+ 'uuid': uuid,
+ 'status': 'up' if is_healthy else 'down'
+ }
+ print(json.dumps(result))
+
+ elif action == 'getip':
+ if len(sys.argv) < 3:
+ print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'}))
+ sys.exit(1)
+
+ uuid = sys.argv[2]
+ gateway_config = {}
+ if len(sys.argv) > 3:
+ try:
+ gateway_config = json.loads(sys.argv[3])
+ except json.JSONDecodeError:
+ pass
+
+ result = get_gateway_ip(uuid, gateway_config)
+ print(json.dumps(result))
+
+ elif action == 'status':
+ # Read gateways from OPNsense config and check their status
+ result = {'gateways': {}, 'lastCheck': 0}
+ try:
+ import xml.etree.ElementTree as ET
+ import time
+ tree = ET.parse('/conf/config.xml')
+ root = tree.getroot()
+
+ gateways_node = root.find('.//OPNsense/HCloudDNS/gateways')
+ if gateways_node is not None:
+ for gw in gateways_node.findall('gateway'):
+ uuid = gw.get('uuid')
+ if not uuid:
+ continue
+
+ enabled = gw.findtext('enabled', '0')
+ if enabled != '1':
+ continue
+
+ interface = gw.findtext('interface', '')
+ checkip_method = gw.findtext('checkipMethod', 'web_ipify')
+ health_target = gw.findtext('healthCheckTarget', '8.8.8.8')
+
+ # Resolve interface and get IP
+ phys_if = resolve_interface_name(interface)
+ ipv4 = None
+ ipv6 = None
+
+ if checkip_method == 'if':
+ ipv4 = get_interface_ip(phys_if, ipv6=False)
+ ipv6 = get_interface_ip(phys_if, ipv6=True)
+ else:
+ local_ip = get_interface_ip(phys_if, ipv6=False)
+ ipv4 = get_web_ip(checkip_method, phys_if, source_ip=local_ip, ipv6=False)
+
+ # Quick health check (ping only for speed)
+ status = 'up' if quick_ping_check(health_target, count=1, timeout=2) else 'down'
+
+ result['gateways'][uuid] = {
+ 'status': status,
+ 'ipv4': ipv4,
+ 'ipv6': ipv6
+ }
+
+ result['lastCheck'] = int(time.time())
+ except Exception as e:
+ sys.stderr.write(f"Error getting gateway status: {e}\n")
+
+ print(json.dumps(result))
+
+ else:
+ print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'}))
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py
new file mode 100755
index 0000000000..e6039de98e
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+All rights reserved.
+
+Get current IP from Hetzner DNS for a specific record
+"""
+
+import json
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def get_hetzner_ip(zone_id, record_name, record_type):
+ """Get current IP for a record from Hetzner DNS"""
+ # Read API token from config
+ try:
+ import xml.etree.ElementTree as ET
+ tree = ET.parse('/conf/config.xml')
+ root = tree.getroot()
+ token_node = root.find('.//OPNsense/HCloudDNS/apiToken')
+ if token_node is None or not token_node.text:
+ return {'status': 'error', 'message': 'No API token configured'}
+ token = token_node.text
+ except Exception as e:
+ return {'status': 'error', 'message': f'Config error: {str(e)}'}
+
+ api = HCloudAPI(token)
+
+ try:
+ records = api.list_records(zone_id)
+ for record in records:
+ if record.get('name') == record_name and record.get('type') == record_type:
+ return {
+ 'status': 'ok',
+ 'ip': record.get('value'),
+ 'recordId': record.get('id'),
+ 'ttl': record.get('ttl'),
+ 'modified': record.get('modified')
+ }
+
+ return {'status': 'error', 'message': 'Record not found'}
+ except Exception as e:
+ return {'status': 'error', 'message': str(e)}
+
+
+def main():
+ if len(sys.argv) < 4:
+ print(json.dumps({'status': 'error', 'message': 'Usage: get_hetzner_ip.py '}))
+ sys.exit(1)
+
+ zone_id = sys.argv[1]
+ record_name = sys.argv[2]
+ record_type = sys.argv[3]
+
+ result = get_hetzner_ip(zone_id, record_name, record_type)
+ print(json.dumps(result))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py
new file mode 100755
index 0000000000..b921b68b2e
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py
@@ -0,0 +1,91 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+ AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+ OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGE.
+
+ Hetzner Cloud API wrapper for HCloudDNS OPNsense plugin
+ This is a compatibility wrapper - actual implementation is in lib/hetzner_api.py
+"""
+import os
+import sys
+
+# Add lib directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib'))
+
+from hetzner_api import ( # noqa: E402
+ HetznerCloudAPI,
+ HetznerLegacyAPI,
+ HetznerAPIError,
+ create_api
+)
+
+# Re-export for backward compatibility
+HCloudAPIError = HetznerAPIError
+
+
+class HCloudAPI:
+ """
+ Backward-compatible wrapper for Hetzner DNS API.
+ Delegates to HetznerCloudAPI or HetznerLegacyAPI based on api_type.
+ """
+
+ def __init__(self, token, api_type='cloud', verbose=False):
+ self._api = create_api(token, api_type, verbose)
+ self.api_type = api_type
+ self.verbose = verbose
+
+ def validate_token(self):
+ return self._api.validate_token()
+
+ def list_zones(self):
+ return self._api.list_zones()
+
+ def get_zone_id(self, zone_name):
+ return self._api.get_zone_id(zone_name)
+
+ def list_records(self, zone_id, record_types=None):
+ return self._api.list_records(zone_id, record_types)
+
+ def get_record(self, zone_id, name, record_type):
+ return self._api.get_record(zone_id, name, record_type)
+
+ def update_record(self, zone_id, name, record_type, value, ttl=300):
+ return self._api.update_record(zone_id, name, record_type, value, ttl)
+
+ def create_record(self, zone_id, name, record_type, value, ttl=300):
+ return self._api.create_record(zone_id, name, record_type, value, ttl)
+
+ def delete_record(self, zone_id, name, record_type):
+ return self._api.delete_record(zone_id, name, record_type)
+
+
+# Export all for convenience
+__all__ = [
+ 'HCloudAPI',
+ 'HCloudAPIError',
+ 'HetznerCloudAPI',
+ 'HetznerLegacyAPI',
+ 'HetznerAPIError',
+ 'create_api'
+]
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py
new file mode 100644
index 0000000000..7d4ac3f8d1
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py
@@ -0,0 +1,6 @@
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Shared library for Hetzner DNS API access
+"""
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py
new file mode 100644
index 0000000000..62b613685d
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py
@@ -0,0 +1,812 @@
+"""
+ 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 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):
+ """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 _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.
+ 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
+ Uses pagination to fetch all zones (default limit is 25).
+ """
+ try:
+ all_zones = []
+ page = 1
+ per_page = 100
+
+ while True:
+ response = self._request('GET', '/zones', params={'page': page, 'per_page': per_page})
+
+ 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 all_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.
+ Handles pagination to fetch all records.
+ """
+ if record_types is None:
+ record_types = ['A', 'AAAA']
+
+ try:
+ all_rrsets = []
+ page = 1
+ per_page = 100 # Max allowed by Hetzner API
+
+ # Fetch all pages
+ while True:
+ response = self._request(
+ 'GET',
+ f'/zones/{zone_id}/rrsets',
+ params={'page': page, 'per_page': per_page}
+ )
+
+ 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', [])
+ all_rrsets.extend(rrsets)
+
+ # Check if there are more pages
+ meta = data.get('meta', {}).get('pagination', {})
+ last_page = meta.get('last_page', 1)
+
+ if self.verbose:
+ self._log(syslog.LOG_DEBUG, f"Page {page}/{last_page}: {len(rrsets)} rrsets")
+
+ if page >= last_page or len(rrsets) == 0:
+ break
+
+ page += 1
+
+ result = []
+ for rrset in all_rrsets:
+ if rrset.get('type') in record_types:
+ records = rrset.get('records', [])
+ rrset_name = rrset.get('name', '')
+ rrset_type = rrset.get('type', '')
+ rrset_ttl = rrset.get('ttl', 300)
+
+ # 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': value,
+ 'ttl': rrset_ttl
+ })
+
+ if self.verbose:
+ self._log(syslog.LOG_INFO, f"Found {len(result)} records in zone {zone_id} (fetched {len(all_rrsets)} rrsets)")
+
+ 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 using rrset-actions endpoint.
+ Returns tuple (success: bool, message: str)
+
+ 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
+ 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 AND ttl are same - no update needed
+ if existing.get('value') == str(value) and existing.get('ttl') == ttl:
+ return True, "unchanged"
+
+ # 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
+ }
+
+ response = self._request('POST', url, json_data=data)
+
+ 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)}")
+ 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)
+
+ Note: CREATE operations may return actions that should be awaited.
+ """
+ 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]:
+ # 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}"
+
+ 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)
+
+ 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}"
+
+ 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.
+ Uses pagination to fetch all zones (default limit is 25).
+ """
+ try:
+ all_zones = []
+ page = 1
+ per_page = 100
+
+ while True:
+ response = self._request('GET', '/zones', params={'page': page, 'per_page': per_page})
+
+ 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 all_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 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. Handles pagination to fetch all records."""
+ if record_types is None:
+ record_types = ['A', 'AAAA']
+
+ try:
+ all_records = []
+ page = 1
+ per_page = 100 # Max allowed by Hetzner API
+
+ # Fetch all pages
+ while True:
+ response = self._request(
+ 'GET',
+ '/records',
+ params={'zone_id': zone_id, 'page': page, 'per_page': per_page}
+ )
+
+ 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', [])
+ all_records.extend(records)
+
+ # Check if there are more pages (Legacy API uses meta.pagination)
+ meta = data.get('meta', {}).get('pagination', {})
+ last_page = meta.get('last_page', 1)
+
+ if self.verbose:
+ self._log(syslog.LOG_DEBUG, f"Page {page}/{last_page}: {len(records)} records")
+
+ if page >= last_page or len(records) == 0:
+ break
+
+ page += 1
+
+ result = []
+ for record in all_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)} records in zone {zone_id} (fetched {len(all_records)} total)")
+
+ return result
+
+ except HetznerAPIError as e:
+ self._log(syslog.LOG_ERR, f"Failed to list records: {str(e)}")
+ return []
+
+ def get_record(self, zone_id, name, record_type):
+ """Get a specific DNS record by name and type."""
+ records = self.list_records(zone_id, [record_type])
+
+ for record in records:
+ if record.get('name') == name and record.get('type') == record_type:
+ return record
+
+ return None
+
+ def _get_record_id(self, zone_id, name, record_type):
+ """Get record ID by name and type"""
+ record = self.get_record(zone_id, name, record_type)
+ return record.get('id') if record else None
+
+ def update_record(self, zone_id, name, record_type, value, ttl=300):
+ """
+ Update or create a DNS record.
+ Returns tuple (success: bool, message: str)
+ """
+ try:
+ record_id = self._get_record_id(zone_id, name, record_type)
+
+ if record_id:
+ # Update existing record
+ url = f'/records/{record_id}'
+ data = {
+ 'zone_id': zone_id,
+ 'type': record_type,
+ 'name': name,
+ 'value': str(value),
+ 'ttl': ttl
+ }
+
+ response = self._request('PUT', url, json_data=data)
+
+ if response.status_code == 200:
+ if self.verbose:
+ self._log(syslog.LOG_INFO, f"Updated {name} {record_type} -> {value}")
+ return True, f"Updated {name} {record_type}"
+
+ error_msg = f"HTTP {response.status_code}"
+ self._log(syslog.LOG_ERR, f"Failed to update {name} {record_type}: {error_msg}")
+ return False, error_msg
+ else:
+ # Create new record
+ return self.create_record(zone_id, name, record_type, value, ttl)
+
+ except HetznerAPIError as e:
+ self._log(syslog.LOG_ERR, f"Failed to update record: {str(e)}")
+ return False, str(e)
+
+ def create_record(self, zone_id, name, record_type, value, ttl=300):
+ """
+ Create new DNS record.
+ Returns tuple (success: bool, message: str)
+ """
+ try:
+ url = '/records'
+ data = {
+ 'zone_id': zone_id,
+ 'type': record_type,
+ 'name': name,
+ 'value': str(value),
+ 'ttl': ttl
+ }
+
+ response = self._request('POST', url, json_data=data)
+
+ if response.status_code in [200, 201]:
+ if self.verbose:
+ self._log(syslog.LOG_INFO, f"Created {name} {record_type} -> {value}")
+ return True, f"Created {name} {record_type}"
+
+ error_msg = f"HTTP {response.status_code}"
+ self._log(syslog.LOG_ERR, f"Failed to create {name} {record_type}: {error_msg}")
+ return False, error_msg
+
+ except HetznerAPIError as e:
+ self._log(syslog.LOG_ERR, f"Failed to create record: {str(e)}")
+ return False, str(e)
+
+ def delete_record(self, zone_id, name, record_type):
+ """
+ Delete a DNS record.
+ Returns tuple (success: bool, message: str)
+ """
+ try:
+ record_id = self._get_record_id(zone_id, name, record_type)
+
+ if not record_id:
+ return True, "Record not found (already deleted)"
+
+ response = self._request('DELETE', f'/records/{record_id}')
+
+ if response.status_code in [200, 204]:
+ if self.verbose:
+ self._log(syslog.LOG_INFO, f"Deleted {name} {record_type}")
+ return True, f"Deleted {name} {record_type}"
+
+ error_msg = f"HTTP {response.status_code}"
+ self._log(syslog.LOG_ERR, f"Failed to delete {name} {record_type}: {error_msg}")
+ return False, error_msg
+
+ except HetznerAPIError as e:
+ self._log(syslog.LOG_ERR, f"Failed to delete record: {str(e)}")
+ return False, str(e)
+
+
+def create_api(token, api_type='cloud', verbose=False):
+ """
+ Factory function to create the appropriate API instance.
+ api_type: 'cloud' for api.hetzner.cloud, 'dns' for dns.hetzner.com
+ """
+ if api_type == 'dns':
+ return HetznerLegacyAPI(token, verbose)
+ return HetznerCloudAPI(token, verbose)
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py
new file mode 100755
index 0000000000..8cee2d606a
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_records.py
@@ -0,0 +1,62 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ List DNS records for a zone
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+# All supported record types
+ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA']
+
+
+def main():
+ if len(sys.argv) < 3:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Usage: list_records.py [all]',
+ 'records': []
+ }))
+ sys.exit(1)
+
+ token = sys.argv[1].strip()
+ zone_id = sys.argv[2].strip()
+ # Optional third arg: 'all' to list all record types
+ list_all = len(sys.argv) > 3 and sys.argv[3].strip().lower() == 'all'
+
+ if not token or not zone_id:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Token and zone_id are required',
+ 'records': []
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+
+ # List all record types or just A/AAAA
+ record_types = ALL_RECORD_TYPES if list_all else ['A', 'AAAA']
+ records = api.list_records(zone_id, record_types)
+
+ # Sort records: first by type priority, then by name
+ type_order = {t: i for i, t in enumerate(ALL_RECORD_TYPES)}
+ records.sort(key=lambda r: (type_order.get(r['type'], 99), r['name']))
+
+ result = {
+ 'status': 'ok' if records is not None else 'error',
+ 'message': f'Found {len(records)} record(s)' if records else 'No records found or API error',
+ 'records': records if records else []
+ }
+
+ print(json.dumps(result))
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py
new file mode 100755
index 0000000000..7f4cd95a0c
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/list_zones.py
@@ -0,0 +1,49 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ List DNS zones for Hetzner Cloud API token
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def main():
+ token = None
+
+ if len(sys.argv) > 1:
+ token = sys.argv[1].strip()
+ else:
+ try:
+ token = sys.stdin.read().strip()
+ except Exception:
+ pass
+
+ if not token:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'No API token provided',
+ 'zones': []
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+ zones = api.list_zones()
+
+ result = {
+ 'status': 'ok' if zones else 'error',
+ 'message': f'Found {len(zones)} zone(s)' if zones else 'No zones found or API error',
+ 'zones': zones
+ }
+
+ print(json.dumps(result))
+ sys.exit(0 if zones else 1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py
new file mode 100755
index 0000000000..a646eeb003
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/refresh_status.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+All rights reserved.
+
+Refresh status of all entries from Hetzner DNS API
+"""
+
+import json
+import sys
+import os
+import xml.etree.ElementTree as ET
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def refresh_status():
+ """Refresh status of all configured entries from Hetzner"""
+ result = {
+ 'status': 'ok',
+ 'entries': [],
+ 'errors': []
+ }
+
+ try:
+ tree = ET.parse('/conf/config.xml')
+ root = tree.getroot()
+
+ hcloud = root.find('.//OPNsense/HCloudDNS')
+ if hcloud is None:
+ return {'status': 'ok', 'entries': [], 'message': 'No configuration found'}
+
+ # Load accounts (tokens)
+ accounts = {}
+ accounts_node = hcloud.find('accounts')
+ if accounts_node is not None:
+ for acc in accounts_node.findall('account'):
+ acc_uuid = acc.get('uuid', '')
+ if acc_uuid and acc.findtext('enabled', '1') == '1':
+ accounts[acc_uuid] = {
+ 'token': acc.findtext('apiToken', ''),
+ 'apiType': acc.findtext('apiType', 'cloud'),
+ 'name': acc.findtext('name', '')
+ }
+
+ # Get all entries
+ entries_node = hcloud.find('entries')
+ if entries_node is None:
+ return {'status': 'ok', 'entries': [], 'message': 'No entries configured'}
+
+ # Cache records by (account, zone_id) to minimize API calls
+ zone_records_cache = {}
+ api_cache = {} # Cache API instances per account
+
+ for entry in entries_node.findall('entry'):
+ entry_uuid = entry.get('uuid', '')
+ account_uuid = entry.findtext('account', '')
+ zone_id = entry.findtext('zoneId', '')
+ zone_name = entry.findtext('zoneName', '')
+ record_name = entry.findtext('recordName', '')
+ record_type = entry.findtext('recordType', 'A')
+ current_status = entry.findtext('status', 'pending')
+
+ if not zone_id or not record_name:
+ continue
+
+ # Get account/token for this entry
+ account = accounts.get(account_uuid)
+ if not account or not account['token']:
+ result['errors'].append({
+ 'uuid': entry_uuid,
+ 'error': f'No valid account/token for entry {record_name}.{zone_name}'
+ })
+ continue
+
+ # Get or create API instance for this account
+ if account_uuid not in api_cache:
+ api_cache[account_uuid] = HCloudAPI(account['token'], api_type=account['apiType'])
+ api = api_cache[account_uuid]
+
+ # Cache key includes account to handle different tokens
+ cache_key = f"{account_uuid}:{zone_id}"
+
+ # Get records for this zone (cached)
+ if cache_key not in zone_records_cache:
+ try:
+ zone_records_cache[cache_key] = api.list_records(zone_id)
+ except Exception as e:
+ result['errors'].append({
+ 'uuid': entry_uuid,
+ 'error': f'Failed to get records for zone {zone_name}: {str(e)}'
+ })
+ zone_records_cache[cache_key] = []
+
+ # Find matching record
+ hetzner_ip = None
+ record_id = None
+ for record in zone_records_cache[cache_key]:
+ if record.get('name') == record_name and record.get('type') == record_type:
+ hetzner_ip = record.get('value')
+ record_id = record.get('id')
+ break
+
+ entry_status = {
+ 'uuid': entry_uuid,
+ 'zoneName': zone_name,
+ 'recordName': record_name,
+ 'recordType': record_type,
+ 'hetznerIp': hetzner_ip,
+ 'recordId': record_id,
+ 'configStatus': current_status
+ }
+
+ if hetzner_ip:
+ entry_status['status'] = 'found'
+ else:
+ entry_status['status'] = 'not_found'
+
+ result['entries'].append(entry_status)
+
+ except ET.ParseError as e:
+ return {'status': 'error', 'message': f'Config parse error: {str(e)}'}
+ except Exception as e:
+ return {'status': 'error', 'message': str(e)}
+
+ return result
+
+
+def main():
+ result = refresh_status()
+ print(json.dumps(result, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py
new file mode 100755
index 0000000000..1c4a33216a
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/simulate_failover.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+All rights reserved.
+
+Failover Simulator for HCloudDNS
+Allows testing failover logic without actual gateway failures
+"""
+
+import json
+import sys
+import os
+import time
+
+STATE_FILE = '/var/run/hclouddns_state.json'
+SIMULATION_FILE = '/var/run/hclouddns_simulation.json'
+
+
+def load_state():
+ """Load gateway state from file"""
+ if os.path.exists(STATE_FILE):
+ try:
+ with open(STATE_FILE, 'r') as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError):
+ pass
+ return {'gateways': {}, 'entries': {}, 'failoverHistory': [], 'lastUpdate': 0}
+
+
+def load_simulation():
+ """Load simulation settings"""
+ if os.path.exists(SIMULATION_FILE):
+ try:
+ with open(SIMULATION_FILE, 'r') as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError):
+ pass
+ return {'active': False, 'simulatedDown': []}
+
+
+def save_simulation(sim):
+ """Save simulation settings"""
+ try:
+ with open(SIMULATION_FILE, 'w') as f:
+ json.dump(sim, f, indent=2)
+ except IOError as e:
+ sys.stderr.write(f"Error saving simulation: {e}\n")
+
+
+def simulate_gateway_down(gateway_uuid):
+ """Simulate a gateway going down"""
+ sim = load_simulation()
+ if gateway_uuid not in sim.get('simulatedDown', []):
+ sim.setdefault('simulatedDown', []).append(gateway_uuid)
+ sim['active'] = True
+ save_simulation(sim)
+ return {'status': 'ok', 'message': f'Gateway {gateway_uuid} simulated as DOWN', 'simulation': sim}
+
+
+def simulate_gateway_up(gateway_uuid):
+ """Simulate a gateway coming back up"""
+ sim = load_simulation()
+ if gateway_uuid in sim.get('simulatedDown', []):
+ sim['simulatedDown'].remove(gateway_uuid)
+ if not sim['simulatedDown']:
+ sim['active'] = False
+ save_simulation(sim)
+ return {'status': 'ok', 'message': f'Gateway {gateway_uuid} simulated as UP', 'simulation': sim}
+
+
+def clear_simulation():
+ """Clear all simulations and reset gateway upSince for immediate failback"""
+ sim = {'active': False, 'simulatedDown': []}
+ save_simulation(sim)
+
+ # Also update state file to allow immediate failback
+ # by setting upSince to a time in the past for all gateways
+ state = load_state()
+ past_time = int(time.time()) - 3600 # 1 hour ago
+ for uuid in state.get('gateways', {}):
+ state['gateways'][uuid]['upSince'] = past_time
+ state['gateways'][uuid]['status'] = 'up'
+ state['gateways'][uuid]['simulated'] = False
+ try:
+ with open(STATE_FILE, 'w') as f:
+ json.dump(state, f, indent=2)
+ except IOError:
+ pass
+
+ return {'status': 'ok', 'message': 'Simulation cleared', 'simulation': sim}
+
+
+def get_simulation_status():
+ """Get current simulation status"""
+ sim = load_simulation()
+ state = load_state()
+
+ result = {
+ 'status': 'ok',
+ 'simulation': sim,
+ 'gateways': {}
+ }
+
+ for uuid, gw_state in state.get('gateways', {}).items():
+ is_simulated_down = uuid in sim.get('simulatedDown', [])
+ result['gateways'][uuid] = {
+ 'realStatus': gw_state.get('status', 'unknown'),
+ 'simulatedDown': is_simulated_down,
+ 'effectiveStatus': 'down' if is_simulated_down else gw_state.get('status', 'unknown'),
+ 'ipv4': gw_state.get('ipv4'),
+ 'ipv6': gw_state.get('ipv6')
+ }
+
+ return result
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(json.dumps({'status': 'error', 'message': 'Usage: simulate_failover.py [gateway_uuid]'}))
+ sys.exit(1)
+
+ action = sys.argv[1]
+
+ if action == 'down':
+ if len(sys.argv) < 3:
+ print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'}))
+ sys.exit(1)
+ result = simulate_gateway_down(sys.argv[2])
+
+ elif action == 'up':
+ if len(sys.argv) < 3:
+ print(json.dumps({'status': 'error', 'message': 'Gateway UUID required'}))
+ sys.exit(1)
+ result = simulate_gateway_up(sys.argv[2])
+
+ elif action == 'clear':
+ result = clear_simulation()
+
+ elif action == 'status':
+ result = get_simulation_status()
+
+ else:
+ result = {'status': 'error', 'message': f'Unknown action: {action}'}
+
+ print(json.dumps(result, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py
new file mode 100755
index 0000000000..4a93a58d07
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/status.py
@@ -0,0 +1,124 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Get status of HCloudDNS accounts
+"""
+import sys
+import json
+import os
+import time
+from xml.etree import ElementTree
+
+STATE_PATH = '/var/cache/hclouddns'
+CONFIG_PATH = '/conf/config.xml'
+
+
+def get_config():
+ """Read HCloudDNS configuration from OPNsense config.xml"""
+ try:
+ tree = ElementTree.parse(CONFIG_PATH)
+ root = tree.getroot()
+
+ hcloud = root.find('.//OPNsense/HCloudDNS')
+ if hcloud is None:
+ return None
+
+ config = {
+ 'general': {},
+ 'accounts': []
+ }
+
+ general = hcloud.find('general')
+ if general is not None:
+ config['general'] = {
+ 'enabled': general.findtext('enabled', '0') == '1',
+ 'verbose': general.findtext('verbose', '0') == '1'
+ }
+
+ accounts = hcloud.find('accounts')
+ if accounts is not None:
+ for account in accounts.findall('account'):
+ acc = {
+ 'uuid': account.get('uuid', ''),
+ 'enabled': account.findtext('enabled', '0') == '1',
+ 'description': account.findtext('description', ''),
+ 'zoneName': account.findtext('zoneName', ''),
+ 'records': account.findtext('records', '').split(','),
+ 'updateIPv4': account.findtext('updateIPv4', '1') == '1',
+ 'updateIPv6': account.findtext('updateIPv6', '1') == '1',
+ }
+ acc['records'] = [r.strip() for r in acc['records'] if r.strip()]
+ config['accounts'].append(acc)
+
+ return config
+
+ except Exception:
+ return None
+
+
+def load_state(account_uuid):
+ """Load last known state for an account"""
+ state_file = os.path.join(STATE_PATH, f"{account_uuid}.json")
+ try:
+ if os.path.exists(state_file):
+ with open(state_file, 'r') as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {'ipv4': None, 'ipv6': None, 'last_update': 0}
+
+
+def format_time_ago(timestamp):
+ """Format timestamp as human-readable time ago"""
+ if not timestamp:
+ return 'Never'
+
+ diff = int(time.time()) - timestamp
+
+ if diff < 60:
+ return f"{diff} seconds ago"
+ elif diff < 3600:
+ return f"{diff // 60} minutes ago"
+ elif diff < 86400:
+ return f"{diff // 3600} hours ago"
+ else:
+ return f"{diff // 86400} days ago"
+
+
+def main():
+ config = get_config()
+
+ result = {
+ 'enabled': False,
+ 'accounts': []
+ }
+
+ if config:
+ result['enabled'] = config['general'].get('enabled', False)
+
+ for account in config['accounts']:
+ state = load_state(account['uuid'])
+
+ acc_status = {
+ 'uuid': account['uuid'],
+ 'description': account['description'],
+ 'enabled': account['enabled'],
+ 'zone': account['zoneName'],
+ 'records': account['records'],
+ 'current_ipv4': state.get('ipv4', 'Unknown'),
+ 'current_ipv6': state.get('ipv6', 'Unknown'),
+ 'last_update': state.get('last_update', 0),
+ 'last_update_formatted': format_time_ago(state.get('last_update', 0)),
+ 'update_ipv4': account['updateIPv4'],
+ 'update_ipv6': account['updateIPv6']
+ }
+ result['accounts'].append(acc_status)
+
+ print(json.dumps(result, indent=2))
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py
new file mode 100644
index 0000000000..fd043f2403
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/test_notify.py
@@ -0,0 +1,184 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Test notification channels for HCloudDNS
+"""
+import json
+import subprocess
+import urllib.request
+import urllib.error
+from xml.etree import ElementTree
+
+CONFIG_PATH = '/conf/config.xml'
+
+
+def get_notification_settings():
+ """Read notification settings from OPNsense config.xml"""
+ try:
+ tree = ElementTree.parse(CONFIG_PATH)
+ root = tree.getroot()
+
+ hcloud = root.find('.//OPNsense/HCloudDNS')
+ if hcloud is None:
+ return None
+
+ notifications = hcloud.find('notifications')
+ if notifications is None:
+ return None
+
+ return {
+ 'enabled': notifications.findtext('enabled', '0') == '1',
+ 'emailEnabled': notifications.findtext('emailEnabled', '0') == '1',
+ 'emailTo': notifications.findtext('emailTo', ''),
+ 'webhookEnabled': notifications.findtext('webhookEnabled', '0') == '1',
+ 'webhookUrl': notifications.findtext('webhookUrl', ''),
+ 'webhookMethod': notifications.findtext('webhookMethod', 'POST'),
+ 'ntfyEnabled': notifications.findtext('ntfyEnabled', '0') == '1',
+ 'ntfyServer': notifications.findtext('ntfyServer', 'https://ntfy.sh'),
+ 'ntfyTopic': notifications.findtext('ntfyTopic', ''),
+ 'ntfyPriority': notifications.findtext('ntfyPriority', 'default'),
+ }
+ except Exception:
+ return None
+
+
+def send_email(to_address):
+ """Send test email using OPNsense mail system"""
+ try:
+ subject = "HCloudDNS Test Notification"
+ body = "This is a test notification from HCloudDNS plugin.\n\nIf you received this, email notifications are working correctly."
+
+ # Use OPNsense's mail command
+ result = subprocess.run(
+ ['/usr/local/bin/mail', '-s', subject, to_address],
+ input=body.encode(),
+ capture_output=True,
+ timeout=30
+ )
+
+ if result.returncode == 0:
+ return {'success': True, 'message': f'Sent to {to_address}'}
+ else:
+ return {'success': False, 'message': result.stderr.decode()[:100]}
+ except subprocess.TimeoutExpired:
+ return {'success': False, 'message': 'Timeout sending email'}
+ except Exception as e:
+ return {'success': False, 'message': str(e)[:100]}
+
+
+def send_webhook(url, method):
+ """Send test webhook notification"""
+ try:
+ payload = {
+ 'event': 'test',
+ 'message': 'This is a test notification from HCloudDNS plugin',
+ 'timestamp': __import__('time').time(),
+ 'plugin': 'os-hclouddns'
+ }
+
+ data = json.dumps(payload).encode('utf-8')
+ headers = {'Content-Type': 'application/json'}
+
+ if method == 'GET':
+ # For GET, append as query params
+ import urllib.parse
+ params = urllib.parse.urlencode({'event': 'test', 'message': 'HCloudDNS test'})
+ url = f"{url}?{params}" if '?' not in url else f"{url}&{params}"
+ req = urllib.request.Request(url, headers=headers, method='GET')
+ else:
+ req = urllib.request.Request(url, data=data, headers=headers, method='POST')
+
+ with urllib.request.urlopen(req, timeout=10) as response:
+ return {'success': True, 'message': f'HTTP {response.status}'}
+ except urllib.error.HTTPError as e:
+ return {'success': False, 'message': f'HTTP {e.code}: {e.reason}'}
+ except urllib.error.URLError as e:
+ return {'success': False, 'message': str(e.reason)[:100]}
+ except Exception as e:
+ return {'success': False, 'message': str(e)[:100]}
+
+
+def send_ntfy(server, topic, priority):
+ """Send test ntfy notification"""
+ try:
+ url = f"{server.rstrip('/')}/{topic}"
+
+ priority_map = {
+ 'min': '1',
+ 'low': '2',
+ 'default': '3',
+ 'high': '4',
+ 'urgent': '5'
+ }
+
+ headers = {
+ 'Title': 'HCloudDNS Test',
+ 'Priority': priority_map.get(priority, '3'),
+ 'Tags': 'test,hclouddns'
+ }
+
+ message = "This is a test notification from HCloudDNS plugin."
+ req = urllib.request.Request(url, data=message.encode('utf-8'), headers=headers, method='POST')
+
+ with urllib.request.urlopen(req, timeout=10):
+ return {'success': True, 'message': f'Sent to {topic}'}
+ except urllib.error.HTTPError as e:
+ return {'success': False, 'message': f'HTTP {e.code}: {e.reason}'}
+ except urllib.error.URLError as e:
+ return {'success': False, 'message': str(e.reason)[:100]}
+ except Exception as e:
+ return {'success': False, 'message': str(e)[:100]}
+
+
+def main():
+ settings = get_notification_settings()
+
+ result = {
+ 'status': 'ok',
+ 'results': {}
+ }
+
+ if not settings:
+ result['status'] = 'error'
+ result['message'] = 'Could not read notification settings'
+ print(json.dumps(result))
+ return
+
+ if not settings['enabled']:
+ result['status'] = 'error'
+ result['message'] = 'Notifications are disabled'
+ print(json.dumps(result))
+ return
+
+ # Test each enabled channel
+ channels_tested = 0
+
+ if settings['emailEnabled'] and settings['emailTo']:
+ result['results']['email'] = send_email(settings['emailTo'])
+ channels_tested += 1
+
+ if settings['webhookEnabled'] and settings['webhookUrl']:
+ result['results']['webhook'] = send_webhook(settings['webhookUrl'], settings['webhookMethod'])
+ channels_tested += 1
+
+ if settings['ntfyEnabled'] and settings['ntfyTopic']:
+ result['results']['ntfy'] = send_ntfy(settings['ntfyServer'], settings['ntfyTopic'], settings['ntfyPriority'])
+ channels_tested += 1
+
+ if channels_tested == 0:
+ result['status'] = 'error'
+ result['message'] = 'No notification channels configured'
+ else:
+ # Check if any succeeded
+ successes = sum(1 for r in result['results'].values() if r.get('success'))
+ if successes == 0:
+ result['status'] = 'error'
+ result['message'] = 'All notification tests failed'
+
+ print(json.dumps(result))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py
new file mode 100644
index 0000000000..db2a51c980
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_record.py
@@ -0,0 +1,78 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Update an existing DNS record at Hetzner
+"""
+import sys
+import json
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def main():
+ # Expected args: token zone_id record_name record_type value ttl
+ if len(sys.argv) < 7:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Usage: update_record.py '
+ }))
+ sys.exit(1)
+
+ token = sys.argv[1].strip()
+ zone_id = sys.argv[2].strip()
+ record_name = sys.argv[3].strip()
+ record_type = sys.argv[4].strip().upper()
+ value = sys.argv[5].strip()
+ ttl = int(sys.argv[6].strip()) if sys.argv[6].strip().isdigit() else 300
+
+ if not all([token, zone_id, record_name, value]):
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Missing required parameters'
+ }))
+ sys.exit(1)
+
+ # Support all common record types
+ supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA']
+ if record_type not in supported_types:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': f'Unsupported record type: {record_type}. Supported: {", ".join(supported_types)}'
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+
+ # TXT records need to be quoted for Hetzner API
+ if record_type == 'TXT' and not value.startswith('"'):
+ value = f'"{value}"'
+
+ try:
+ success, message = api.update_record(zone_id, record_name, record_type, value, ttl)
+ if success:
+ print(json.dumps({
+ 'status': 'ok',
+ 'message': f'Record {record_name} ({record_type}) updated successfully',
+ 'unchanged': message == 'unchanged'
+ }))
+ sys.exit(0)
+ else:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': f'Failed to update record: {message}'
+ }))
+ sys.exit(1)
+ except Exception as e:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': str(e)
+ }))
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py
new file mode 100755
index 0000000000..a2898f5db9
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records.py
@@ -0,0 +1,319 @@
+#!/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 parse_ttl(ttl_raw):
+ """Parse TTL value from config - handles '_60' format and plain '60'"""
+ if not ttl_raw:
+ return 300
+ # Handle OptionField format: "_60" -> 60 or "opt60" -> 60
+ if ttl_raw.startswith('_'):
+ ttl_raw = ttl_raw[1:]
+ elif ttl_raw.startswith('opt'):
+ ttl_raw = ttl_raw[3:]
+ try:
+ return int(ttl_raw)
+ except ValueError:
+ return 300
+
+
+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': parse_ttl(account.findtext('ttl', '300'))
+ }
+ # Filter empty records
+ acc['records'] = [r.strip() for r in acc['records'] if r.strip()]
+ config['accounts'].append(acc)
+
+ return config
+
+ except Exception as e:
+ syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to read config: {str(e)}")
+ return None
+
+
+def get_current_ip(method, interface=None, ip_version=4):
+ """Get current public IP address"""
+ if method == 'if' and interface:
+ # Get IP from interface
+ try:
+ family = 'inet6' if ip_version == 6 else 'inet'
+ cmd = f"ifconfig {interface} | grep '{family} ' | head -1"
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
+
+ if result.returncode == 0 and result.stdout:
+ # Parse IP from ifconfig output
+ line = result.stdout.strip()
+ if ip_version == 6:
+ # inet6 fe80::1%em0 prefixlen 64 scopeid 0x1
+ match = re.search(r'inet6\s+([0-9a-fA-F:]+)', line)
+ if match:
+ ip = match.group(1)
+ # Skip link-local addresses
+ if not ip.startswith('fe80'):
+ return ip
+ else:
+ # inet 192.168.1.1 netmask 0xffffff00 broadcast 192.168.1.255
+ match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line)
+ if match:
+ return match.group(1)
+ except Exception as e:
+ syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to get IP from interface: {str(e)}")
+
+ else:
+ # Use web service
+ services = {
+ 'web_ipify': ('https://api.ipify.org', 'https://api6.ipify.org'),
+ 'web_ip4only': ('https://ip4only.me/api/', None),
+ 'web_ip6only': (None, 'https://ip6only.me/api/'),
+ 'web_dyndns': ('http://checkip.dyndns.org', None),
+ 'web_freedns': ('https://freedns.afraid.org/dynamic/check.php', None),
+ 'web_he': ('http://checkip.dns.he.net', None),
+ }
+
+ urls = services.get(method, ('https://api.ipify.org', 'https://api6.ipify.org'))
+ url = urls[1] if ip_version == 6 else urls[0]
+
+ if url:
+ try:
+ import requests
+ response = requests.get(url, timeout=10)
+ if response.status_code == 200:
+ # Extract IP from response
+ text = response.text.strip()
+ if ip_version == 6:
+ match = re.search(r'([0-9a-fA-F:]+:[0-9a-fA-F:]+)', text)
+ else:
+ match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
+ if match:
+ return match.group(1)
+ except Exception as e:
+ syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to get IP from {url}: {str(e)}")
+
+ return None
+
+
+def load_state(account_uuid):
+ """Load last known state for an account"""
+ state_file = os.path.join(STATE_PATH, f"{account_uuid}.json")
+ try:
+ if os.path.exists(state_file):
+ with open(state_file, 'r') as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {'ipv4': None, 'ipv6': None, 'last_update': 0}
+
+
+def save_state(account_uuid, state):
+ """Save state for an account"""
+ os.makedirs(STATE_PATH, exist_ok=True)
+ state_file = os.path.join(STATE_PATH, f"{account_uuid}.json")
+ try:
+ with open(state_file, 'w') as f:
+ json.dump(state, f)
+ except Exception as e:
+ syslog.syslog(syslog.LOG_ERR, f"HCloudDNS: Failed to save state: {str(e)}")
+
+
+def update_account(account, verbose=False):
+ """Update DNS records for a single account"""
+ results = []
+
+ api = HCloudAPI(account['apiToken'], verbose=verbose)
+
+ # Get current IPs
+ current_ipv4 = None
+ current_ipv6 = None
+
+ if account['updateIPv4']:
+ current_ipv4 = get_current_ip(account['checkip'], account['checkipInterface'], 4)
+ if verbose and current_ipv4:
+ syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Current IPv4: {current_ipv4}")
+
+ if account['updateIPv6']:
+ current_ipv6 = get_current_ip(account['checkip'], account['checkipInterface'], 6)
+ if verbose and current_ipv6:
+ syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Current IPv6: {current_ipv6}")
+
+ if not current_ipv4 and not current_ipv6:
+ syslog.syslog(syslog.LOG_WARNING, f"HCloudDNS: [{account['description']}] No IP address detected")
+ return [{'status': 'error', 'message': 'No IP address detected'}]
+
+ # Load previous state
+ state = load_state(account['uuid'])
+
+ # Check if update needed
+ ipv4_changed = current_ipv4 and current_ipv4 != state.get('ipv4')
+ ipv6_changed = current_ipv6 and current_ipv6 != state.get('ipv6')
+
+ if not ipv4_changed and not ipv6_changed:
+ if verbose:
+ syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: [{account['description']}] No IP change detected")
+ return [{'status': 'ok', 'message': 'No update needed'}]
+
+ # Update each record
+ for record_spec in account['records']:
+ # record_spec format: "name:type" e.g. "www:A" or "@:AAAA"
+ if ':' in record_spec:
+ name, rtype = record_spec.split(':', 1)
+ else:
+ # Default: update both A and AAAA
+ name = record_spec
+ rtype = None
+
+ # Determine which updates to perform
+ updates = []
+ if rtype:
+ if rtype == 'A' and current_ipv4 and ipv4_changed:
+ updates.append(('A', current_ipv4))
+ elif rtype == 'AAAA' and current_ipv6 and ipv6_changed:
+ updates.append(('AAAA', current_ipv6))
+ else:
+ if current_ipv4 and ipv4_changed:
+ updates.append(('A', current_ipv4))
+ if current_ipv6 and ipv6_changed:
+ updates.append(('AAAA', current_ipv6))
+
+ for record_type, ip in updates:
+ success, message = api.update_record(
+ account['zoneId'],
+ name,
+ record_type,
+ ip,
+ account['ttl']
+ )
+
+ result = {
+ 'record': f"{name}.{account['zoneName']}",
+ 'type': record_type,
+ 'ip': ip,
+ 'status': 'ok' if success else 'error',
+ 'message': message
+ }
+ results.append(result)
+
+ if success:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ f"HCloudDNS: [{account['description']}] Updated {name} {record_type} -> {ip}"
+ )
+ else:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ f"HCloudDNS: [{account['description']}] Failed to update {name} {record_type}: {message}"
+ )
+
+ # Save state if any updates succeeded
+ if any(r['status'] == 'ok' for r in results):
+ if current_ipv4 and ipv4_changed:
+ state['ipv4'] = current_ipv4
+ if current_ipv6 and ipv6_changed:
+ state['ipv6'] = current_ipv6
+ import time
+ state['last_update'] = int(time.time())
+ save_state(account['uuid'], state)
+
+ return results
+
+
+def main():
+ syslog.openlog('HCloudDNS', syslog.LOG_PID, syslog.LOG_DAEMON)
+
+ config = get_config()
+ if not config:
+ print(json.dumps({
+ 'status': 'error',
+ 'message': 'Failed to read configuration'
+ }))
+ sys.exit(1)
+
+ if not config['general'].get('enabled', False):
+ print(json.dumps({
+ 'status': 'ok',
+ 'message': 'HCloudDNS is disabled'
+ }))
+ sys.exit(0)
+
+ verbose = config['general'].get('verbose', False)
+ all_results = []
+
+ for account in config['accounts']:
+ if not account['enabled']:
+ continue
+
+ if verbose:
+ syslog.syslog(syslog.LOG_INFO, f"HCloudDNS: Processing account [{account['description']}]")
+
+ results = update_account(account, verbose)
+ all_results.append({
+ 'account': account['description'],
+ 'results': results
+ })
+
+ print(json.dumps({
+ 'status': 'ok',
+ 'accounts': all_results
+ }))
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py
new file mode 100755
index 0000000000..979ff58af3
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/update_records_v2.py
@@ -0,0 +1,1088 @@
+#!/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'
+CONFIG_FILE = '/conf/config.xml'
+
+
+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 add_history_entry(entry, account, old_ip, new_ip, action='update'):
+ """
+ Add a history entry to config.xml for DNS change tracking.
+ This allows users to see all IP changes over time.
+ """
+ import uuid as uuid_mod
+ import fcntl
+
+ try:
+ # Use file locking for safe concurrent access
+ with open(CONFIG_FILE, 'r+') as f:
+ fcntl.flock(f, fcntl.LOCK_EX)
+ try:
+ content = f.read()
+ tree = ET.ElementTree(ET.fromstring(content))
+ root = tree.getroot()
+
+ hcloud = root.find('.//OPNsense/HCloudDNS')
+ if hcloud is None:
+ return False
+
+ # Find or create history section
+ history = hcloud.find('history')
+ if history is None:
+ history = ET.SubElement(hcloud, 'history')
+
+ # Create new change entry
+ change = ET.SubElement(history, 'change')
+ change.set('uuid', str(uuid_mod.uuid4()))
+
+ # Add all required fields
+ ET.SubElement(change, 'timestamp').text = str(int(time.time()))
+ ET.SubElement(change, 'action').text = action
+ ET.SubElement(change, 'accountUuid').text = account.get('uuid', '')
+ ET.SubElement(change, 'accountName').text = account.get('name', '')
+ ET.SubElement(change, 'zoneId').text = entry.get('zoneId', '')
+ ET.SubElement(change, 'zoneName').text = entry.get('zoneName', '')
+ ET.SubElement(change, 'recordName').text = entry.get('recordName', '')
+ ET.SubElement(change, 'recordType').text = entry.get('recordType', '')
+ ET.SubElement(change, 'oldValue').text = old_ip or ''
+ ET.SubElement(change, 'oldTtl').text = str(entry.get('ttl', 300))
+ ET.SubElement(change, 'newValue').text = new_ip or ''
+ ET.SubElement(change, 'newTtl').text = str(entry.get('ttl', 300))
+ ET.SubElement(change, 'reverted').text = '0'
+
+ # Write back
+ f.seek(0)
+ f.truncate()
+ tree.write(f, encoding='unicode', xml_declaration=True)
+
+ log(f"History: {action} {entry['recordName']}.{entry['zoneName']} "
+ f"{entry['recordType']} {old_ip} -> {new_ip}")
+ return True
+
+ finally:
+ fcntl.flock(f, fcntl.LOCK_UN)
+
+ except Exception as e:
+ log(f"Failed to add history entry: {str(e)}", syslog.LOG_ERR)
+ return False
+
+
+def cleanup_old_history(retention_days):
+ """Remove history entries older than retention_days"""
+ import fcntl
+
+ if retention_days <= 0:
+ return 0
+
+ cutoff_time = int(time.time()) - (retention_days * 86400)
+ removed = 0
+
+ try:
+ with open(CONFIG_FILE, 'r+') as f:
+ fcntl.flock(f, fcntl.LOCK_EX)
+ try:
+ content = f.read()
+ tree = ET.ElementTree(ET.fromstring(content))
+ root = tree.getroot()
+
+ hcloud = root.find('.//OPNsense/HCloudDNS')
+ if hcloud is None:
+ return 0
+
+ history = hcloud.find('history')
+ if history is None:
+ return 0
+
+ # Find entries to remove
+ to_remove = []
+ for change in history.findall('change'):
+ timestamp = int(change.findtext('timestamp', '0'))
+ if timestamp < cutoff_time:
+ to_remove.append(change)
+
+ # Remove old entries
+ for change in to_remove:
+ history.remove(change)
+ removed += 1
+
+ if removed > 0:
+ f.seek(0)
+ f.truncate()
+ tree.write(f, encoding='unicode', xml_declaration=True)
+ log(f"History cleanup: removed {removed} entries older than {retention_days} days")
+
+ finally:
+ fcntl.flock(f, fcntl.LOCK_UN)
+
+ except Exception as e:
+ log(f"Failed to cleanup history: {str(e)}", syslog.LOG_ERR)
+
+ return removed
+
+
+def parse_ttl(ttl_raw):
+ """Parse TTL value from config - handles '_60' format and plain '60'"""
+ if not ttl_raw:
+ return 300
+ # Handle OptionField format: "_60" -> 60 or "opt60" -> 60
+ if ttl_raw.startswith('_'):
+ ttl_raw = ttl_raw[1:]
+ elif ttl_raw.startswith('opt'):
+ ttl_raw = ttl_raw[3:]
+ try:
+ return int(ttl_raw)
+ except ValueError:
+ return 300
+
+
+def send_ntfy(settings, title, message, tags=''):
+ """Send notification via ntfy"""
+ import urllib.request
+ import urllib.error
+
+ if not settings.get('ntfyEnabled') or not settings.get('ntfyTopic'):
+ return False
+
+ try:
+ server = settings.get('ntfyServer', 'https://ntfy.sh').rstrip('/')
+ topic = settings['ntfyTopic']
+ url = f"{server}/{topic}"
+
+ priority_map = {
+ 'min': '1', 'low': '2', 'default': '3', 'high': '4', 'urgent': '5'
+ }
+ priority = priority_map.get(settings.get('ntfyPriority', 'default'), '3')
+
+ headers = {
+ 'Title': title,
+ 'Priority': priority,
+ }
+ if tags:
+ headers['Tags'] = tags
+
+ req = urllib.request.Request(
+ url,
+ data=message.encode('utf-8'),
+ headers=headers,
+ method='POST'
+ )
+
+ with urllib.request.urlopen(req, timeout=10):
+ log(f"Sent ntfy notification: {title}")
+ return True
+ except Exception as e:
+ log(f"Failed to send ntfy notification: {e}", syslog.LOG_ERR)
+ return False
+
+
+def send_webhook(settings, event_type, data):
+ """Send notification via webhook"""
+ import urllib.request
+ import urllib.error
+
+ if not settings.get('webhookEnabled') or not settings.get('webhookUrl'):
+ return False
+
+ try:
+ url = settings['webhookUrl']
+ method = settings.get('webhookMethod', 'POST')
+
+ payload = {
+ 'event': event_type,
+ 'timestamp': int(time.time()),
+ 'plugin': 'os-hclouddns',
+ **data
+ }
+
+ json_data = json.dumps(payload).encode('utf-8')
+ headers = {'Content-Type': 'application/json'}
+
+ req = urllib.request.Request(url, data=json_data, headers=headers, method=method)
+
+ with urllib.request.urlopen(req, timeout=10):
+ log(f"Sent webhook notification: {event_type}")
+ return True
+ except Exception as e:
+ log(f"Failed to send webhook notification: {e}", syslog.LOG_ERR)
+ return False
+
+
+def send_notification(config, event_type, entry, old_ip=None, new_ip=None, error_msg=None):
+ """Send notifications for DNS events based on configuration (single event)"""
+ notifications = config.get('notifications', {})
+
+ if not notifications.get('enabled'):
+ return
+
+ # Check if this event type should trigger a notification
+ should_notify = False
+ if event_type == 'update' and notifications.get('notifyOnUpdate'):
+ should_notify = True
+ elif event_type == 'failover' and notifications.get('notifyOnFailover'):
+ should_notify = True
+ elif event_type == 'failback' and notifications.get('notifyOnFailback'):
+ should_notify = True
+ elif event_type == 'error' and notifications.get('notifyOnError'):
+ should_notify = True
+
+ if not should_notify:
+ return
+
+ # Build notification message
+ record_name = f"{entry['recordName']}.{entry['zoneName']}"
+
+ if event_type == 'update':
+ title = f"DNS Updated: {record_name}"
+ message = f"Record {record_name} ({entry['recordType']}) updated"
+ if old_ip and new_ip:
+ message += f"\nOld IP: {old_ip}\nNew IP: {new_ip}"
+ tags = 'arrows_counterclockwise,hclouddns'
+ elif event_type == 'failover':
+ title = f"DNS Failover: {record_name}"
+ message = f"Record {record_name} switched to failover gateway"
+ if new_ip:
+ message += f"\nNew IP: {new_ip}"
+ tags = 'warning,hclouddns'
+ elif event_type == 'failback':
+ title = f"DNS Failback: {record_name}"
+ message = f"Record {record_name} returned to primary gateway"
+ if new_ip:
+ message += f"\nNew IP: {new_ip}"
+ tags = 'white_check_mark,hclouddns'
+ elif event_type == 'error':
+ title = f"DNS Error: {record_name}"
+ message = f"Error updating {record_name}: {error_msg or 'Unknown error'}"
+ tags = 'x,hclouddns'
+ else:
+ return
+
+ # Send to all enabled channels
+ send_ntfy(notifications, title, message, tags)
+ send_webhook(notifications, event_type, {
+ 'record': record_name,
+ 'type': entry['recordType'],
+ 'old_ip': old_ip,
+ 'new_ip': new_ip,
+ 'error': error_msg
+ })
+
+
+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 = {
+ 'enabled': False,
+ 'checkInterval': 300,
+ 'failoverEnabled': False,
+ 'failbackEnabled': True,
+ 'failbackDelay': 60,
+ 'verbose': False,
+ 'historyRetentionDays': 7,
+ 'accounts': {},
+ 'gateways': {},
+ 'entries': [],
+ 'notifications': {}
+ }
+
+ 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'))
+ config['historyRetentionDays'] = int(general.findtext('historyRetentionDays', '7'))
+
+ # 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': parse_ttl(entry.findtext('ttl', '300')),
+ 'currentIp': entry.findtext('currentIp', ''),
+ 'status': entry.findtext('status', 'pending')
+ })
+
+ # Notification settings
+ notifications = hcloud.find('notifications')
+ if notifications is not None:
+ config['notifications'] = {
+ 'enabled': notifications.findtext('enabled', '0') == '1',
+ 'notifyOnUpdate': notifications.findtext('notifyOnUpdate', '1') == '1',
+ 'notifyOnFailover': notifications.findtext('notifyOnFailover', '1') == '1',
+ 'notifyOnFailback': notifications.findtext('notifyOnFailback', '1') == '1',
+ 'notifyOnError': notifications.findtext('notifyOnError', '1') == '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 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 and TTL first
+ records = api.list_records(zone_id)
+ current_value = None
+ current_ttl = None
+ for rec in records:
+ if rec.get('name') == record_name and rec.get('type') == record_type:
+ current_value = rec.get('value')
+ current_ttl = rec.get('ttl')
+ break
+
+ # Only skip if BOTH value AND TTL match
+ if current_value == target_ip and current_ttl == ttl:
+ 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_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.
+ 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_duplicate': 0
+ }
+
+ # 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
+
+ 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']:
+ 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']
+ )
+
+ # Store entry with its dependencies for parallel processing
+ unique_entries[record_key] = {
+ 'entry': entry,
+ 'account': account,
+ 'api': api_cache[account_uuid]
+ }
+
+ # Phase 2: Process all unique entries in parallel
+ max_workers = min(10, len(unique_entries)) if unique_entries else 1
+
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ # Submit all tasks
+ future_to_entry = {
+ executor.submit(
+ _process_single_entry,
+ data['entry'],
+ data['account'],
+ data['api'],
+ config,
+ state,
+ state_lock
+ ): record_key
+ for record_key, data in unique_entries.items()
+ }
+
+ # Collect results as they complete
+ for future in as_completed(future_to_entry):
+ record_key = future_to_entry[future]
+ try:
+ result = future.result()
+
+ results['processed'] += 1
+
+ if result.get('updated'):
+ results['updated'] += 1
+ if result.get('update_event'):
+ batch_events['updates'].append(result['update_event'])
+
+ if result.get('error'):
+ results['errors'] += 1
+ batch_events['errors'].append(result['error'])
+
+ if result.get('failover_event') == 'failover':
+ results['failovers'] += 1
+ if result.get('failover_notification'):
+ batch_events['failovers'].append(result['failover_notification'])
+ if result.get('failover_history'):
+ with state_lock:
+ state['failoverHistory'].append(result['failover_history'])
+
+ elif result.get('failover_event') == 'failback':
+ results['failbacks'] += 1
+ if result.get('failover_notification'):
+ batch_events['failbacks'].append(result['failover_notification'])
+ if result.get('failover_history'):
+ with state_lock:
+ state['failoverHistory'].append(result['failover_history'])
+
+ except Exception as e:
+ log(f"Error processing entry {record_key}: {str(e)}", syslog.LOG_ERR)
+ results['errors'] += 1
+ batch_events['errors'].append({
+ 'record': f"{record_key[1]}.unknown",
+ 'type': record_key[2],
+ 'error': str(e)
+ })
+
+ # Trim failover history to last 100 entries
+ 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
+
+
+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)
+
+ # Cleanup old history entries
+ if config['historyRetentionDays'] > 0:
+ cleanup_old_history(config['historyRetentionDays'])
+
+ 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.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:
+ result['message'] += f", {update_results['failbacks']} failbacks"
+ if update_results['errors'] > 0:
+ result['status'] = 'warning'
+ result['message'] += f", {update_results['errors']} errors"
+
+ print(json.dumps(result, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py
new file mode 100755
index 0000000000..577115846a
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/validate_token.py
@@ -0,0 +1,52 @@
+#!/usr/local/bin/python3
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Validate Hetzner Cloud API token for HCloudDNS plugin
+"""
+import sys
+import json
+import os
+
+# Add script directory to path for local imports
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from hcloud_api import HCloudAPI
+
+
+def main():
+ # Token passed as argument or via stdin
+ token = None
+
+ if len(sys.argv) > 1:
+ token = sys.argv[1].strip()
+ else:
+ # Read from stdin (for security - avoids token in process list)
+ try:
+ token = sys.stdin.read().strip()
+ except Exception:
+ pass
+
+ if not token:
+ print(json.dumps({
+ 'valid': False,
+ 'message': 'No API token provided',
+ 'zone_count': 0
+ }))
+ sys.exit(1)
+
+ api = HCloudAPI(token)
+ valid, message, zone_count = api.validate_token()
+
+ result = {
+ 'valid': valid,
+ 'message': message,
+ 'zone_count': zone_count
+ }
+
+ print(json.dumps(result))
+ sys.exit(0 if valid else 1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py
new file mode 100644
index 0000000000..d6f3646aad
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_cloud.py
@@ -0,0 +1,357 @@
+"""
+ 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) 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
+
+ _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 _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()
+ 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 using set_records action.
+
+ Uses the proper rrset-actions endpoint which correctly updates RRsets.
+ Actions are async and will be waited upon for completion.
+ """
+ url = f"{self._api_base}/zones/{zone_id}/rrsets/{record_name}/{record_type}/actions/set_records"
+
+ 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 updating record: HTTP %d - %s" % (
+ self.description, response.status_code, response.text
+ )
+ )
+ 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 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 with async action handling."""
+ 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
+
+ # 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,
+ "Account %s created %s %s with %s" % (
+ self.description, record_name, record_type, address
+ )
+ )
+
+ return True
+
+ def _extract_record_name(self, hostname, zone_name):
+ """Extract record name from hostname, handling FQDN format"""
+ # Remove trailing dot if present
+ hostname = hostname.rstrip('.')
+
+ # Extract record name from FQDN if needed
+ if hostname.endswith('.' + zone_name):
+ record_name = hostname[:-len(zone_name) - 1]
+ elif hostname == zone_name:
+ record_name = '@'
+ else:
+ record_name = hostname
+
+ # Handle root domain
+ if not record_name or record_name == '@':
+ record_name = '@'
+
+ return record_name
+
+ def execute(self):
+ if super().execute():
+ record_type = "AAAA" if ':' in str(self.current_address) else "A"
+ headers = self._get_headers()
+
+ # Get zone ID
+ zone_id = self._get_zone_id(headers)
+ if not zone_id:
+ return False
+
+ zone_name = self._get_zone_name()
+
+ # Get hostnames - can be comma-separated list
+ hostnames_raw = self.settings.get('hostnames', '')
+ hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()]
+
+ if not hostnames:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s no hostnames configured" % self.description
+ )
+ return False
+
+ all_success = True
+ for hostname in hostnames:
+ record_name = self._extract_record_name(hostname, zone_name)
+
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s updating %s (record: %s, type: %s) to %s" % (
+ self.description, hostname, record_name, record_type, self.current_address
+ )
+ )
+
+ # Check if record exists
+ existing = self._get_record(headers, zone_id, record_name, record_type)
+
+ if existing:
+ success = self._update_record(
+ headers, zone_id, record_name, record_type, self.current_address
+ )
+ else:
+ success = self._create_record(
+ headers, zone_id, record_name, record_type, self.current_address
+ )
+
+ if success:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s set new IP %s for %s" % (
+ self.description, self.current_address, hostname
+ )
+ )
+ else:
+ all_success = False
+
+ if all_success:
+ self.update_state(address=self.current_address)
+ return True
+
+ return False
diff --git a/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py
new file mode 100644
index 0000000000..08f5591e2e
--- /dev/null
+++ b/net/hclouddns/src/opnsense/scripts/ddclient/lib/account/hetzner_legacy.py
@@ -0,0 +1,310 @@
+"""
+ Copyright (c) 2025 Arcan Consulting (www.arcan-it.de)
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+ AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+ OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGE.
+
+ Hetzner DNS Console (Legacy) API provider for OPNsense DynDNS
+ Uses the old API at dns.hetzner.com - will be shut down May 2026
+ For zones not yet migrated to Hetzner Cloud Console
+"""
+import syslog
+import requests
+from . import BaseAccount
+
+
+class HetznerLegacy(BaseAccount):
+ _priority = 65535
+
+ _services = {
+ 'hetzner': 'dns.hetzner.com'
+ }
+
+ _api_base = "https://dns.hetzner.com/api/v1"
+
+ def __init__(self, account: dict):
+ super().__init__(account)
+
+ @staticmethod
+ def known_services():
+ # Match the existing 'hetzner' service key from DynDNS.xml
+ return {'hetzner': 'Hetzner DNS Console'}
+
+ @staticmethod
+ def match(account):
+ return account.get('service') in HetznerLegacy._services
+
+ def _get_headers(self):
+ return {
+ 'User-Agent': 'OPNsense-dyndns',
+ 'Auth-API-Token': self.settings.get('password', ''),
+ 'Content-Type': 'application/json'
+ }
+
+ def _get_zone_name(self):
+ """Get zone name from settings - try 'zone' field first, then 'username' as fallback"""
+ zone_name = self.settings.get('zone', '').strip()
+ if not zone_name:
+ # Fallback to username for backwards compatibility
+ zone_name = self.settings.get('username', '').strip()
+ return zone_name
+
+ def _get_zone_id(self, headers):
+ """Get zone ID by zone name"""
+ zone_name = self._get_zone_name()
+
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s looking for zone '%s' (zone field: '%s', username field: '%s')" % (
+ self.description,
+ zone_name,
+ self.settings.get('zone', ''),
+ self.settings.get('username', '')
+ )
+ )
+
+ url = f"{self._api_base}/zones"
+ response = requests.get(url, headers=headers)
+
+ if response.status_code != 200:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error fetching zones: HTTP %d - %s" % (
+ self.description, response.status_code, response.text
+ )
+ )
+ return None
+
+ try:
+ payload = response.json()
+ except requests.exceptions.JSONDecodeError:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error parsing JSON response [zones]: %s" % (self.description, response.text)
+ )
+ return None
+
+ zones = payload.get('zones', [])
+ for zone in zones:
+ if zone.get('name') == zone_name:
+ zone_id = zone.get('id')
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s found zone ID %s for %s" % (self.description, zone_id, zone_name)
+ )
+ return zone_id
+
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s zone '%s' not found" % (self.description, zone_name)
+ )
+ return None
+
+ def _get_record_id(self, headers, zone_id, record_name, record_type):
+ """Get record ID by name and type"""
+ url = f"{self._api_base}/records"
+ params = {'zone_id': zone_id}
+
+ response = requests.get(url, headers=headers, params=params)
+
+ if response.status_code != 200:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error fetching records: HTTP %d - %s" % (
+ self.description, response.status_code, response.text
+ )
+ )
+ return None
+
+ try:
+ payload = response.json()
+ except requests.exceptions.JSONDecodeError:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error parsing JSON response [records]: %s" % (self.description, response.text)
+ )
+ return None
+
+ records = payload.get('records', [])
+ for record in records:
+ if record.get('name') == record_name and record.get('type') == record_type:
+ record_id = record.get('id')
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s found record ID %s for %s %s" % (
+ self.description, record_id, record_name, record_type
+ )
+ )
+ return record_id
+
+ return None
+
+ def _update_record(self, headers, zone_id, record_id, record_name, record_type, address):
+ """Update existing record with new address"""
+ url = f"{self._api_base}/records/{record_id}"
+
+ data = {
+ 'zone_id': zone_id,
+ 'type': record_type,
+ 'name': record_name,
+ 'value': str(address),
+ 'ttl': int(self.settings.get('ttl', 300))
+ }
+
+ response = requests.put(url, headers=headers, json=data)
+
+ if response.status_code != 200:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error updating record: HTTP %d - %s" % (
+ self.description, response.status_code, response.text
+ )
+ )
+ return False
+
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s updated %s %s to %s" % (
+ self.description, record_name, record_type, address
+ )
+ )
+
+ return True
+
+ def _create_record(self, headers, zone_id, record_name, record_type, address):
+ """Create new record"""
+ url = f"{self._api_base}/records"
+
+ data = {
+ 'zone_id': zone_id,
+ 'type': record_type,
+ 'name': record_name,
+ 'value': str(address),
+ 'ttl': int(self.settings.get('ttl', 300))
+ }
+
+ response = requests.post(url, headers=headers, json=data)
+
+ if response.status_code not in [200, 201]:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s error creating record: HTTP %d - %s" % (
+ self.description, response.status_code, response.text
+ )
+ )
+ return False
+
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s created %s %s with %s" % (
+ self.description, record_name, record_type, address
+ )
+ )
+
+ return True
+
+ def _extract_record_name(self, hostname, zone_name):
+ """Extract record name from hostname, handling FQDN format"""
+ # Remove trailing dot if present
+ hostname = hostname.rstrip('.')
+
+ # Extract record name from FQDN if needed
+ if hostname.endswith('.' + zone_name):
+ record_name = hostname[:-len(zone_name) - 1]
+ elif hostname == zone_name:
+ record_name = '@'
+ else:
+ record_name = hostname
+
+ # Handle root domain
+ if not record_name or record_name == '@':
+ record_name = '@'
+
+ return record_name
+
+ def execute(self):
+ if super().execute():
+ record_type = "AAAA" if ':' in str(self.current_address) else "A"
+ headers = self._get_headers()
+
+ # Get zone ID
+ zone_id = self._get_zone_id(headers)
+ if not zone_id:
+ return False
+
+ zone_name = self._get_zone_name()
+
+ # Get hostnames - can be comma-separated list
+ hostnames_raw = self.settings.get('hostnames', '')
+ hostnames = [h.strip() for h in hostnames_raw.split(',') if h.strip()]
+
+ if not hostnames:
+ syslog.syslog(
+ syslog.LOG_ERR,
+ "Account %s no hostnames configured" % self.description
+ )
+ return False
+
+ all_success = True
+ for hostname in hostnames:
+ record_name = self._extract_record_name(hostname, zone_name)
+
+ if self.is_verbose:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s updating %s (record: %s, type: %s) to %s" % (
+ self.description, hostname, record_name, record_type, self.current_address
+ )
+ )
+
+ # Check if record exists
+ record_id = self._get_record_id(headers, zone_id, record_name, record_type)
+
+ if record_id:
+ success = self._update_record(
+ headers, zone_id, record_id, record_name, record_type, self.current_address
+ )
+ else:
+ success = self._create_record(
+ headers, zone_id, record_name, record_type, self.current_address
+ )
+
+ if success:
+ syslog.syslog(
+ syslog.LOG_NOTICE,
+ "Account %s set new IP %s for %s" % (
+ self.description, self.current_address, hostname
+ )
+ )
+ else:
+ all_success = False
+
+ if all_success:
+ self.update_state(address=self.current_address)
+ return True
+
+ return False
diff --git a/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf b/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf
new file mode 100644
index 0000000000..ae019e50ad
--- /dev/null
+++ b/net/hclouddns/src/opnsense/service/conf/actions.d/actions_hclouddns.conf
@@ -0,0 +1,119 @@
+[validate]
+command:/usr/local/opnsense/scripts/HCloudDNS/validate_token.py
+parameters:%s
+type:script_output
+message:Validating Hetzner Cloud API token
+
+[list.zones]
+command:/usr/local/opnsense/scripts/HCloudDNS/list_zones.py
+parameters:%s
+type:script_output
+message:Listing Hetzner Cloud DNS zones
+
+[list.records]
+command:/usr/local/opnsense/scripts/HCloudDNS/list_records.py
+parameters:%s %s
+type:script_output
+message:Listing DNS records for zone
+
+[list.allrecords]
+command:/usr/local/opnsense/scripts/HCloudDNS/list_records.py
+parameters:%s %s all
+type:script_output
+message:Listing all DNS records for zone
+
+[update]
+command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py
+parameters:
+type:script_output
+message:Updating Hetzner Cloud DNS records
+
+[status]
+command:/usr/local/opnsense/scripts/HCloudDNS/status.py
+parameters:
+type:script_output
+message:Getting HCloudDNS status
+
+[healthcheck]
+command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py healthcheck
+parameters:%s %s
+type:script_output
+message:Checking gateway health
+
+[getip]
+command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py getip
+parameters:%s %s
+type:script_output
+message:Getting gateway IP address
+
+[gatewaystatus]
+command:/usr/local/opnsense/scripts/HCloudDNS/gateway_health.py status
+parameters:
+type:script_output
+message:Getting all gateway status
+
+[gethetznerip]
+command:/usr/local/opnsense/scripts/HCloudDNS/get_hetzner_ip.py
+parameters:%s %s %s
+type:script_output
+message:Getting IP from Hetzner DNS
+
+[refreshstatus]
+command:/usr/local/opnsense/scripts/HCloudDNS/refresh_status.py
+parameters:
+type:script_output
+message:Refreshing entry status from Hetzner
+
+[updatev2]
+command:/usr/local/opnsense/scripts/HCloudDNS/update_records_v2.py
+parameters:
+type:script_output
+message:Updating DNS records with failover support
+
+[simulate.down]
+command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py down
+parameters:%s
+type:script_output
+message:Simulating gateway failure
+
+[simulate.up]
+command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py up
+parameters:%s
+type:script_output
+message:Simulating gateway recovery
+
+[simulate.clear]
+command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py clear
+parameters:
+type:script_output
+message:Clearing failover simulation
+
+[simulate.status]
+command:/usr/local/opnsense/scripts/HCloudDNS/simulate_failover.py status
+parameters:
+type:script_output
+message:Getting simulation status
+
+[dns.create]
+command:/usr/local/opnsense/scripts/HCloudDNS/create_record.py
+parameters:%s %s %s %s %s %s
+type:script_output
+message:Creating DNS record at Hetzner
+
+[dns.update]
+command:/usr/local/opnsense/scripts/HCloudDNS/update_record.py
+parameters:%s %s %s %s %s %s
+type:script_output
+message:Updating DNS record at Hetzner
+
+[dns.delete]
+command:/usr/local/opnsense/scripts/HCloudDNS/delete_record.py
+parameters:%s %s %s %s
+type:script_output
+message:Deleting DNS record at Hetzner
+
+[testnotify]
+command:/usr/local/opnsense/scripts/HCloudDNS/test_notify.py
+parameters:
+type:script_output
+message:Testing notification channels