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 @@ +
+ + account.enabled + + checkbox + Enable this API token + + + account.name + + text + Short name for this token (e.g. "Production", "Project A") + + + account.description + + text + Optional description + + + account.apiType + + dropdown + Cloud API for new zones, Legacy API for zones not yet migrated + + + account.apiToken + + password + Hetzner API Token + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml new file mode 100644 index 0000000000..9df8eaa92a --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogEntry.xml @@ -0,0 +1,71 @@ +
+ + entry.enabled + + checkbox + Enable this DNS entry for dynamic updates + + + entry.account + + dropdown + API token/account to use for this entry + + + entry.zoneId + + dropdown + Select the DNS zone for this record + + + entry.zoneName + + hidden + + + entry.recordName + + text + DNS record name (@ for root, www, mail, etc.) + + + entry.recordType + + dropdown + A for IPv4, AAAA for IPv6 + + + entry.primaryGateway + + dropdown + Main gateway to use for this record's IP + + + entry.failoverGateway + + dropdown + Backup gateway when primary is down (optional) + + + entry.ttl + + dropdown + Time to live - use 60s for fast DynDNS updates + + + header + + + + entry.currentIp + + info + Currently configured IP at Hetzner + + + entry.status + + info + Current status of this entry + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/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 @@ +
+ + header + + + + hclouddns.general.defaultTtl + + dropdown + Default Time-To-Live for new DynDNS entries. 60s recommended for dynamic IPs. + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml new file mode 100644 index 0000000000..2f41773e87 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogGateway.xml @@ -0,0 +1,38 @@ +
+ + gateway.enabled + + checkbox + Enable this gateway for DNS updates + + + gateway.name + + text + Friendly name for this gateway (e.g., "Glasfaser", "Kabel") + + + gateway.interface + + dropdown + WAN interface for this gateway + + + gateway.priority + + text + Gateway priority (1-100, lower = higher priority) + + + gateway.checkipMethod + + dropdown + How to determine the public IP for this gateway + + + gateway.healthCheckTarget + + text + IP or hostname to ping for health checks (default: 8.8.8.8) + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml new file mode 100644 index 0000000000..02343c8d21 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/dialogScheduled.xml @@ -0,0 +1,34 @@ +
+ + header + + + + hclouddns.general.cronEnabled + + checkbox + Enable periodic DNS updates via cron job. Disabled by default - automatic triggers (gateway events, IP changes) are usually sufficient. + + + hclouddns.general.cronInterval + + text + How often to run the update check. Default: 5 minutes. Range: 1-60 minutes. + + + header + + + + hclouddns.general.checkInterval + + text + Minimum time between IP checks during scheduled updates. Default: 300 (5 minutes). Range: 60-86400 + + + hclouddns.general.forceInterval + + text + Force DNS update even if IP unchanged. 0 = disabled. Default: 0. Range: 0-30 + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml new file mode 100644 index 0000000000..4baa3b1f4a --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/failover.xml @@ -0,0 +1,24 @@ +
+ + header + + + + hclouddns.general.failoverEnabled + + checkbox + Automatically switch DNS to backup gateway when primary fails (detected by OPNsense dpinger) + + + hclouddns.general.failbackEnabled + + checkbox + Automatically switch back to primary gateway when it becomes available again + + + hclouddns.general.failbackDelay + + text + Wait time before failback after primary gateway becomes available. Default: 60. Range: 0-600 + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml new file mode 100644 index 0000000000..5e2fbdae4e --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/controllers/OPNsense/HCloudDNS/forms/general.xml @@ -0,0 +1,20 @@ +
+ + hclouddns.general.enabled + + checkbox + Enable Hetzner Cloud Dynamic DNS Service + + + hclouddns.general.verbose + + checkbox + Write detailed log entries to syslog + + + hclouddns.general.historyRetentionDays + + text + Number of days to keep DNS change history for undo functionality (1-365, default: 7) + +
diff --git a/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml b/net/hclouddns/src/opnsense/mvc/app/models/OPNsense/HCloudDNS/ACL/ACL.xml new file mode 100644 index 0000000000..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._('Full DNS zone management for all your Hetzner DNS zones. Create, edit, and delete any DNS record type.') }} +
+ + +
+ + +
+ + +
+
+
+ + +
+
+
+ + + + + +
+
+ +

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

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

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

+

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

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

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

+

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

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

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

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

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

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

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

+ {{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_general_settings']) }} +
+
+ + + + + +
+
+ + + + {{ lang._('Status Dashboard') }} + +
+
+
+
+ + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt new file mode 100644 index 0000000000..1542363f58 --- /dev/null +++ b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/history.volt @@ -0,0 +1,256 @@ +{# + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Hetzner Cloud DNS - Change History +#} + + + +
+
+

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

+
+
+

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

+ +
+ + {{ lang._('History retention is configured in Settings (current:') }} ... {{ lang._('days).') }} +
+ + +
+
+
-
+
{{ lang._('Total') }}
+
+
+
-
+
{{ lang._('Creates') }}
+
+
+
-
+
{{ lang._('Updates') }}
+
+
+
-
+
{{ lang._('Deletes') }}
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('Time') }}{{ lang._('Action') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Old Value') }}{{ lang._('New Value') }}{{ lang._('Account') }}{{ lang._('Status') }}{{ lang._('Actions') }}
{{ lang._('Loading...') }}
+ +
+ + + +
+
+ + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/index.volt 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._('Service Status:') }} + {{ lang._('Loading...') }} + +
+
+ {{ lang._('Last refresh:') }} - + +
+
+ + +
+
+
+
0
+
{{ lang._('Gateways') }}
+
-
+
+
+
+
0
+
{{ lang._('Accounts') }}
+
-
+
+
+
+
0
+
{{ lang._('DNS Entries') }}
+
-
+
+
+ + +
+
+
+
+

0

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

0

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

0

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

0

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

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

+

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

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

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

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

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

+ + + + + + + + + + + + + + + + + +
ID{{ lang._('Enabled') }}{{ lang._('Name') }}{{ lang._('Interface') }}{{ lang._('Priority') }}{{ lang._('IP Detection') }}{{ lang._('Commands') }}
+ +
+ +
+ + +
+
+ {{ lang._('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._('Updates TTL for all DynDNS records at Hetzner') }} +
+ + +
+ + + + {{ lang._('Import existing A/AAAA records as DynDNS entries') }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ lang._('Enabled') }}{{ lang._('Account') }}{{ lang._('Zone') }}{{ lang._('Record') }}{{ lang._('Type') }}{{ lang._('Primary IP') }}{{ lang._('Failover IP') }}{{ lang._('Current IP') }}{{ lang._('Status') }}{{ lang._('Commands') }}
+ +
+ + + + +
+ + +
+
+

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

+

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

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

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

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

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

+
+ + {{ partial("layout_partials/base_form", ['fields': scheduledForm, 'id': 'frm_scheduled_settings']) }} + +
+ +
+
+
+ + +{{ partial("layout_partials/base_dialog", ['fields': gatewayForm, 'id': 'dialogGateway', 'label': lang._('Gateway')]) }} + + +{{ partial("layout_partials/base_dialog", ['fields': entryForm, 'id': 'dialogEntry', 'label': lang._('DNS Entry')]) }} + + + + + + + + diff --git a/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt b/net/hclouddns/src/opnsense/mvc/app/views/OPNsense/HCloudDNS/settings.volt new file mode 100644 index 0000000000..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._('API Accounts') }}

+
+
+

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

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

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

+
+
+

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

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

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

+
+
+

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

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

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

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

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

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

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

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

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

+
+

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

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

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

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

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

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

+

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

+
+ + +
+
+ +
+ + + + + +
+ +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ {{ lang._('No records selected') }} +
+
+ + +
+
+
+ {{ lang._('Enter your API token and click "Load Zones" to see available zones.') }} +
+
+
+
+
+
diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py new file mode 100644 index 0000000000..39e34cc293 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/create_record.py @@ -0,0 +1,77 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Create a new DNS record at Hetzner +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Expected args: token zone_id record_name record_type value ttl + if len(sys.argv) < 7: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: create_record.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + record_name = sys.argv[3].strip() + record_type = sys.argv[4].strip().upper() + value = sys.argv[5].strip() + ttl = int(sys.argv[6].strip()) if sys.argv[6].strip().isdigit() else 300 + + if not all([token, zone_id, record_name, value]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + # Support all common record types + supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR', 'SOA'] + if record_type not in supported_types: + print(json.dumps({ + 'status': 'error', + 'message': f'Unsupported record type: {record_type}. Supported: {", ".join(supported_types)}' + })) + sys.exit(1) + + api = HCloudAPI(token) + + # TXT records need to be quoted for Hetzner API + if record_type == 'TXT' and not value.startswith('"'): + value = f'"{value}"' + + try: + success, message = api.create_record(zone_id, record_name, record_type, value, ttl) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Record {record_name} ({record_type}) created successfully' + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to create record: {message}' + })) + sys.exit(1) + except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py new file mode 100644 index 0000000000..d6fe202449 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/delete_record.py @@ -0,0 +1,62 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Delete a DNS record at Hetzner +""" +import sys +import json +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def main(): + # Expected args: token zone_id record_name record_type + if len(sys.argv) < 5: + print(json.dumps({ + 'status': 'error', + 'message': 'Usage: delete_record.py ' + })) + sys.exit(1) + + token = sys.argv[1].strip() + zone_id = sys.argv[2].strip() + record_name = sys.argv[3].strip() + record_type = sys.argv[4].strip().upper() + + if not all([token, zone_id, record_name, record_type]): + print(json.dumps({ + 'status': 'error', + 'message': 'Missing required parameters' + })) + sys.exit(1) + + api = HCloudAPI(token) + + try: + success, message = api.delete_record(zone_id, record_name, record_type) + if success: + print(json.dumps({ + 'status': 'ok', + 'message': f'Record {record_name} ({record_type}) deleted successfully' + })) + sys.exit(0) + else: + print(json.dumps({ + 'status': 'error', + 'message': f'Failed to delete record: {message}' + })) + sys.exit(1) + except Exception as e: + print(json.dumps({ + 'status': 'error', + 'message': str(e) + })) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py new file mode 100755 index 0000000000..b8fe969854 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/gateway_health.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Gateway health check and IP detection for HCloudDNS +""" + +import json +import subprocess +import sys +import os +import socket +import urllib.request +import urllib.error +import ssl + +# State file for gateway status persistence +STATE_FILE = '/var/run/hclouddns_gateways.json' + +# IP check services +IP_SERVICES = { + 'web_ipify': { + 'ipv4': 'https://api.ipify.org', + 'ipv6': 'https://api6.ipify.org' + }, + 'web_dyndns': { + 'ipv4': 'http://checkip.dyndns.org', + 'ipv6': None + }, + 'web_freedns': { + 'ipv4': 'https://freedns.afraid.org/dynamic/check.php', + 'ipv6': None + }, + 'web_ip4only': { + 'ipv4': 'https://ip4only.me/api/', + 'ipv6': None + }, + 'web_ip6only': { + 'ipv4': None, + 'ipv6': 'https://ip6only.me/api/' + } +} + + +def load_state(): + """Load gateway state from file""" + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {'gateways': {}, 'lastCheck': 0} + + +def save_state(state): + """Save gateway state to file""" + try: + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + except IOError as e: + sys.stderr.write(f"Error saving state: {e}\n") + + +def get_interface_ip(interface, ipv6=False): + """Get IP address from interface using ifconfig""" + try: + result = subprocess.run( + ['ifconfig', interface], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + line = line.strip() + if ipv6 and line.startswith('inet6 ') and 'scopeid' not in line.lower(): + parts = line.split() + if len(parts) >= 2: + addr = parts[1].split('%')[0] + if not addr.startswith('fe80:'): + return addr + elif not ipv6 and line.startswith('inet '): + parts = line.split() + if len(parts) >= 2: + return parts[1] + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + return None + + +def get_web_ip(service, interface=None, source_ip=None, ipv6=False): + """Get public IP from web service, optionally binding to source IP""" + service_config = IP_SERVICES.get(service, {}) + url = service_config.get('ipv6' if ipv6 else 'ipv4') + + if not url: + return None + + try: + # Use curl if source_ip is specified (more reliable for source binding) + if source_ip: + cmd = ['curl', '-s', '--connect-timeout', '10', '--interface', source_ip, url] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if result.returncode == 0: + content = result.stdout.strip() + if 'dyndns' in service: + import re + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content) + if match: + return match.group(1) + elif 'ip4only' in service or 'ip6only' in service: + parts = content.split(',') + if len(parts) >= 2: + return parts[1].strip() + else: + if is_valid_ip(content): + return content + return None + + # Default: use urllib without source binding + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + request = urllib.request.Request(url, headers={'User-Agent': 'OPNsense-HCloudDNS/2.0'}) + + with urllib.request.urlopen(request, timeout=10, context=ctx) as response: + content = response.read().decode('utf-8').strip() + + if 'dyndns' in service: + import re + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', content) + if match: + return match.group(1) + elif 'ip4only' in service or 'ip6only' in service: + parts = content.split(',') + if len(parts) >= 2: + return parts[1].strip() + else: + if is_valid_ip(content): + return content + except (urllib.error.URLError, socket.timeout, subprocess.TimeoutExpired, Exception) as e: + sys.stderr.write(f"Error getting IP from {service}: {e}\n") + + return None + + +def is_valid_ip(ip): + """Check if string is a valid IP address""" + try: + socket.inet_pton(socket.AF_INET, ip) + return True + except socket.error: + try: + socket.inet_pton(socket.AF_INET6, ip) + return True + except socket.error: + return False + + +def quick_ping_check(target='8.8.8.8', count=1, timeout=2): + """ + Quick ping check for gateway connectivity. + Used as a simple fallback health check. + + Args: + target: IP or hostname to ping + count: Number of pings + timeout: Timeout in seconds + + Returns: + bool: True if ping succeeded + """ + cmd = ['ping', '-c', str(count), '-W', str(timeout), target] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * count + 2) + return result.returncode == 0 + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return False + + +def resolve_interface_name(interface): + """Resolve OPNsense interface name to physical interface and get its IP""" + # Map common OPNsense names to physical interfaces + # First try to get from config.xml + try: + import xml.etree.ElementTree as ET + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + iface_node = root.find(f'.//interfaces/{interface}') + if iface_node is not None: + phys_if = iface_node.findtext('if') + if phys_if: + return phys_if + except Exception: + pass + return interface + + +def get_gateway_ip(uuid, gateway_config): + """Get current IP for a gateway""" + interface = gateway_config.get('interface') + checkip_method = gateway_config.get('checkipMethod', 'web_ipify') + + result = { + 'status': 'ok', + 'uuid': uuid, + 'ipv4': None, + 'ipv6': None + } + + # Resolve interface name and get local IP for source binding + phys_interface = resolve_interface_name(interface) + local_ip = get_interface_ip(phys_interface, ipv6=False) + + if checkip_method == 'if': + result['ipv4'] = local_ip + result['ipv6'] = get_interface_ip(phys_interface, ipv6=True) + else: + # Use local_ip as source for web requests + result['ipv4'] = get_web_ip(checkip_method, phys_interface, source_ip=local_ip, ipv6=False) + result['ipv6'] = get_web_ip(checkip_method, phys_interface, source_ip=None, ipv6=True) + + if not result['ipv4'] and not result['ipv6']: + result['status'] = 'error' + result['message'] = 'Could not determine IP address' + + return result + + +def main(): + """Main entry point for configd actions""" + if len(sys.argv) < 2: + print(json.dumps({'status': 'error', 'message': 'No action specified'})) + sys.exit(1) + + action = sys.argv[1] + + if action == 'healthcheck': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'})) + sys.exit(1) + + uuid = sys.argv[2] + gateway_config = {} + if len(sys.argv) > 3: + try: + gateway_config = json.loads(sys.argv[3]) + except json.JSONDecodeError: + pass + + # Simple ping-based health check (dpinger handles real gateway monitoring) + target = gateway_config.get('healthCheckTarget', '8.8.8.8') + is_healthy = quick_ping_check(target, count=1, timeout=2) + result = { + 'uuid': uuid, + 'status': 'up' if is_healthy else 'down' + } + print(json.dumps(result)) + + elif action == 'getip': + if len(sys.argv) < 3: + print(json.dumps({'status': 'error', 'message': 'No gateway UUID specified'})) + sys.exit(1) + + uuid = sys.argv[2] + gateway_config = {} + if len(sys.argv) > 3: + try: + gateway_config = json.loads(sys.argv[3]) + except json.JSONDecodeError: + pass + + result = get_gateway_ip(uuid, gateway_config) + print(json.dumps(result)) + + elif action == 'status': + # Read gateways from OPNsense config and check their status + result = {'gateways': {}, 'lastCheck': 0} + try: + import xml.etree.ElementTree as ET + import time + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + + gateways_node = root.find('.//OPNsense/HCloudDNS/gateways') + if gateways_node is not None: + for gw in gateways_node.findall('gateway'): + uuid = gw.get('uuid') + if not uuid: + continue + + enabled = gw.findtext('enabled', '0') + if enabled != '1': + continue + + interface = gw.findtext('interface', '') + checkip_method = gw.findtext('checkipMethod', 'web_ipify') + health_target = gw.findtext('healthCheckTarget', '8.8.8.8') + + # Resolve interface and get IP + phys_if = resolve_interface_name(interface) + ipv4 = None + ipv6 = None + + if checkip_method == 'if': + ipv4 = get_interface_ip(phys_if, ipv6=False) + ipv6 = get_interface_ip(phys_if, ipv6=True) + else: + local_ip = get_interface_ip(phys_if, ipv6=False) + ipv4 = get_web_ip(checkip_method, phys_if, source_ip=local_ip, ipv6=False) + + # Quick health check (ping only for speed) + status = 'up' if quick_ping_check(health_target, count=1, timeout=2) else 'down' + + result['gateways'][uuid] = { + 'status': status, + 'ipv4': ipv4, + 'ipv6': ipv6 + } + + result['lastCheck'] = int(time.time()) + except Exception as e: + sys.stderr.write(f"Error getting gateway status: {e}\n") + + print(json.dumps(result)) + + else: + print(json.dumps({'status': 'error', 'message': f'Unknown action: {action}'})) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py new file mode 100755 index 0000000000..e6039de98e --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/get_hetzner_ip.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) +All rights reserved. + +Get current IP from Hetzner DNS for a specific record +""" + +import json +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from hcloud_api import HCloudAPI + + +def get_hetzner_ip(zone_id, record_name, record_type): + """Get current IP for a record from Hetzner DNS""" + # Read API token from config + try: + import xml.etree.ElementTree as ET + tree = ET.parse('/conf/config.xml') + root = tree.getroot() + token_node = root.find('.//OPNsense/HCloudDNS/apiToken') + if token_node is None or not token_node.text: + return {'status': 'error', 'message': 'No API token configured'} + token = token_node.text + except Exception as e: + return {'status': 'error', 'message': f'Config error: {str(e)}'} + + api = HCloudAPI(token) + + try: + records = api.list_records(zone_id) + for record in records: + if record.get('name') == record_name and record.get('type') == record_type: + return { + 'status': 'ok', + 'ip': record.get('value'), + 'recordId': record.get('id'), + 'ttl': record.get('ttl'), + 'modified': record.get('modified') + } + + return {'status': 'error', 'message': 'Record not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + +def main(): + if len(sys.argv) < 4: + print(json.dumps({'status': 'error', 'message': 'Usage: get_hetzner_ip.py '})) + sys.exit(1) + + zone_id = sys.argv[1] + record_name = sys.argv[2] + record_type = sys.argv[3] + + result = get_hetzner_ip(zone_id, record_name, record_type) + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py new file mode 100755 index 0000000000..b921b68b2e --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/hcloud_api.py @@ -0,0 +1,91 @@ +#!/usr/local/bin/python3 +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + Hetzner Cloud API wrapper for HCloudDNS OPNsense plugin + This is a compatibility wrapper - actual implementation is in lib/hetzner_api.py +""" +import os +import sys + +# Add lib directory to path +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib')) + +from hetzner_api import ( # noqa: E402 + HetznerCloudAPI, + HetznerLegacyAPI, + HetznerAPIError, + create_api +) + +# Re-export for backward compatibility +HCloudAPIError = HetznerAPIError + + +class HCloudAPI: + """ + Backward-compatible wrapper for Hetzner DNS API. + Delegates to HetznerCloudAPI or HetznerLegacyAPI based on api_type. + """ + + def __init__(self, token, api_type='cloud', verbose=False): + self._api = create_api(token, api_type, verbose) + self.api_type = api_type + self.verbose = verbose + + def validate_token(self): + return self._api.validate_token() + + def list_zones(self): + return self._api.list_zones() + + def get_zone_id(self, zone_name): + return self._api.get_zone_id(zone_name) + + def list_records(self, zone_id, record_types=None): + return self._api.list_records(zone_id, record_types) + + def get_record(self, zone_id, name, record_type): + return self._api.get_record(zone_id, name, record_type) + + def update_record(self, zone_id, name, record_type, value, ttl=300): + return self._api.update_record(zone_id, name, record_type, value, ttl) + + def create_record(self, zone_id, name, record_type, value, ttl=300): + return self._api.create_record(zone_id, name, record_type, value, ttl) + + def delete_record(self, zone_id, name, record_type): + return self._api.delete_record(zone_id, name, record_type) + + +# Export all for convenience +__all__ = [ + 'HCloudAPI', + 'HCloudAPIError', + 'HetznerCloudAPI', + 'HetznerLegacyAPI', + 'HetznerAPIError', + 'create_api' +] diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py new file mode 100644 index 0000000000..7d4ac3f8d1 --- /dev/null +++ b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/__init__.py @@ -0,0 +1,6 @@ +""" + Copyright (c) 2025 Arcan Consulting (www.arcan-it.de) + All rights reserved. + + Shared library for Hetzner DNS API access +""" diff --git a/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py b/net/hclouddns/src/opnsense/scripts/HCloudDNS/lib/hetzner_api.py new file mode 100644 index 0000000000..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